practicode
Advanced tools
+1
-1
@@ -456,3 +456,3 @@ # This file is automatically @generated by Cargo. | ||
| name = "practicode" | ||
| version = "0.2.1" | ||
| version = "0.2.2" | ||
| dependencies = [ | ||
@@ -459,0 +459,0 @@ "anyhow", |
+1
-1
| [package] | ||
| name = "practicode" | ||
| version = "0.2.1" | ||
| version = "0.2.2" | ||
| edition = "2024" | ||
@@ -5,0 +5,0 @@ description = "Offline-first programming language lessons and coding practice in a Rust terminal UI." |
+1
-1
| { | ||
| "name": "practicode", | ||
| "version": "0.2.1", | ||
| "version": "0.2.2", | ||
| "description": "Offline-first programming language lessons and coding practice in a Rust terminal UI.", | ||
@@ -5,0 +5,0 @@ "license": "MIT", |
+75
-10
| use crate::{ | ||
| core::{ | ||
| AppState, CLAUDE_AI_EFFORTS, LANGUAGES, PROBLEM_NOTES_PATH, Problem, Settings, | ||
| SyntaxLesson, UI_LANGUAGES, ensure_submission, ensure_syntax_submission, | ||
| normalize_ai_provider, regular_file_exists, render_problem, save_user_text, | ||
| syntax_lesson_study_context, ui_text, | ||
| AppState, BANK_PATH, CLAUDE_AI_EFFORTS, LANGUAGES, PROBLEM_NOTES_PATH, Problem, STATE_PATH, | ||
| Settings, SyntaxLesson, UI_LANGUAGES, ensure_submission, ensure_syntax_submission, | ||
| load_bank, load_state, normalize_ai_provider, regular_file_exists, render_problem, | ||
| save_bank, save_state, save_user_text, syntax_lesson_study_context, ui_text, | ||
| }, | ||
| process::{run_capture, sh_quote, shell_process, unique_temp_path, which}, | ||
| process::{RunOutput, run_capture, sh_quote, shell_process, unique_temp_path, which}, | ||
| }; | ||
| use anyhow::{Context, Result}; | ||
| use anyhow::{Context, Result, bail}; | ||
| use std::{ | ||
@@ -27,2 +27,3 @@ env, fs, | ||
| const CLAUDE_MODEL_FALLBACKS: &[&str] = &["sonnet", "opus", "fable", "claude-fable-5"]; | ||
| const PROBLEM_BANK_SCHEMA: &str = "problem_bank.json is an array. Preserve every existing problem. Each item must contain id and slug as strings; difficulty as a string; topics as an array of strings; title, statement, input, and output as objects mapping UI language codes to strings; examples and cases as arrays of objects with string input and output fields; answers as an object mapping code language names to complete source strings."; | ||
@@ -128,3 +129,3 @@ pub fn build_lesson_ai_prompt( | ||
| .env("PRACTICODE_AI_EFFORT", &state.settings.ai_effort); | ||
| match run_capture(&mut process, "", Duration::from_secs(900)) { | ||
| match run_problem_generation_command(root, Some(state), &mut process) { | ||
| Ok(run) if run.code == Some(0) => { | ||
@@ -193,3 +194,3 @@ let output = output_text(&run.stdout, &run.stderr); | ||
| .env("PRACTICODE_AI_EFFORT", &state.settings.ai_effort); | ||
| match run_capture(&mut process, "", Duration::from_secs(900)) { | ||
| match run_problem_generation_command(root, None, &mut process) { | ||
| Ok(run) if run.code == Some(0) => { | ||
@@ -206,2 +207,66 @@ AiGenerationResult::Succeeded(output_text(&run.stdout, &run.stderr)) | ||
| fn run_problem_generation_command( | ||
| root: &Path, | ||
| state_snapshot: Option<&AppState>, | ||
| process: &mut Command, | ||
| ) -> Result<RunOutput> { | ||
| let bank = load_bank(root)?; | ||
| save_bank(root, &bank).context("materialize problem bank before AI command")?; | ||
| if let Some(state) = state_snapshot { | ||
| save_state(root, state).context("save problem state before AI command")?; | ||
| } | ||
| let run = match run_capture(process, "", Duration::from_secs(900)) { | ||
| Ok(run) => run, | ||
| Err(error) => { | ||
| if let Err(restore_error) = restore_ai_metadata(root, &bank, state_snapshot) { | ||
| return Err(error.context(format!( | ||
| "restore Practicode metadata after AI command failure: {restore_error}" | ||
| ))); | ||
| } | ||
| return Err(error); | ||
| } | ||
| }; | ||
| if run.code != Some(0) { | ||
| restore_ai_metadata(root, &bank, state_snapshot)?; | ||
| return Ok(run); | ||
| } | ||
| let validation: Result<()> = (|| { | ||
| if !regular_file_exists(&root.join(BANK_PATH))? { | ||
| bail!("AI command removed {BANK_PATH}"); | ||
| } | ||
| let generated_bank = load_bank(root)?; | ||
| if state_snapshot.is_some() { | ||
| if !regular_file_exists(&root.join(STATE_PATH))? { | ||
| bail!("AI command removed {STATE_PATH}"); | ||
| } | ||
| load_state(root, &generated_bank)?; | ||
| } | ||
| Ok(()) | ||
| })(); | ||
| if let Err(error) = validation { | ||
| if let Err(restore_error) = restore_ai_metadata(root, &bank, state_snapshot) { | ||
| return Err(error.context(format!( | ||
| "restore Practicode metadata after invalid AI output: {restore_error}" | ||
| ))); | ||
| } | ||
| return Err(error.context("AI command produced invalid Practicode metadata")); | ||
| } | ||
| Ok(run) | ||
| } | ||
| fn restore_ai_metadata( | ||
| root: &Path, | ||
| bank: &[Problem], | ||
| state_snapshot: Option<&AppState>, | ||
| ) -> Result<()> { | ||
| save_bank(root, bank).context("restore problem bank after AI command")?; | ||
| if let Some(state) = state_snapshot { | ||
| save_state(root, state).context("restore problem state after AI command")?; | ||
| } | ||
| Ok(()) | ||
| } | ||
| pub fn default_ai_next_command(root: &Path, settings: &Settings, request: &str) -> String { | ||
@@ -290,3 +355,3 @@ match normalize_ai_provider(&settings.ai_provider).as_str() { | ||
| format!( | ||
| "Read problem_notes.md if present, problems/INDEX.md if present, problem_bank.json if present, and problem-state.json. Create exactly one new non-duplicate coding practice problem. The built-in 001-hello-world already exists, so do not duplicate it. User request: {}. User profile: difficulty preference: {}; preferred topics: {}; avoid topics: {}; code language: {}; UI language: {}; generated answer languages: {}; generated UI languages: {}. Treat difficulty auto as gradual progression from state; otherwise prefer the requested difficulty unless the direct user request conflicts. Make the smallest valid edits: update problem_bank.json, one problem directory, problems/INDEX.md, and problem-state.json. Do not include the answer in the problem statement. Do not create solution.*, test_solution.*, or any answer-revealing file inside the problem directory.", | ||
| "Read problem_notes.md if present, problems/INDEX.md if present, problem_bank.json if present, and problem-state.json. {PROBLEM_BANK_SCHEMA} Create exactly one new non-duplicate coding practice problem. The built-in 001-hello-world already exists, so do not duplicate it. User request: {}. User profile: difficulty preference: {}; preferred topics: {}; avoid topics: {}; code language: {}; UI language: {}; generated answer languages: {}; generated UI languages: {}. Treat difficulty auto as gradual progression from state; otherwise prefer the requested difficulty unless the direct user request conflicts. Make the smallest valid edits: update problem_bank.json, one problem directory, problems/INDEX.md, and problem-state.json. Do not include the answer in the problem statement. Do not create solution.*, test_solution.*, or any answer-revealing file inside the problem directory.", | ||
| if request.is_empty() { | ||
@@ -309,3 +374,3 @@ "(none)" | ||
| format!( | ||
| "Read problem_notes.md if present, problems/INDEX.md if present, problem_bank.json if present, and problem-state.json. Create exactly one new non-duplicate coding practice problem for later use. The built-in 001-hello-world already exists, so do not duplicate it. User request: {}. User profile: difficulty preference: {}; preferred topics: {}; avoid topics: {}; current code language: {}; current UI language: {}; generated answer languages: {}; generated UI languages: {}. Treat difficulty auto as gradual progression from state; otherwise prefer the requested difficulty unless the direct user request conflicts. Make the smallest valid edits: update problem_bank.json, one problem directory, and problems/INDEX.md. Preserve problem-state.json current_problem, history, solved, and settings; do not switch the current problem. Do not include the answer in the problem statement. Do not create solution.*, test_solution.*, or any answer-revealing file inside the problem directory.", | ||
| "Read problem_notes.md if present, problems/INDEX.md if present, problem_bank.json if present, and problem-state.json. {PROBLEM_BANK_SCHEMA} Create exactly one new non-duplicate coding practice problem for later use. The built-in 001-hello-world already exists, so do not duplicate it. User request: {}. User profile: difficulty preference: {}; preferred topics: {}; avoid topics: {}; current code language: {}; current UI language: {}; generated answer languages: {}; generated UI languages: {}. Treat difficulty auto as gradual progression from state; otherwise prefer the requested difficulty unless the direct user request conflicts. Make the smallest valid edits: update problem_bank.json, one problem directory, and problems/INDEX.md. Preserve problem-state.json current_problem, history, solved, and settings; do not switch the current problem. Do not include the answer in the problem statement. Do not create solution.*, test_solution.*, or any answer-revealing file inside the problem directory.", | ||
| if request.is_empty() { | ||
@@ -312,0 +377,0 @@ "(none)" |
+3
-3
@@ -15,5 +15,5 @@ use crate::{ | ||
| parse_ui_language_list, previous_problem, problem_by_id, record_pass, record_syntax_result, | ||
| render_syntax_lesson, save_state, save_user_text, set_current_syntax_lesson, syntax_cases, | ||
| syntax_core_progress_count, syntax_language_name, syntax_review_due_at, template_for, | ||
| ui_text, | ||
| render_syntax_lesson, save_bank, save_state, save_user_text, set_current_syntax_lesson, | ||
| syntax_cases, syntax_core_progress_count, syntax_language_name, syntax_review_due_at, | ||
| template_for, ui_text, | ||
| }, | ||
@@ -20,0 +20,0 @@ text::{ |
+64
-26
@@ -83,26 +83,35 @@ use super::*; | ||
| let old_len = self.generate_bank_len; | ||
| let (added, reload_error) = match load_bank(&self.root) { | ||
| Ok(bank) | ||
| if !bank | ||
| .iter() | ||
| .any(|problem| problem.id == self.state.current_problem) => | ||
| { | ||
| ( | ||
| 0, | ||
| Some(format!( | ||
| let (added, reload_error) = | ||
| match save_state(&self.root, &self.state).and_then(|()| load_bank(&self.root)) { | ||
| Ok(bank) | ||
| if !bank | ||
| .iter() | ||
| .any(|problem| problem.id == self.state.current_problem) => | ||
| { | ||
| let detail = format!( | ||
| "current problem {} is missing from the reloaded bank", | ||
| self.state.current_problem | ||
| ); | ||
| ( | ||
| 0, | ||
| Some(restore_generated_bank(&self.root, &self.bank, detail)), | ||
| ) | ||
| } | ||
| Ok(bank) => { | ||
| let added = bank.len().saturating_sub(old_len); | ||
| self.bank = bank; | ||
| if let Some(cursor) = self.list_cursor.as_mut() { | ||
| *cursor = (*cursor).min(self.bank.len() - 1); | ||
| } | ||
| (added, None) | ||
| } | ||
| Err(error) => ( | ||
| 0, | ||
| Some(restore_generated_bank( | ||
| &self.root, | ||
| &self.bank, | ||
| error.to_string(), | ||
| )), | ||
| ) | ||
| } | ||
| Ok(bank) => { | ||
| let added = bank.len().saturating_sub(old_len); | ||
| self.bank = bank; | ||
| if let Some(cursor) = self.list_cursor.as_mut() { | ||
| *cursor = (*cursor).min(self.bank.len() - 1); | ||
| } | ||
| (added, None) | ||
| } | ||
| Err(error) => (0, Some(error.to_string())), | ||
| }; | ||
| ), | ||
| }; | ||
| self.generate_notice = Some(match result { | ||
@@ -405,2 +414,9 @@ AiGenerationResult::Failed { status, detail } => GenerationNotice::Failed { | ||
| fn restore_generated_bank(root: &std::path::Path, bank: &[Problem], detail: String) -> String { | ||
| match save_bank(root, bank) { | ||
| Ok(()) => detail, | ||
| Err(error) => format!("{detail}; restore previous problem bank: {error}"), | ||
| } | ||
| } | ||
| #[cfg(test)] | ||
@@ -432,2 +448,27 @@ mod tests { | ||
| #[test] | ||
| fn background_generation_restores_invalid_metadata() { | ||
| let mut app = localized_app("practicode-invalid-background-metadata", "en"); | ||
| let current_problem = app.state.current_problem.clone(); | ||
| std::fs::write(app.root.join("problem_bank.json"), "not json").unwrap(); | ||
| std::fs::write(app.root.join("problem-state.json"), "not json").unwrap(); | ||
| let bank_len = app.bank.len(); | ||
| finish_generation( | ||
| &mut app, | ||
| AiGenerationResult::Succeeded(String::new()), | ||
| bank_len, | ||
| ); | ||
| let bank = load_bank(&app.root).unwrap(); | ||
| assert_eq!( | ||
| load_state(&app.root, &bank).unwrap().current_problem, | ||
| current_problem | ||
| ); | ||
| assert!(matches!( | ||
| app.generate_notice, | ||
| Some(GenerationNotice::ReloadFailed(_)) | ||
| )); | ||
| } | ||
| fn learning_app(name: &str) -> PracticodeApp { | ||
@@ -686,2 +727,3 @@ let root = crate::process::unique_temp_path(name, "dir"); | ||
| ); | ||
| load_bank(&root).unwrap(); | ||
@@ -705,7 +747,3 @@ finish_generation( | ||
| ); | ||
| assert!( | ||
| failed_reload.contains("問題バンクを再読み込みできませんでした"), | ||
| "{failed_reload}" | ||
| ); | ||
| assert!(failed_reload.contains("parse"), "{failed_reload}"); | ||
| assert!(!failed_reload.contains("parse"), "{failed_reload}"); | ||
| } | ||
@@ -712,0 +750,0 @@ |
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
1765721
0.24%10
-9.09%