practicode
Advanced tools
+1
-0
@@ -24,2 +24,3 @@ .git | ||
| coverage | ||
| docs/superpowers | ||
| node_modules | ||
@@ -26,0 +27,0 @@ problems |
@@ -174,2 +174,3 @@ { | ||
| "status_written": "written", | ||
| "status_unreadable": "unreadable", | ||
| "status_auto": "auto", | ||
@@ -210,2 +211,3 @@ "status_easy": "easy", | ||
| "generation_reload_failed": "Background generation finished, but the problem bank could not be reloaded.", | ||
| "task_failed": "Background task stopped unexpectedly. Please retry.", | ||
| "generation_exit_status": "Exit status: {status}", | ||
@@ -286,2 +288,5 @@ "generation_partial_count": "New problems added to the bank despite the failure: {count}.", | ||
| "doctor_tsc_required": "TypeScript 5.9.x required ({version})", | ||
| "doctor_python_required": "Python 3.12+ required ({version})", | ||
| "doctor_java_required": "JDK 21+ required ({version})", | ||
| "doctor_rust_required": "Rust 1.85+ with edition 2024 support required ({version})", | ||
| "doctor_unknown_version": "unknown", | ||
@@ -288,0 +293,0 @@ "doctor_node_install_linux": "Ubuntu/Debian: download Node.js LTS from {url}", |
@@ -174,2 +174,3 @@ { | ||
| "status_written": "escrito", | ||
| "status_unreadable": "ilegible", | ||
| "status_auto": "automática", | ||
@@ -210,2 +211,3 @@ "status_easy": "fácil", | ||
| "generation_reload_failed": "La generación terminó, pero no se pudo recargar el banco de problemas.", | ||
| "task_failed": "La tarea en segundo plano se detuvo inesperadamente. Inténtalo de nuevo.", | ||
| "generation_exit_status": "Estado de salida: {status}", | ||
@@ -286,2 +288,5 @@ "generation_partial_count": "Problemas nuevos añadidos al banco a pesar del fallo: {count}.", | ||
| "doctor_tsc_required": "se requiere TypeScript 5.9.x ({version})", | ||
| "doctor_python_required": "se requiere Python 3.12 o posterior ({version})", | ||
| "doctor_java_required": "se requiere JDK 21 o posterior ({version})", | ||
| "doctor_rust_required": "se requiere Rust 1.85 o posterior con soporte para la edición 2024 ({version})", | ||
| "doctor_unknown_version": "desconocida", | ||
@@ -288,0 +293,0 @@ "doctor_node_install_linux": "Ubuntu/Debian: descarga Node.js LTS desde {url}", |
@@ -174,2 +174,3 @@ { | ||
| "status_written": "編集済み", | ||
| "status_unreadable": "読み取り不可", | ||
| "status_auto": "自動", | ||
@@ -210,2 +211,3 @@ "status_easy": "易しい", | ||
| "generation_reload_failed": "バックグラウンド生成は完了しましたが、問題バンクを再読み込みできませんでした。", | ||
| "task_failed": "バックグラウンド処理が予期せず停止しました。もう一度お試しください。", | ||
| "generation_exit_status": "終了ステータス: {status}", | ||
@@ -286,2 +288,5 @@ "generation_partial_count": "失敗しましたが、問題バンクには新しい問題が{count}件反映されました。", | ||
| "doctor_tsc_required": "TypeScript 5.9.xが必要 ({version})", | ||
| "doctor_python_required": "Python 3.12以上が必要 ({version})", | ||
| "doctor_java_required": "JDK 21以上が必要 ({version})", | ||
| "doctor_rust_required": "Edition 2024対応のRust 1.85以上が必要 ({version})", | ||
| "doctor_unknown_version": "不明", | ||
@@ -288,0 +293,0 @@ "doctor_node_install_linux": "Ubuntu/Debian: {url}からNode.js LTSをダウンロード", |
@@ -174,2 +174,3 @@ { | ||
| "status_written": "작성함", | ||
| "status_unreadable": "읽을 수 없음", | ||
| "status_auto": "자동", | ||
@@ -210,2 +211,3 @@ "status_easy": "쉬움", | ||
| "generation_reload_failed": "백그라운드 생성은 끝났지만 문제 목록을 다시 불러오지 못했습니다.", | ||
| "task_failed": "백그라운드 작업이 예기치 않게 중단되었습니다. 다시 시도하세요.", | ||
| "generation_exit_status": "종료 상태: {status}", | ||
@@ -286,2 +288,5 @@ "generation_partial_count": "실패했지만 문제 목록에 새 문제 {count}개가 반영되었습니다.", | ||
| "doctor_tsc_required": "TypeScript 5.9.x 필요 ({version})", | ||
| "doctor_python_required": "Python 3.12 이상 필요 ({version})", | ||
| "doctor_java_required": "JDK 21 이상 필요 ({version})", | ||
| "doctor_rust_required": "에디션 2024를 지원하는 Rust 1.85 이상 필요 ({version})", | ||
| "doctor_unknown_version": "알 수 없음", | ||
@@ -288,0 +293,0 @@ "doctor_node_install_linux": "Ubuntu/Debian: {url}에서 Node.js LTS 다운로드", |
@@ -174,2 +174,3 @@ { | ||
| "status_written": "已编写", | ||
| "status_unreadable": "无法读取", | ||
| "status_auto": "自动", | ||
@@ -210,2 +211,3 @@ "status_easy": "简单", | ||
| "generation_reload_failed": "后台生成已完成,但无法重新加载题库。", | ||
| "task_failed": "后台任务意外停止。请重试。", | ||
| "generation_exit_status": "退出状态:{status}", | ||
@@ -286,2 +288,5 @@ "generation_partial_count": "尽管生成失败,题库中仍新增了{count}道题。", | ||
| "doctor_tsc_required": "需要 TypeScript 5.9.x({version})", | ||
| "doctor_python_required": "需要 Python 3.12 或更高版本({version})", | ||
| "doctor_java_required": "需要 JDK 21 或更高版本({version})", | ||
| "doctor_rust_required": "需要支持 2024 edition 的 Rust 1.85 或更高版本({version})", | ||
| "doctor_unknown_version": "未知", | ||
@@ -288,0 +293,0 @@ "doctor_node_install_linux": "Ubuntu/Debian: 从 {url} 下载 Node.js LTS", |
+25
-3
@@ -26,2 +26,3 @@ const { spawnSync } = require("node:child_process"); | ||
| const MAX_MANIFEST_BYTES = 1024 * 1024; | ||
| const MAX_BINARY_BYTES = 128 * 1024 * 1024; | ||
| const STALE_TEMPORARY_FILE_MS = 60 * 60 * 1000; | ||
@@ -130,7 +131,13 @@ const RELEASE_ROOT = "https://github.com/baba9811/practicode/releases/download"; | ||
| async function downloadFile(url, filename, timeoutMs) { | ||
| async function downloadFile(url, filename, timeoutMs, maxBytes) { | ||
| const response = await responseFor(url, timeoutMs); | ||
| const hash = createHash("sha256"); | ||
| let bytes = 0; | ||
| const tap = new Transform({ | ||
| transform(chunk, _encoding, callback) { | ||
| bytes += chunk.length; | ||
| if (bytes > maxBytes) { | ||
| callback(new Error(`Binary download is unexpectedly large: ${url}`)); | ||
| return; | ||
| } | ||
| hash.update(chunk); | ||
@@ -140,3 +147,12 @@ callback(null, chunk); | ||
| }); | ||
| await pipeline(response, tap, createWriteStream(filename, { flags: "wx", mode: 0o600 })); | ||
| const output = createWriteStream(filename, { flags: "wx", mode: 0o600 }); | ||
| const outputClosed = new Promise((resolve) => output.once("close", resolve)); | ||
| try { | ||
| await pipeline(response, tap, output); | ||
| } catch (error) { | ||
| // pipeline can reject before an asynchronously opened file is closed. Windows | ||
| // cannot remove that temporary file until the handle has actually closed. | ||
| await outputClosed; | ||
| throw error; | ||
| } | ||
| return hash.digest("hex"); | ||
@@ -185,2 +201,3 @@ } | ||
| requestTimeoutMs = REQUEST_TIMEOUT_MS, | ||
| maxBinaryBytes = MAX_BINARY_BYTES, | ||
| }) { | ||
@@ -203,3 +220,8 @@ const asset = assetFor(platform, arch); | ||
| const expected = checksumFor(manifest, asset); | ||
| const actual = await downloadFile(`${releaseBaseUrl}/${asset}`, temporaryBinary, requestTimeoutMs); | ||
| const actual = await downloadFile( | ||
| `${releaseBaseUrl}/${asset}`, | ||
| temporaryBinary, | ||
| requestTimeoutMs, | ||
| maxBinaryBytes, | ||
| ); | ||
| if (actual !== expected) { | ||
@@ -206,0 +228,0 @@ throw new Error(`Checksum mismatch for ${asset}: expected ${expected}, received ${actual}`); |
+1
-1
@@ -456,3 +456,3 @@ # This file is automatically @generated by Cargo. | ||
| name = "practicode" | ||
| version = "0.2.0" | ||
| version = "0.2.1" | ||
| dependencies = [ | ||
@@ -459,0 +459,0 @@ "anyhow", |
+2
-2
| [package] | ||
| name = "practicode" | ||
| version = "0.2.0" | ||
| version = "0.2.1" | ||
| edition = "2024" | ||
@@ -15,3 +15,3 @@ description = "Offline-first programming language lessons and coding practice in a Rust terminal UI." | ||
| "/assets/**", | ||
| "/docs/**", | ||
| "/docs/*.md", | ||
| "/Cargo.lock", | ||
@@ -18,0 +18,0 @@ "/LICENSE", |
+1
-1
| { | ||
| "name": "practicode", | ||
| "version": "0.2.0", | ||
| "version": "0.2.1", | ||
| "description": "Offline-first programming language lessons and coding practice in a Rust terminal UI.", | ||
@@ -5,0 +5,0 @@ "license": "MIT", |
+142
-32
@@ -5,11 +5,10 @@ use crate::{ | ||
| SyntaxLesson, UI_LANGUAGES, ensure_submission, ensure_syntax_submission, | ||
| normalize_ai_provider, render_problem, syntax_lesson_study_context, ui_text, | ||
| normalize_ai_provider, regular_file_exists, render_problem, save_user_text, | ||
| syntax_lesson_study_context, ui_text, | ||
| }, | ||
| process::{run_capture, sh_quote, shell_process, unique_temp_path, which}, | ||
| }; | ||
| use anyhow::Result; | ||
| use anyhow::{Context, Result}; | ||
| use std::{ | ||
| env, | ||
| fs::{self, OpenOptions}, | ||
| io::Write, | ||
| env, fs, | ||
| path::{Path, PathBuf}, | ||
@@ -56,3 +55,8 @@ process::Command, | ||
| }; | ||
| let code = fs::read_to_string(&solution).unwrap_or_default(); | ||
| let code = match fs::read_to_string(&solution) | ||
| .with_context(|| format!("read {}", solution.display())) | ||
| { | ||
| Ok(code) => code, | ||
| Err(error) => return format!("AI prompt failed\n{error}"), | ||
| }; | ||
| let relative = solution | ||
@@ -86,3 +90,8 @@ .strip_prefix(root) | ||
| }; | ||
| let code = fs::read_to_string(&exercise).unwrap_or_default(); | ||
| let code = match fs::read_to_string(&exercise) | ||
| .with_context(|| format!("read {}", exercise.display())) | ||
| { | ||
| Ok(code) => code, | ||
| Err(error) => return format!("AI prompt failed\n{error}"), | ||
| }; | ||
| let relative = exercise | ||
@@ -106,8 +115,11 @@ .strip_prefix(root) | ||
| let provider = normalize_ai_provider(&state.settings.ai_provider); | ||
| let command = if state.settings.next_ai_command().trim().is_empty() { | ||
| default_ai_next_command(root, &state.settings, request) | ||
| let mut process = if state.settings.next_ai_command().trim().is_empty() { | ||
| builtin_ai_process( | ||
| root, | ||
| &state.settings, | ||
| &default_ai_next_prompt_with_settings(&state.settings, request), | ||
| ) | ||
| } else { | ||
| state.settings.next_ai_command().to_string() | ||
| shell_process(state.settings.next_ai_command()) | ||
| }; | ||
| let mut process = shell_process(&command); | ||
| process | ||
@@ -167,8 +179,11 @@ .current_dir(root) | ||
| let provider = normalize_ai_provider(&state.settings.ai_provider); | ||
| let command = if state.settings.next_ai_command().trim().is_empty() { | ||
| default_ai_generate_command(root, &state.settings, request) | ||
| let mut process = if state.settings.next_ai_command().trim().is_empty() { | ||
| builtin_ai_process( | ||
| root, | ||
| &state.settings, | ||
| &default_ai_generate_prompt_with_settings(&state.settings, request), | ||
| ) | ||
| } else { | ||
| state.settings.next_ai_command().to_string() | ||
| shell_process(state.settings.next_ai_command()) | ||
| }; | ||
| let mut process = shell_process(&command); | ||
| process | ||
@@ -312,14 +327,19 @@ .current_dir(root) | ||
| let path = root.join(PROBLEM_NOTES_PATH); | ||
| if let Some(parent) = path.parent() { | ||
| fs::create_dir_all(parent)?; | ||
| } | ||
| let mut file = OpenOptions::new().create(true).append(true).open(path)?; | ||
| writeln!(file, "{}", note.trim())?; | ||
| Ok(()) | ||
| let existing = read_problem_notes_file(root)?; | ||
| let separator = if existing.is_empty() || existing.ends_with('\n') { | ||
| "" | ||
| } else { | ||
| "\n" | ||
| }; | ||
| save_user_text(&path, &format!("{existing}{separator}{}\n", note.trim())) | ||
| } | ||
| pub fn read_problem_notes(root: &Path) -> Result<String> { | ||
| Ok(read_problem_notes_file(root)?.trim_end().to_string()) | ||
| } | ||
| fn read_problem_notes_file(root: &Path) -> Result<String> { | ||
| let path = root.join(PROBLEM_NOTES_PATH); | ||
| if path.exists() { | ||
| Ok(fs::read_to_string(path)?.trim_end().to_string()) | ||
| if regular_file_exists(&path)? { | ||
| fs::read_to_string(&path).with_context(|| format!("read {}", path.display())) | ||
| } else { | ||
@@ -456,10 +476,5 @@ Ok(String::new()) | ||
| command | ||
| .args([ | ||
| "exec", | ||
| "--skip-git-repo-check", | ||
| "--cd", | ||
| &root.display().to_string(), | ||
| "--sandbox", | ||
| "read-only", | ||
| ]) | ||
| .args(["exec", "--skip-git-repo-check", "--cd"]) | ||
| .arg(root) | ||
| .args(["--sandbox", "read-only"]) | ||
| .current_dir(root); | ||
@@ -470,3 +485,3 @@ if let Some(model) = settings.model_arg() { | ||
| add_codex_effort_args(&mut command, settings); | ||
| command.args(["-o", &output_path.display().to_string(), prompt]); | ||
| command.arg("-o").arg(&output_path).arg(prompt); | ||
| let result = run_capture(&mut command, "", Duration::from_secs(600)); | ||
@@ -527,2 +542,32 @@ let last_message = fs::read_to_string(&output_path).unwrap_or_default(); | ||
| fn builtin_ai_process(root: &Path, settings: &Settings, prompt: &str) -> Command { | ||
| if normalize_ai_provider(&settings.ai_provider) == "claude" { | ||
| let mut command = Command::new("claude"); | ||
| command | ||
| .args(["--permission-mode", "acceptEdits"]) | ||
| .current_dir(root); | ||
| if let Some(model) = settings.model_arg() { | ||
| command.args(["--model", model]); | ||
| } | ||
| if let Some(effort) = settings.effort_arg() { | ||
| command.args(["--effort", effort]); | ||
| } | ||
| command.args(["-p", prompt]); | ||
| command | ||
| } else { | ||
| let mut command = Command::new("codex"); | ||
| command | ||
| .args(["exec", "--ephemeral", "--skip-git-repo-check", "--cd"]) | ||
| .arg(root) | ||
| .args(["--sandbox", "workspace-write"]) | ||
| .current_dir(root); | ||
| if let Some(model) = settings.model_arg() { | ||
| command.args(["--model", model]); | ||
| } | ||
| add_codex_effort_args(&mut command, settings); | ||
| command.arg(prompt); | ||
| command | ||
| } | ||
| } | ||
| fn default_codex_next_command(root: &Path, settings: &Settings, request: &str) -> String { | ||
@@ -654,2 +699,67 @@ let start = "if [ -x \"$HOME/.codex/packages/standalone/current/codex\" ]; then codex app-server daemon start >/dev/null 2>&1 || true; fi"; | ||
| #[test] | ||
| fn builtin_codex_command_keeps_values_as_separate_arguments() { | ||
| let root = Path::new("workspace with spaces"); | ||
| let settings = Settings { | ||
| ai_model: "model with spaces".to_string(), | ||
| ai_effort: "high".to_string(), | ||
| ..Settings::default() | ||
| }; | ||
| let command = builtin_ai_process(root, &settings, "quote ' & percent %"); | ||
| let args = command | ||
| .get_args() | ||
| .map(|arg| arg.to_string_lossy().to_string()) | ||
| .collect::<Vec<_>>(); | ||
| assert_eq!(command.get_program(), "codex"); | ||
| assert_eq!(command.get_current_dir(), Some(root)); | ||
| assert!( | ||
| args.windows(2) | ||
| .any(|pair| pair == ["--cd", "workspace with spaces"]) | ||
| ); | ||
| assert!( | ||
| args.windows(2) | ||
| .any(|pair| pair == ["--model", "model with spaces"]) | ||
| ); | ||
| assert_eq!(args.last().unwrap(), "quote ' & percent %"); | ||
| } | ||
| #[cfg(unix)] | ||
| #[test] | ||
| fn builtin_codex_command_preserves_a_non_utf8_workspace_path() { | ||
| use std::{ffi::OsString, os::unix::ffi::OsStringExt}; | ||
| let root = PathBuf::from(OsString::from_vec(b"workspace-\xff".to_vec())); | ||
| let command = builtin_ai_process(&root, &Settings::default(), "prompt"); | ||
| let args = command.get_args().collect::<Vec<_>>(); | ||
| let cd = args.iter().position(|arg| *arg == "--cd").unwrap(); | ||
| assert_eq!(args[cd + 1], root.as_os_str()); | ||
| } | ||
| #[test] | ||
| fn builtin_claude_command_keeps_values_as_separate_arguments() { | ||
| let root = Path::new("workspace with spaces"); | ||
| let settings = Settings { | ||
| ai_provider: "claude".to_string(), | ||
| ai_model: "model with spaces".to_string(), | ||
| ai_effort: "max".to_string(), | ||
| ..Settings::default() | ||
| }; | ||
| let command = builtin_ai_process(root, &settings, "quote ' & percent %"); | ||
| let args = command | ||
| .get_args() | ||
| .map(|arg| arg.to_string_lossy().to_string()) | ||
| .collect::<Vec<_>>(); | ||
| assert_eq!(command.get_program(), "claude"); | ||
| assert_eq!(command.get_current_dir(), Some(root)); | ||
| assert!( | ||
| args.windows(2) | ||
| .any(|pair| pair == ["--model", "model with spaces"]) | ||
| ); | ||
| assert!(args.windows(2).any(|pair| pair == ["--effort", "max"])); | ||
| assert_eq!(args.last().unwrap(), "quote ' & percent %"); | ||
| } | ||
| #[test] | ||
| fn parses_codex_model_list_response() { | ||
@@ -656,0 +766,0 @@ let output = |
+39
-0
@@ -46,1 +46,40 @@ use crate::process::{CommandSpec, run_capture, which}; | ||
| }; | ||
| pub(crate) fn regular_file_exists(path: &Path) -> Result<bool> { | ||
| match fs::symlink_metadata(path) { | ||
| Ok(metadata) if metadata.file_type().is_file() => Ok(true), | ||
| Ok(_) => bail!("{} is not a regular file", path.display()), | ||
| Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), | ||
| Err(error) => Err(error).with_context(|| format!("inspect {}", path.display())), | ||
| } | ||
| } | ||
| pub(crate) fn create_dir_all_beneath(root: &Path, directory: &Path) -> Result<()> { | ||
| fs::create_dir_all(root).with_context(|| format!("create data root {}", root.display()))?; | ||
| let relative = directory.strip_prefix(root).with_context(|| { | ||
| format!( | ||
| "directory {} is outside data root {}", | ||
| directory.display(), | ||
| root.display() | ||
| ) | ||
| })?; | ||
| let mut current = root.to_path_buf(); | ||
| for component in relative.components() { | ||
| let std::path::Component::Normal(component) = component else { | ||
| bail!("unsafe directory path {}", directory.display()); | ||
| }; | ||
| current.push(component); | ||
| match fs::symlink_metadata(¤t) { | ||
| Ok(metadata) if metadata.file_type().is_dir() => {} | ||
| Ok(_) => bail!("{} is not a regular directory", current.display()), | ||
| Err(error) if error.kind() == std::io::ErrorKind::NotFound => { | ||
| fs::create_dir(¤t) | ||
| .with_context(|| format!("create directory {}", current.display()))?; | ||
| } | ||
| Err(error) => { | ||
| return Err(error).with_context(|| format!("inspect {}", current.display())); | ||
| } | ||
| } | ||
| } | ||
| Ok(()) | ||
| } |
+2
-3
@@ -79,3 +79,3 @@ use super::*; | ||
| let path = root.join(BANK_PATH); | ||
| if path.exists() { | ||
| if regular_file_exists(&path)? { | ||
| let text = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; | ||
@@ -97,4 +97,3 @@ let bank: Vec<Problem> = | ||
| } | ||
| fs::write(&path, serde_json::to_string_pretty(bank)? + "\n")?; | ||
| Ok(()) | ||
| save_user_text(&path, &(serde_json::to_string_pretty(bank)? + "\n")) | ||
| } | ||
@@ -101,0 +100,0 @@ |
+84
-48
| use super::*; | ||
| use std::env; | ||
| use std::{env, ffi::OsString}; | ||
@@ -20,2 +20,8 @@ const TYPESCRIPT_TYPECHECK_FLAGS: [&str; 8] = [ | ||
| #[derive(Clone, Debug)] | ||
| struct PreparedCommand { | ||
| program: PathBuf, | ||
| args: Vec<OsString>, | ||
| } | ||
| #[derive(Debug)] | ||
@@ -45,7 +51,7 @@ struct CommandFailure { | ||
| .join(format!("solution.{}", ext_for(&language))); | ||
| if !path.exists() { | ||
| if !regular_file_exists(&path)? { | ||
| if let Some(parent) = path.parent() { | ||
| fs::create_dir_all(parent)?; | ||
| create_dir_all_beneath(root, parent)?; | ||
| } | ||
| fs::write(&path, template_for(&language))?; | ||
| save_user_text(&path, &template_for(&language))?; | ||
| } | ||
@@ -87,3 +93,3 @@ Ok(path) | ||
| ) -> JudgeResult { | ||
| judge_path_with(root, id, path, language, cases, command_for) | ||
| judge_path_with(root, id, path, language, cases, command_for_os) | ||
| } | ||
@@ -100,3 +106,3 @@ | ||
| where | ||
| F: FnMut(&Path, &Path, &str) -> Result<Option<CommandSpec>>, | ||
| F: FnMut(&Path, &Path, &str) -> Result<Option<PreparedCommand>>, | ||
| { | ||
@@ -143,3 +149,3 @@ if cases.is_empty() { | ||
| let run_dir = root.join("build").join(id).join("run"); | ||
| if let Err(error) = fs::create_dir_all(&run_dir) { | ||
| if let Err(error) = create_dir_all_beneath(root, &run_dir) { | ||
| return JudgeResult { | ||
@@ -265,9 +271,30 @@ passed: false, | ||
| pub fn command_for(root: &Path, path: &Path, language: &str) -> Result<Option<CommandSpec>> { | ||
| command_for_os(root, path, language)? | ||
| .map(|command| { | ||
| let args = command | ||
| .args | ||
| .into_iter() | ||
| .map(|arg| { | ||
| arg.into_string() | ||
| .map_err(|arg| anyhow!("command argument {arg:?} is not valid UTF-8")) | ||
| }) | ||
| .collect::<Result<Vec<_>>>()?; | ||
| Ok(CommandSpec { | ||
| program: command.program, | ||
| args, | ||
| }) | ||
| }) | ||
| .transpose() | ||
| } | ||
| fn command_for_os(root: &Path, path: &Path, language: &str) -> Result<Option<PreparedCommand>> { | ||
| match language { | ||
| "python" => Ok(which("python3") | ||
| .or_else(|| which("python")) | ||
| .map(|program| CommandSpec { | ||
| program, | ||
| args: vec![path.display().to_string()], | ||
| })), | ||
| "python" => { | ||
| Ok(which("python3") | ||
| .or_else(|| which("python")) | ||
| .map(|program| PreparedCommand { | ||
| program, | ||
| args: vec![path.as_os_str().to_owned()], | ||
| })) | ||
| } | ||
| "ts" => compile_typescript(root, path), | ||
@@ -280,3 +307,3 @@ "java" => compile_java(root, path), | ||
| fn compile_typescript(root: &Path, path: &Path) -> Result<Option<CommandSpec>> { | ||
| fn compile_typescript(root: &Path, path: &Path) -> Result<Option<PreparedCommand>> { | ||
| compile_typescript_with(root, path, which, |program, args| { | ||
@@ -294,6 +321,6 @@ let mut command = Command::new(program); | ||
| mut run: R, | ||
| ) -> Result<Option<CommandSpec>> | ||
| ) -> Result<Option<PreparedCommand>> | ||
| where | ||
| F: FnMut(&str) -> Option<PathBuf>, | ||
| R: FnMut(&Path, &[String]) -> Result<crate::process::RunOutput>, | ||
| R: FnMut(&Path, &[OsString]) -> Result<crate::process::RunOutput>, | ||
| { | ||
@@ -303,3 +330,3 @@ let Some(tsc) = find("tsc") else { | ||
| }; | ||
| let version = run(&tsc, &["--version".to_string()])?; | ||
| let version = run(&tsc, &[OsString::from("--version")])?; | ||
| validate_typescript_version(&version)?; | ||
@@ -311,9 +338,13 @@ | ||
| .join("typescript"); | ||
| fs::create_dir_all(&build)?; | ||
| create_dir_all_beneath(root, &build)?; | ||
| let shim = build.join("node-shim.d.ts"); | ||
| let type_root = build.join("type-roots"); | ||
| if type_root.exists() { | ||
| fs::remove_dir_all(&type_root)?; | ||
| match fs::symlink_metadata(&type_root) { | ||
| Ok(metadata) if metadata.file_type().is_dir() => fs::remove_dir_all(&type_root)?, | ||
| Ok(_) => bail!("{} is not a regular directory", type_root.display()), | ||
| Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} | ||
| Err(error) => return Err(error.into()), | ||
| } | ||
| fs::create_dir_all(&type_root)?; | ||
| create_dir_all_beneath(root, &type_root)?; | ||
| regular_file_exists(&shim)?; | ||
| fs::write( | ||
@@ -323,8 +354,8 @@ &shim, | ||
| )?; | ||
| let mut args = TYPESCRIPT_TYPECHECK_FLAGS.map(str::to_string).to_vec(); | ||
| let mut args = TYPESCRIPT_TYPECHECK_FLAGS.map(OsString::from).to_vec(); | ||
| args.extend([ | ||
| "--typeRoots".to_string(), | ||
| type_root.display().to_string(), | ||
| shim.display().to_string(), | ||
| path.display().to_string(), | ||
| OsString::from("--typeRoots"), | ||
| type_root.as_os_str().to_owned(), | ||
| shim.as_os_str().to_owned(), | ||
| path.as_os_str().to_owned(), | ||
| ]); | ||
@@ -338,7 +369,7 @@ let output = run(&tsc, &args)?; | ||
| }; | ||
| Ok(Some(CommandSpec { | ||
| Ok(Some(PreparedCommand { | ||
| program: node, | ||
| args: vec![ | ||
| "--experimental-strip-types".to_string(), | ||
| path.display().to_string(), | ||
| OsString::from("--experimental-strip-types"), | ||
| path.as_os_str().to_owned(), | ||
| ], | ||
@@ -461,3 +492,3 @@ })) | ||
| fn compile_java(root: &Path, path: &Path) -> Result<Option<CommandSpec>> { | ||
| fn compile_java(root: &Path, path: &Path) -> Result<Option<PreparedCommand>> { | ||
| let Some(javac) = which("javac") else { | ||
@@ -473,11 +504,17 @@ return Ok(None); | ||
| .join("java"); | ||
| fs::create_dir_all(&build)?; | ||
| let build_parent = build.parent().context("find Java build parent")?; | ||
| create_dir_all_beneath(root, build_parent)?; | ||
| match fs::symlink_metadata(&build) { | ||
| Ok(metadata) if metadata.file_type().is_dir() => fs::remove_dir_all(&build)?, | ||
| Ok(_) => bail!("{} is not a regular directory", build.display()), | ||
| Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} | ||
| Err(error) => return Err(error.into()), | ||
| } | ||
| create_dir_all_beneath(root, &build)?; | ||
| let mut compile = Command::new(javac); | ||
| compile | ||
| .args(JAVA_RELEASE_FLAGS) | ||
| .args([ | ||
| "-d", | ||
| &build.display().to_string(), | ||
| &path.display().to_string(), | ||
| ]) | ||
| .arg("-d") | ||
| .arg(&build) | ||
| .arg(path) | ||
| .current_dir(root); | ||
@@ -488,8 +525,8 @@ let output = run_capture(&mut compile, "", Duration::from_secs(30))?; | ||
| } | ||
| Ok(Some(CommandSpec { | ||
| Ok(Some(PreparedCommand { | ||
| program: java, | ||
| args: vec![ | ||
| "-cp".to_string(), | ||
| build.display().to_string(), | ||
| "Solution".to_string(), | ||
| OsString::from("-cp"), | ||
| build.as_os_str().to_owned(), | ||
| OsString::from("Solution"), | ||
| ], | ||
@@ -499,3 +536,3 @@ })) | ||
| fn compile_rust(root: &Path, path: &Path) -> Result<Option<CommandSpec>> { | ||
| fn compile_rust(root: &Path, path: &Path) -> Result<Option<PreparedCommand>> { | ||
| let Some(rustc) = which("rustc") else { | ||
@@ -507,3 +544,3 @@ return Ok(None); | ||
| .join(path.parent().and_then(Path::file_name).unwrap_or_default()); | ||
| fs::create_dir_all(&build)?; | ||
| create_dir_all_beneath(root, &build)?; | ||
| let exe = build.join(if cfg!(windows) { | ||
@@ -514,10 +551,9 @@ "solution.exe" | ||
| }); | ||
| regular_file_exists(&exe)?; | ||
| let mut compile = Command::new(rustc); | ||
| compile | ||
| .arg(RUST_EDITION_FLAG) | ||
| .args([ | ||
| path.display().to_string(), | ||
| "-o".to_string(), | ||
| exe.display().to_string(), | ||
| ]) | ||
| .arg(path) | ||
| .arg("-o") | ||
| .arg(&exe) | ||
| .current_dir(root); | ||
@@ -528,3 +564,3 @@ let output = run_capture(&mut compile, "", Duration::from_secs(30))?; | ||
| } | ||
| Ok(Some(CommandSpec { | ||
| Ok(Some(PreparedCommand { | ||
| program: exe, | ||
@@ -531,0 +567,0 @@ args: Vec::new(), |
@@ -5,5 +5,5 @@ use super::*; | ||
| let problem_dir = root.join("problems").join(&problem.id); | ||
| fs::create_dir_all(&problem_dir)?; | ||
| create_dir_all_beneath(root, &problem_dir)?; | ||
| let readme = problem_dir.join("README.md"); | ||
| if readme.exists() { | ||
| if regular_file_exists(&readme)? { | ||
| return Ok(()); | ||
@@ -17,5 +17,5 @@ } | ||
| .join("\n"); | ||
| fs::write( | ||
| readme, | ||
| format!( | ||
| save_user_text( | ||
| &readme, | ||
| &format!( | ||
| "# {}. {}\n\n난이도: {}\n\n{}\n\n## 입력\n\n{}\n\n## 출력\n\n{}\n\n## 예시\n\n```text\n{}\n```\n", | ||
@@ -30,4 +30,3 @@ problem.id, | ||
| ), | ||
| )?; | ||
| Ok(()) | ||
| ) | ||
| } | ||
@@ -38,6 +37,6 @@ | ||
| if let Some(parent) = index.parent() { | ||
| fs::create_dir_all(parent)?; | ||
| create_dir_all_beneath(root, parent)?; | ||
| } | ||
| let mut rows: HashMap<String, (String, String, String, String)> = HashMap::new(); | ||
| if index.exists() { | ||
| if regular_file_exists(&index)? { | ||
| for line in fs::read_to_string(&index)?.lines() { | ||
@@ -89,9 +88,8 @@ let parts = line | ||
| .join("\n"); | ||
| fs::write( | ||
| index, | ||
| format!( | ||
| save_user_text( | ||
| &index, | ||
| &format!( | ||
| "# Problem Index\n\n| # | Slug | Difficulty | Topics | Status |\n|---|------|------------|--------|--------|\n{body}\n" | ||
| ), | ||
| )?; | ||
| Ok(()) | ||
| ) | ||
| } |
@@ -38,2 +38,4 @@ use super::*; | ||
| }; | ||
| let submission = ensure_submission(root, &problem, &state.settings)?; | ||
| fs::read_to_string(&submission).with_context(|| format!("read {}", submission.display()))?; | ||
| state.current_problem = problem.id.clone(); | ||
@@ -68,7 +70,10 @@ mark_history(state, &problem.id, "assigned"); | ||
| } | ||
| state.current_problem = history[index - 1].clone(); | ||
| let problem = problem_by_id(bank, &history[index - 1]) | ||
| .cloned() | ||
| .ok_or_else(|| anyhow!("previous problem missing"))?; | ||
| let submission = ensure_submission(root, &problem, &state.settings)?; | ||
| fs::read_to_string(&submission).with_context(|| format!("read {}", submission.display()))?; | ||
| state.current_problem = problem.id.clone(); | ||
| save_state(root, state)?; | ||
| problem_by_id(bank, &state.current_problem) | ||
| .cloned() | ||
| .ok_or_else(|| anyhow!("current problem missing")) | ||
| Ok(problem) | ||
| } | ||
@@ -75,0 +80,0 @@ |
+20
-4
@@ -7,3 +7,3 @@ use super::*; | ||
| let backup = sibling_path(&path, ".bak"); | ||
| if !path.exists() { | ||
| if !regular_file_exists(&path)? { | ||
| match fs::symlink_metadata(&backup) { | ||
@@ -24,3 +24,3 @@ Ok(metadata) if metadata.file_type().is_file() => { | ||
| } | ||
| if !path.exists() { | ||
| if !regular_file_exists(&path)? { | ||
| return Ok(AppState { | ||
@@ -85,2 +85,3 @@ current_problem: bank[0].id.clone(), | ||
| let path = root.join(STATE_PATH); | ||
| regular_file_exists(&path)?; | ||
| if let Some(parent) = path.parent() { | ||
@@ -115,2 +116,15 @@ fs::create_dir_all(parent) | ||
| fn replace_state_file(path: &Path, contents: &[u8]) -> Result<()> { | ||
| replace_file(path, contents, true) | ||
| } | ||
| pub(crate) fn save_user_text(path: &Path, contents: &str) -> Result<()> { | ||
| regular_file_exists(path)?; | ||
| if let Some(parent) = path.parent() { | ||
| fs::create_dir_all(parent) | ||
| .with_context(|| format!("create directory {}", parent.display()))?; | ||
| } | ||
| replace_file(path, contents.as_bytes(), false) | ||
| } | ||
| fn replace_file(path: &Path, contents: &[u8], durable: bool) -> Result<()> { | ||
| let temporary = sibling_path(path, ".tmp"); | ||
@@ -131,4 +145,6 @@ let backup = sibling_path(path, ".bak"); | ||
| .with_context(|| format!("flush {}", temporary.display()))?; | ||
| file.sync_all() | ||
| .with_context(|| format!("sync {}", temporary.display()))?; | ||
| if durable { | ||
| file.sync_all() | ||
| .with_context(|| format!("sync {}", temporary.display()))?; | ||
| } | ||
| drop(file); | ||
@@ -135,0 +151,0 @@ replace_state_file_inner(path, &temporary, &backup) |
@@ -464,7 +464,7 @@ use super::*; | ||
| .join(format!("exercise.{}", ext_for(lesson.language))); | ||
| if !path.exists() { | ||
| if !regular_file_exists(&path)? { | ||
| if let Some(parent) = path.parent() { | ||
| fs::create_dir_all(parent)?; | ||
| create_dir_all_beneath(root, parent)?; | ||
| } | ||
| fs::write(&path, lesson.exercise.starter)?; | ||
| save_user_text(&path, lesson.exercise.starter)?; | ||
| } | ||
@@ -471,0 +471,0 @@ Ok(path) |
+16
-15
@@ -38,16 +38,16 @@ use anyhow::{Context, Result, bail}; | ||
| let args = env::args().skip(1).collect::<Vec<_>>(); | ||
| if args | ||
| .iter() | ||
| .any(|arg| matches!(arg.as_str(), "-h" | "--help")) | ||
| { | ||
| println!("{}", cli_help_text()); | ||
| return Ok(()); | ||
| } | ||
| if args | ||
| .iter() | ||
| .any(|arg| matches!(arg.as_str(), "-V" | "--version")) | ||
| { | ||
| println!("practicode {}", update::CURRENT_VERSION); | ||
| return Ok(()); | ||
| } | ||
| let smoke = match args.as_slice() { | ||
| [] => false, | ||
| [arg] if matches!(arg.as_str(), "-h" | "--help") => { | ||
| println!("{}", cli_help_text()); | ||
| return Ok(()); | ||
| } | ||
| [arg] if matches!(arg.as_str(), "-V" | "--version") => { | ||
| println!("practicode {}", update::CURRENT_VERSION); | ||
| return Ok(()); | ||
| } | ||
| [arg] if arg == "--smoke" => true, | ||
| [arg] => bail!("unknown argument: {arg}"), | ||
| _ => bail!("expected at most one argument"), | ||
| }; | ||
@@ -64,3 +64,3 @@ let launch_dir = env::current_dir().context("read current directory")?; | ||
| migrate_legacy_data(&launch_dir, &root)?; | ||
| if args.iter().any(|arg| arg == "--smoke") { | ||
| if smoke { | ||
| let bank = core::load_bank(&root)?; | ||
@@ -80,2 +80,3 @@ let state = core::load_state(&root, &bank)?; | ||
| let result = app.run(&mut terminal); | ||
| process::terminate_active_commands(); | ||
| ratatui::restore(); | ||
@@ -82,0 +83,0 @@ let _ = execute!( |
+302
-23
@@ -1,8 +0,11 @@ | ||
| use anyhow::{Context, Result}; | ||
| use anyhow::{Context, Result, anyhow}; | ||
| use std::{ | ||
| collections::HashSet, | ||
| env, | ||
| ffi::OsStr, | ||
| io::{ErrorKind, Write}, | ||
| io::{self, ErrorKind, Read, Write}, | ||
| path::{Path, PathBuf}, | ||
| process::{Command, Stdio}, | ||
| process::{Child, Command, Stdio}, | ||
| sync::{Mutex, OnceLock}, | ||
| thread::{self, ScopedJoinHandle}, | ||
| time::{Duration, SystemTime, UNIX_EPOCH}, | ||
@@ -12,2 +15,59 @@ }; | ||
| const MAX_CAPTURE_BYTES: usize = 4 * 1024 * 1024; | ||
| const OUTPUT_TRUNCATED_MARKER: &[u8] = b"\n[output truncated]\n"; | ||
| static ACTIVE_COMMANDS: OnceLock<Mutex<CommandRegistry>> = OnceLock::new(); | ||
| #[derive(Default)] | ||
| struct CommandRegistry { | ||
| pids: HashSet<u32>, | ||
| shutting_down: bool, | ||
| } | ||
| impl CommandRegistry { | ||
| fn register(&mut self, pid: u32) -> bool { | ||
| if self.shutting_down { | ||
| return false; | ||
| } | ||
| self.pids.insert(pid); | ||
| true | ||
| } | ||
| fn remove(&mut self, pid: u32) { | ||
| self.pids.remove(&pid); | ||
| } | ||
| fn begin_shutdown(&mut self) -> Vec<u32> { | ||
| self.shutting_down = true; | ||
| self.pids.iter().copied().collect() | ||
| } | ||
| } | ||
| struct ActiveCommand(u32); | ||
| impl ActiveCommand { | ||
| fn register(pid: u32) -> Self { | ||
| let registered = active_commands() | ||
| .lock() | ||
| .unwrap_or_else(|poisoned| poisoned.into_inner()) | ||
| .register(pid); | ||
| if !registered { | ||
| let _ = terminate_process_group(pid); | ||
| } | ||
| Self(pid) | ||
| } | ||
| } | ||
| impl Drop for ActiveCommand { | ||
| fn drop(&mut self) { | ||
| active_commands() | ||
| .lock() | ||
| .unwrap_or_else(|poisoned| poisoned.into_inner()) | ||
| .remove(self.0); | ||
| } | ||
| } | ||
| fn active_commands() -> &'static Mutex<CommandRegistry> { | ||
| ACTIVE_COMMANDS.get_or_init(|| Mutex::new(CommandRegistry::default())) | ||
| } | ||
| #[derive(Clone, Debug)] | ||
@@ -28,2 +88,7 @@ pub struct CommandSpec { | ||
| pub fn run_capture(command: &mut Command, input: &str, timeout: Duration) -> Result<RunOutput> { | ||
| #[cfg(unix)] | ||
| { | ||
| use std::os::unix::process::CommandExt; | ||
| command.process_group(0); | ||
| } | ||
| command | ||
@@ -34,27 +99,144 @@ .stdin(Stdio::piped()) | ||
| let mut child = command.spawn().context("spawn command")?; | ||
| if let Some(stdin) = child.stdin.as_mut() { | ||
| match stdin.write_all(input.as_bytes()) { | ||
| Ok(()) => {} | ||
| Err(error) if error.kind() == ErrorKind::BrokenPipe => {} | ||
| Err(error) => return Err(error).context("write stdin"), | ||
| let active = ActiveCommand::register(child.id()); | ||
| let stdin = child.stdin.take(); | ||
| let stdout = child.stdout.take().context("capture stdout")?; | ||
| let stderr = child.stderr.take().context("capture stderr")?; | ||
| thread::scope(|scope| { | ||
| let input_thread = stdin.map(|mut stdin| { | ||
| scope.spawn(move || match stdin.write_all(input.as_bytes()) { | ||
| Ok(()) => Ok(()), | ||
| Err(error) if error.kind() == ErrorKind::BrokenPipe => Ok(()), | ||
| Err(error) => Err(error), | ||
| }) | ||
| }); | ||
| let stdout_thread = scope.spawn(move || read_captured(stdout)); | ||
| let stderr_thread = scope.spawn(move || read_captured(stderr)); | ||
| let (status, timed_out) = match child.wait_timeout(timeout).context("wait for command")? { | ||
| Some(status) => (status, false), | ||
| None => { | ||
| terminate_command(&mut child).context("kill timed out command")?; | ||
| (child.wait().context("reap timed out command")?, true) | ||
| } | ||
| }; | ||
| if !timed_out { | ||
| // A successful parent can leave descendants holding our pipes open forever. | ||
| // run_capture owns the whole command tree, so no descendant should outlive it. | ||
| let _ = terminate_process_group(child.id()); | ||
| } | ||
| if let Some(handle) = input_thread { | ||
| join_io_thread(handle, "write stdin")?; | ||
| } | ||
| let stdout = join_io_thread(stdout_thread, "read stdout")?; | ||
| let stderr = join_io_thread(stderr_thread, "read stderr")?; | ||
| drop(active); | ||
| Ok(RunOutput { | ||
| code: status.code(), | ||
| stdout: String::from_utf8_lossy(&stdout).to_string(), | ||
| stderr: String::from_utf8_lossy(&stderr).to_string(), | ||
| timed_out, | ||
| }) | ||
| }) | ||
| } | ||
| pub(crate) fn terminate_active_commands() { | ||
| let pids = active_commands() | ||
| .lock() | ||
| .unwrap_or_else(|poisoned| poisoned.into_inner()) | ||
| .begin_shutdown(); | ||
| for pid in pids { | ||
| let _ = terminate_process_group(pid); | ||
| } | ||
| drop(child.stdin.take()); | ||
| } | ||
| let timed_out = match child.wait_timeout(timeout).context("wait for command")? { | ||
| Some(_) => false, | ||
| None => { | ||
| let _ = child.kill(); | ||
| true | ||
| #[cfg(test)] | ||
| fn active_command_is_registered(pid: u32) -> bool { | ||
| active_commands() | ||
| .lock() | ||
| .unwrap_or_else(|poisoned| poisoned.into_inner()) | ||
| .pids | ||
| .contains(&pid) | ||
| } | ||
| fn read_captured(mut reader: impl Read) -> io::Result<Vec<u8>> { | ||
| let mut output = Vec::new(); | ||
| let mut buffer = [0; 8192]; | ||
| let mut truncated = false; | ||
| loop { | ||
| let read = reader.read(&mut buffer)?; | ||
| if read == 0 { | ||
| break; | ||
| } | ||
| }; | ||
| let output = child.wait_with_output().context("read command output")?; | ||
| Ok(RunOutput { | ||
| code: output.status.code(), | ||
| stdout: String::from_utf8_lossy(&output.stdout).to_string(), | ||
| stderr: String::from_utf8_lossy(&output.stderr).to_string(), | ||
| timed_out, | ||
| let remaining = MAX_CAPTURE_BYTES.saturating_sub(output.len()); | ||
| output.extend_from_slice(&buffer[..read.min(remaining)]); | ||
| truncated |= read > remaining; | ||
| } | ||
| if truncated { | ||
| output.extend_from_slice(OUTPUT_TRUNCATED_MARKER); | ||
| } | ||
| Ok(output) | ||
| } | ||
| #[cfg(unix)] | ||
| fn terminate_process_group(pid: u32) -> io::Result<()> { | ||
| unsafe extern "C" { | ||
| fn kill(pid: i32, signal: i32) -> i32; | ||
| } | ||
| // SAFETY: the child starts a fresh process group whose id is its positive process id. | ||
| if unsafe { kill(-(pid as i32), 9) } == 0 { | ||
| Ok(()) | ||
| } else { | ||
| Err(io::Error::last_os_error()) | ||
| } | ||
| } | ||
| #[cfg(unix)] | ||
| fn terminate_command(child: &mut Child) -> io::Result<()> { | ||
| terminate_process_group(child.id()).or_else(|_| match child.kill() { | ||
| Ok(()) => Ok(()), | ||
| Err(error) if error.kind() == ErrorKind::InvalidInput => Ok(()), | ||
| Err(error) => Err(error), | ||
| }) | ||
| } | ||
| #[cfg(windows)] | ||
| fn terminate_process_group(pid: u32) -> io::Result<()> { | ||
| let pid = pid.to_string(); | ||
| let status = Command::new("taskkill") | ||
| .args(["/F", "/T", "/PID", &pid]) | ||
| .status(); | ||
| match status { | ||
| Ok(status) if status.success() => Ok(()), | ||
| Ok(_) => Err(io::Error::other("taskkill failed")), | ||
| Err(error) => Err(error), | ||
| } | ||
| } | ||
| #[cfg(windows)] | ||
| fn terminate_command(child: &mut Child) -> io::Result<()> { | ||
| terminate_process_group(child.id()).or_else(|_| child.kill()) | ||
| } | ||
| #[cfg(not(any(unix, windows)))] | ||
| fn terminate_process_group(_pid: u32) -> io::Result<()> { | ||
| Err(io::Error::new( | ||
| ErrorKind::Unsupported, | ||
| "process tree termination is unsupported", | ||
| )) | ||
| } | ||
| #[cfg(not(any(unix, windows)))] | ||
| fn terminate_command(child: &mut Child) -> io::Result<()> { | ||
| child.kill() | ||
| } | ||
| fn join_io_thread<T>(handle: ScopedJoinHandle<'_, std::io::Result<T>>, action: &str) -> Result<T> { | ||
| handle | ||
| .join() | ||
| .map_err(|_| anyhow!("{action} thread panicked"))? | ||
| .with_context(|| action.to_string()) | ||
| } | ||
| pub fn which(name: &str) -> Option<PathBuf> { | ||
@@ -68,3 +250,3 @@ let paths = env::var_os("PATH")?; | ||
| let path = dir.join(name); | ||
| if path.is_file() { | ||
| if is_executable(&path) { | ||
| return Some(path); | ||
@@ -87,5 +269,18 @@ } | ||
| }) | ||
| .find(|path| path.is_file()) | ||
| .find(|path| is_executable(path)) | ||
| } | ||
| #[cfg(unix)] | ||
| fn is_executable(path: &Path) -> bool { | ||
| use std::os::unix::fs::PermissionsExt; | ||
| path.metadata() | ||
| .is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) | ||
| } | ||
| #[cfg(not(unix))] | ||
| fn is_executable(path: &Path) -> bool { | ||
| path.is_file() | ||
| } | ||
| pub fn shell_process(command: &str) -> Command { | ||
@@ -118,2 +313,4 @@ if cfg!(windows) { | ||
| use super::*; | ||
| #[cfg(unix)] | ||
| use std::os::unix::fs::PermissionsExt; | ||
@@ -134,3 +331,69 @@ #[test] | ||
| #[cfg(unix)] | ||
| #[test] | ||
| fn run_capture_drains_output_while_child_is_running() { | ||
| let mut command = shell_process( | ||
| "i=0; while [ \"$i\" -lt 20000 ]; do printf 0123456789abcdef; i=$((i + 1)); done", | ||
| ); | ||
| let output = run_capture(&mut command, "", Duration::from_secs(2)).unwrap(); | ||
| assert!(!output.timed_out); | ||
| assert_eq!(output.stdout.len(), 320_000); | ||
| } | ||
| #[cfg(unix)] | ||
| #[test] | ||
| fn run_capture_timeout_terminates_descendants() { | ||
| let started = std::time::Instant::now(); | ||
| let mut command = shell_process("sleep 3 & wait"); | ||
| let output = run_capture(&mut command, "", Duration::from_millis(100)).unwrap(); | ||
| assert!(output.timed_out); | ||
| assert!(started.elapsed() < Duration::from_secs(2)); | ||
| } | ||
| #[cfg(unix)] | ||
| #[test] | ||
| fn run_capture_does_not_wait_for_descendants_after_parent_exits() { | ||
| let started = std::time::Instant::now(); | ||
| let mut command = shell_process("sleep 3 &"); | ||
| let output = run_capture(&mut command, "", Duration::from_secs(5)).unwrap(); | ||
| assert_eq!(output.code, Some(0)); | ||
| assert!(started.elapsed() < Duration::from_secs(2)); | ||
| } | ||
| #[cfg(unix)] | ||
| #[test] | ||
| fn run_capture_bounds_captured_output() { | ||
| let mut command = shell_process("dd if=/dev/zero bs=1048576 count=6 2>/dev/null"); | ||
| let output = run_capture(&mut command, "", Duration::from_secs(5)).unwrap(); | ||
| assert!(!output.timed_out); | ||
| assert!(output.stdout.len() < 5 * 1024 * 1024); | ||
| assert!(output.stdout.contains("[output truncated]")); | ||
| } | ||
| #[test] | ||
| fn active_command_registration_is_removed_on_drop() { | ||
| let pid = u32::MAX; | ||
| { | ||
| let _active = ActiveCommand::register(pid); | ||
| assert!(active_command_is_registered(pid)); | ||
| } | ||
| assert!(!active_command_is_registered(pid)); | ||
| } | ||
| #[test] | ||
| fn command_registry_rejects_registration_after_shutdown_starts() { | ||
| let mut registry = CommandRegistry::default(); | ||
| assert!(registry.register(10)); | ||
| assert_eq!(registry.begin_shutdown(), vec![10]); | ||
| assert!(!registry.register(11)); | ||
| } | ||
| #[test] | ||
| fn which_honors_pathext_suffixes() { | ||
@@ -141,2 +404,4 @@ let root = unique_temp_path("practicode-which", "dir"); | ||
| std::fs::write(&exe, "").unwrap(); | ||
| #[cfg(unix)] | ||
| std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap(); | ||
@@ -150,2 +415,16 @@ assert_eq!( | ||
| } | ||
| #[cfg(unix)] | ||
| #[test] | ||
| fn which_rejects_non_executable_files() { | ||
| let root = unique_temp_path("practicode-which-permissions", "dir"); | ||
| std::fs::create_dir_all(&root).unwrap(); | ||
| let tool = root.join("tool"); | ||
| std::fs::write(&tool, "").unwrap(); | ||
| std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o644)).unwrap(); | ||
| assert_eq!(find_in_dir(&root, "tool", None), None); | ||
| let _ = std::fs::remove_dir_all(root); | ||
| } | ||
| } |
+5
-5
@@ -15,3 +15,3 @@ use crate::{ | ||
| parse_ui_language_list, previous_problem, problem_by_id, record_pass, record_syntax_result, | ||
| render_syntax_lesson, save_state, set_current_syntax_lesson, syntax_cases, | ||
| 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, | ||
@@ -25,3 +25,3 @@ ui_text, | ||
| }; | ||
| use anyhow::Result; | ||
| use anyhow::{Context, Result}; | ||
| use crossterm::event::{ | ||
@@ -44,3 +44,3 @@ self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind, | ||
| path::PathBuf, | ||
| sync::mpsc::{self, Receiver}, | ||
| sync::mpsc::{self, Receiver, TryRecvError}, | ||
| thread, | ||
@@ -297,5 +297,5 @@ time::{Duration, Instant}, | ||
| } | ||
| self.save_code().ok(); | ||
| let save_result = self.save_code(); | ||
| self.disable_mouse_capture(); | ||
| Ok(()) | ||
| save_result | ||
| } | ||
@@ -302,0 +302,0 @@ |
+50
-9
@@ -478,4 +478,4 @@ use super::*; | ||
| } | ||
| self.load_syntax_editor()?; | ||
| save_state(&self.root, &self.state)?; | ||
| self.load_syntax_editor()?; | ||
| self.show_current_syntax_lesson(); | ||
@@ -551,4 +551,4 @@ Ok(()) | ||
| set_current_syntax_lesson(&mut self.state, &language, lesson_id); | ||
| self.load_syntax_editor()?; | ||
| save_state(&self.root, &self.state)?; | ||
| self.load_syntax_editor()?; | ||
| self.learn_result.clear(); | ||
@@ -570,4 +570,4 @@ self.show_current_syntax_lesson(); | ||
| next_syntax_lesson(&mut self.state, &language, 1); | ||
| self.load_syntax_editor()?; | ||
| save_state(&self.root, &self.state)?; | ||
| self.load_syntax_editor()?; | ||
| self.learn_result.clear(); | ||
@@ -585,4 +585,4 @@ self.left_scroll = 0; | ||
| next_syntax_lesson(&mut self.state, &language, -1); | ||
| self.load_syntax_editor()?; | ||
| save_state(&self.root, &self.state)?; | ||
| self.load_syntax_editor()?; | ||
| self.learn_result.clear(); | ||
@@ -670,4 +670,4 @@ self.left_scroll = 0; | ||
| } | ||
| self.load_code_editor()?; | ||
| save_state(&self.root, &self.state)?; | ||
| self.load_code_editor()?; | ||
| self.settings_cursor = None; | ||
@@ -738,2 +738,19 @@ self.show_output = false; | ||
| pub(super) fn set_generate_languages(&mut self, value: &str, ui: bool) -> Result<()> { | ||
| let all = value.trim().eq_ignore_ascii_case("all"); | ||
| let valid = all | ||
| || value.split(',').all(|language| { | ||
| let language = language.trim().to_lowercase(); | ||
| if ui { | ||
| language | ||
| .split(['-', '_']) | ||
| .next() | ||
| .is_some_and(|language| UI_LANGUAGES.contains(&language)) | ||
| } else { | ||
| LANGUAGES.contains(&language.as_str()) | ||
| } | ||
| }); | ||
| if !valid { | ||
| self.show_profile(); | ||
| return Ok(()); | ||
| } | ||
| if ui { | ||
@@ -750,4 +767,16 @@ self.state.settings.generate_ui_languages = parse_ui_language_list(value); | ||
| pub(super) fn set_ai_effort(&mut self, effort: &str) -> Result<()> { | ||
| let effort = effort.trim().to_lowercase(); | ||
| let allowed = if self.state.settings.ai_provider == "claude" { | ||
| CLAUDE_AI_EFFORTS | ||
| } else { | ||
| CODEX_AI_EFFORTS | ||
| }; | ||
| if !(allowed.contains(&effort.as_str()) | ||
| || self.state.settings.ai_provider == "codex" && effort == "max") | ||
| { | ||
| self.write_model_status(); | ||
| return Ok(()); | ||
| } | ||
| self.state.settings.ai_effort = | ||
| normalize_ai_effort(&self.state.settings.ai_provider, effort); | ||
| normalize_ai_effort(&self.state.settings.ai_provider, &effort); | ||
| save_state(&self.root, &self.state)?; | ||
@@ -846,4 +875,4 @@ self.write_model_status(); | ||
| self.save_code()?; | ||
| self.note_editor | ||
| .set_text(&read_problem_notes(&self.root).unwrap_or_default()); | ||
| let notes = read_problem_notes(&self.root)?; | ||
| self.note_editor.set_text(¬es); | ||
| self.settings_cursor = None; | ||
@@ -865,3 +894,3 @@ self.showing_model_status = false; | ||
| let text = text.trim_end(); | ||
| fs::write(path, if text.is_empty() { "" } else { text })?; | ||
| save_user_text(&path, if text.is_empty() { "" } else { text })?; | ||
| Ok(()) | ||
@@ -1129,2 +1158,14 @@ } | ||
| } | ||
| #[test] | ||
| fn unreadable_notes_do_not_clear_the_note_editor() { | ||
| let mut app = localized_app("practicode-unreadable-notes", "en"); | ||
| app.note_editor.set_text("keep this note"); | ||
| let path = app.root.join(PROBLEM_NOTES_PATH); | ||
| std::fs::write(&path, [0xff]).unwrap(); | ||
| assert!(app.start_note_editor().is_err()); | ||
| assert_eq!(app.note_editor.text(), "keep this note"); | ||
| assert_eq!(std::fs::read(path).unwrap(), [0xff]); | ||
| } | ||
| } |
@@ -47,3 +47,10 @@ use super::*; | ||
| "ui" if arg.is_empty() => self.action_toggle_ui_language()?, | ||
| "ui" => self.set_ui_language(&normalize_ui_language(arg))?, | ||
| "ui" if arg | ||
| .to_lowercase() | ||
| .split(['-', '_']) | ||
| .next() | ||
| .is_some_and(|language| UI_LANGUAGES.contains(&language)) => | ||
| { | ||
| self.set_ui_language(&normalize_ui_language(arg))? | ||
| } | ||
| "theme" if arg.is_empty() => self.action_toggle_theme()?, | ||
@@ -235,2 +242,20 @@ "theme" if THEMES.contains(&arg) => self.set_theme(arg)?, | ||
| } | ||
| #[test] | ||
| fn invalid_setting_commands_leave_settings_unchanged() { | ||
| let mut app = app("ko"); | ||
| app.state.settings.ai_effort = "high".to_string(); | ||
| app.state.settings.generate_languages = vec!["rust".to_string()]; | ||
| app.state.settings.generate_ui_languages = vec!["ko".to_string()]; | ||
| app.handle_command("ui klingon").unwrap(); | ||
| app.handle_command("effort impossible").unwrap(); | ||
| app.handle_command("generate-languages ruby").unwrap(); | ||
| app.handle_command("generate-ui klingon").unwrap(); | ||
| assert_eq!(app.state.settings.ui_language, "ko"); | ||
| assert_eq!(app.state.settings.ai_effort, "high"); | ||
| assert_eq!(app.state.settings.generate_languages, ["rust"]); | ||
| assert_eq!(app.state.settings.generate_ui_languages, ["ko"]); | ||
| } | ||
| } |
+163
-40
@@ -103,12 +103,13 @@ use super::*; | ||
| vec![ | ||
| python_check(lang, has_command), | ||
| python_check(lang, has_command, command_version), | ||
| node_check(lang, has_command, command_version), | ||
| java_check(lang, has_command), | ||
| rust_check(lang, has_command), | ||
| java_check(lang, has_command, command_version), | ||
| rust_check(lang, has_command, command_version), | ||
| ] | ||
| } | ||
| fn python_check<F>(lang: &str, has_command: &mut F) -> DoctorCheck | ||
| fn python_check<F, V>(lang: &str, has_command: &mut F, command_version: &mut V) -> DoctorCheck | ||
| where | ||
| F: FnMut(&str) -> bool, | ||
| V: FnMut(&str, &[&str]) -> Option<String>, | ||
| { | ||
@@ -122,17 +123,36 @@ let command = if has_command("python3") { | ||
| }; | ||
| let Some(command) = command else { | ||
| return DoctorCheck { | ||
| status: DoctorStatus::Missing, | ||
| name: "Python", | ||
| detail: format!( | ||
| "{}; {}", | ||
| missing_tool(lang, "python3"), | ||
| missing_tool(lang, "python") | ||
| ), | ||
| install: Some(InstallHelp::Python), | ||
| }; | ||
| }; | ||
| let version = command_version(command, &["--version"]); | ||
| let ok = version | ||
| .as_deref() | ||
| .is_some_and(|version| reported_version_at_least(version, 3, 12, 0)); | ||
| DoctorCheck { | ||
| status: if command.is_some() { | ||
| status: if ok { | ||
| DoctorStatus::Ok | ||
| } else { | ||
| DoctorStatus::Missing | ||
| DoctorStatus::Update | ||
| }, | ||
| name: "Python", | ||
| detail: command.map(str::to_string).unwrap_or_else(|| { | ||
| format!( | ||
| "{}; {}", | ||
| missing_tool(lang, "python3"), | ||
| missing_tool(lang, "python") | ||
| detail: if ok { | ||
| version.unwrap() | ||
| } else { | ||
| ui_text(lang, "doctor_python_required").replace( | ||
| "{version}", | ||
| version | ||
| .as_deref() | ||
| .unwrap_or_else(|| ui_text(lang, "doctor_unknown_version")), | ||
| ) | ||
| }), | ||
| install: command.is_none().then_some(InstallHelp::Python), | ||
| }, | ||
| install: (!ok).then_some(InstallHelp::Python), | ||
| } | ||
@@ -229,35 +249,76 @@ } | ||
| fn java_check<F>(lang: &str, has_command: &mut F) -> DoctorCheck | ||
| fn java_check<F, V>(lang: &str, has_command: &mut F, command_version: &mut V) -> DoctorCheck | ||
| where | ||
| F: FnMut(&str) -> bool, | ||
| V: FnMut(&str, &[&str]) -> Option<String>, | ||
| { | ||
| let has_javac = has_command("javac"); | ||
| let has_java = has_command("java"); | ||
| let detail = match (has_javac, has_java) { | ||
| (true, true) => "javac + java".to_string(), | ||
| (false, true) => missing_tool(lang, "javac"), | ||
| (true, false) => missing_tool(lang, "java"), | ||
| (false, false) => format!( | ||
| let missing_detail = match (has_javac, has_java) { | ||
| (true, true) => None, | ||
| (false, true) => Some(missing_tool(lang, "javac")), | ||
| (true, false) => Some(missing_tool(lang, "java")), | ||
| (false, false) => Some(format!( | ||
| "{}; {}", | ||
| missing_tool(lang, "javac"), | ||
| missing_tool(lang, "java") | ||
| ), | ||
| )), | ||
| }; | ||
| if let Some(detail) = missing_detail { | ||
| return DoctorCheck { | ||
| status: DoctorStatus::Missing, | ||
| name: "Java", | ||
| detail, | ||
| install: Some(InstallHelp::Java), | ||
| }; | ||
| } | ||
| let javac = command_version("javac", &["--version"]); | ||
| let java = command_version("java", &["--version"]); | ||
| let ok = javac | ||
| .as_deref() | ||
| .is_some_and(|version| reported_version_at_least(version, 21, 0, 0)) | ||
| && java | ||
| .as_deref() | ||
| .is_some_and(|version| reported_version_at_least(version, 21, 0, 0)); | ||
| let versions = format!( | ||
| "{} + {}", | ||
| javac | ||
| .as_deref() | ||
| .unwrap_or_else(|| ui_text(lang, "doctor_unknown_version")), | ||
| java.as_deref() | ||
| .unwrap_or_else(|| ui_text(lang, "doctor_unknown_version")) | ||
| ); | ||
| DoctorCheck { | ||
| status: if has_javac && has_java { | ||
| status: if ok { | ||
| DoctorStatus::Ok | ||
| } else { | ||
| DoctorStatus::Missing | ||
| DoctorStatus::Update | ||
| }, | ||
| name: "Java", | ||
| detail, | ||
| install: (!(has_javac && has_java)).then_some(InstallHelp::Java), | ||
| detail: if ok { | ||
| versions | ||
| } else { | ||
| ui_text(lang, "doctor_java_required").replace("{version}", &versions) | ||
| }, | ||
| install: (!ok).then_some(InstallHelp::Java), | ||
| } | ||
| } | ||
| fn rust_check<F>(lang: &str, has_command: &mut F) -> DoctorCheck | ||
| fn rust_check<F, V>(lang: &str, has_command: &mut F, command_version: &mut V) -> DoctorCheck | ||
| where | ||
| F: FnMut(&str) -> bool, | ||
| V: FnMut(&str, &[&str]) -> Option<String>, | ||
| { | ||
| let ok = has_command("rustc"); | ||
| if !has_command("rustc") { | ||
| return DoctorCheck { | ||
| status: DoctorStatus::Missing, | ||
| name: "Rust", | ||
| detail: missing_tool(lang, "rustc"), | ||
| install: Some(InstallHelp::Rust), | ||
| }; | ||
| } | ||
| let version = command_version("rustc", &["--version"]); | ||
| let ok = version | ||
| .as_deref() | ||
| .is_some_and(|version| reported_version_at_least(version, 1, 85, 0)); | ||
| DoctorCheck { | ||
@@ -267,9 +328,14 @@ status: if ok { | ||
| } else { | ||
| DoctorStatus::Missing | ||
| DoctorStatus::Update | ||
| }, | ||
| name: "Rust", | ||
| detail: if ok { | ||
| "rustc".to_string() | ||
| version.unwrap() | ||
| } else { | ||
| missing_tool(lang, "rustc") | ||
| ui_text(lang, "doctor_rust_required").replace( | ||
| "{version}", | ||
| version | ||
| .as_deref() | ||
| .unwrap_or_else(|| ui_text(lang, "doctor_unknown_version")), | ||
| ) | ||
| }, | ||
@@ -373,3 +439,7 @@ install: (!ok).then_some(InstallHelp::Rust), | ||
| } | ||
| let output = output.stdout.trim().to_string(); | ||
| let output = if output.stdout.trim().is_empty() { | ||
| output.stderr.trim().to_string() | ||
| } else { | ||
| output.stdout.trim().to_string() | ||
| }; | ||
| (!output.is_empty()).then_some(output) | ||
@@ -382,13 +452,31 @@ } | ||
| fn reported_version_at_least(output: &str, major: u64, minor: u64, patch: u64) -> bool { | ||
| output | ||
| .split_whitespace() | ||
| .find(|part| { | ||
| part.trim_start_matches('v') | ||
| .starts_with(|char: char| char.is_ascii_digit()) | ||
| }) | ||
| .is_some_and(|version| { | ||
| version_at_least(version.trim_start_matches('v'), major, minor, patch) | ||
| }) | ||
| } | ||
| fn version_at_least(version: &str, major: u64, minor: u64, patch: u64) -> bool { | ||
| let mut parts = version | ||
| .split(['.', '-']) | ||
| .take(3) | ||
| .map(|part| part.parse::<u64>().unwrap_or(0)); | ||
| let found = ( | ||
| parts.next().unwrap_or(0), | ||
| parts.next().unwrap_or(0), | ||
| parts.next().unwrap_or(0), | ||
| ); | ||
| found >= (major, minor, patch) | ||
| let mut found = [0; 3]; | ||
| let mut count = 0; | ||
| for part in version.split('.') { | ||
| if count == found.len() | ||
| || part.is_empty() | ||
| || !part.bytes().all(|byte| byte.is_ascii_digit()) | ||
| { | ||
| return false; | ||
| } | ||
| let Ok(value) = part.parse() else { | ||
| return false; | ||
| }; | ||
| found[count] = value; | ||
| count += 1; | ||
| } | ||
| count > 0 && found >= [major, minor, patch] | ||
| } | ||
@@ -408,2 +496,37 @@ | ||
| #[test] | ||
| fn doctor_rejects_runtimes_that_cannot_run_the_documented_targets() { | ||
| let output = doctor_text_with( | ||
| "en", | ||
| "python", | ||
| "codex", | ||
| |_| true, | ||
| |program, _| { | ||
| Some( | ||
| match program { | ||
| "python3" | "python" => "Python 3.11.9", | ||
| "node" => "v22.6.0", | ||
| "tsc" => "Version 5.9.3", | ||
| "javac" => "javac 17.0.12", | ||
| "java" => "openjdk 17.0.12", | ||
| "rustc" => "rustc 1.84.1", | ||
| _ => "unknown", | ||
| } | ||
| .to_string(), | ||
| ) | ||
| }, | ||
| ); | ||
| assert!(output.contains("UPDATE Python"), "{output}"); | ||
| assert!(output.contains("UPDATE Java"), "{output}"); | ||
| assert!(output.contains("UPDATE Rust"), "{output}"); | ||
| } | ||
| #[test] | ||
| fn doctor_rejects_malformed_versions_with_large_major_numbers() { | ||
| assert!(!reported_version_at_least("Python 999.invalid", 3, 12, 0)); | ||
| assert!(!reported_version_at_least("javac 999.unknown", 21, 0, 0)); | ||
| assert!(!node_supports_strip_types("v999.invalid")); | ||
| } | ||
| #[test] | ||
| fn doctor_output_includes_install_help_for_missing_runtimes() { | ||
@@ -410,0 +533,0 @@ let output = doctor_text_with("en", "python", "codex", |_| false, |_, _| None); |
+50
-14
@@ -9,3 +9,3 @@ use super::*; | ||
| let path = ensure_submission(&self.root, &self.problem, &self.state.settings)?; | ||
| let text = fs::read_to_string(path).unwrap_or_default(); | ||
| let text = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; | ||
| self.editor.set_text(&text); | ||
@@ -20,3 +20,3 @@ Ok(()) | ||
| let path = ensure_submission(&self.root, &self.problem, &self.state.settings)?; | ||
| fs::write(path, self.editor.text())?; | ||
| save_user_text(&path, &self.editor.text())?; | ||
| Ok(()) | ||
@@ -28,3 +28,3 @@ } | ||
| let path = ensure_syntax_submission(&self.root, lesson)?; | ||
| let text = fs::read_to_string(path).unwrap_or_default(); | ||
| let text = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; | ||
| self.editor.set_text(&text); | ||
@@ -37,3 +37,3 @@ Ok(()) | ||
| let path = ensure_syntax_submission(&self.root, lesson)?; | ||
| fs::write(path, self.editor.text())?; | ||
| save_user_text(&path, &self.editor.text())?; | ||
| Ok(()) | ||
@@ -61,3 +61,4 @@ } | ||
| .list_cursor | ||
| .unwrap_or_else(|| self.current_problem_index()); | ||
| .unwrap_or_else(|| self.current_problem_index()) | ||
| .min(self.bank.len().saturating_sub(1)); | ||
| let mut lines = vec![ | ||
@@ -118,3 +119,4 @@ ui_text(lang, "problem_list_title").to_string(), | ||
| .list_cursor | ||
| .unwrap_or_else(|| self.current_problem_index()) as isize; | ||
| .unwrap_or_else(|| self.current_problem_index()) | ||
| .min(self.bank.len() - 1) as isize; | ||
| let len = self.bank.len() as isize; | ||
@@ -126,5 +128,6 @@ self.list_cursor = Some(((cursor + delta).rem_euclid(len)) as usize); | ||
| pub(super) fn open_selected_problem(&mut self) -> Result<()> { | ||
| if let Some(cursor) = self.list_cursor { | ||
| let problem_id = self.bank[cursor].id.clone(); | ||
| self.list_cursor = None; | ||
| if let Some(cursor) = self.list_cursor.take() | ||
| && let Some(problem) = self.bank.get(cursor).or_else(|| self.bank.last()) | ||
| { | ||
| let problem_id = problem.id.clone(); | ||
| self.open_problem(&problem_id)?; | ||
@@ -159,5 +162,5 @@ } | ||
| } | ||
| save_state(&self.root, &self.state)?; | ||
| ensure_problem_files(&self.root, &self.problem)?; | ||
| self.load_code_editor()?; | ||
| save_state(&self.root, &self.state)?; | ||
| self.show_output = false; | ||
@@ -202,7 +205,15 @@ self.practice_view = PracticeView::Problem; | ||
| .join(format!("solution.{}", ext_for(&language))); | ||
| if !path.exists() { | ||
| return ("missing".to_string(), format!("({language})")); | ||
| let relative = path.strip_prefix(&self.root).unwrap_or(&path).display(); | ||
| match fs::symlink_metadata(&path) { | ||
| Err(error) if error.kind() == std::io::ErrorKind::NotFound => { | ||
| return ("missing".to_string(), format!("({language})")); | ||
| } | ||
| Ok(metadata) if metadata.file_type().is_file() => {} | ||
| Ok(_) | Err(_) => { | ||
| return ("unreadable".to_string(), format!("({relative})")); | ||
| } | ||
| } | ||
| let content = fs::read_to_string(&path).unwrap_or_default(); | ||
| let relative = path.strip_prefix(&self.root).unwrap_or(&path).display(); | ||
| let Ok(content) = fs::read_to_string(&path) else { | ||
| return ("unreadable".to_string(), format!("({relative})")); | ||
| }; | ||
| if content == template_for(&language) { | ||
@@ -272,2 +283,27 @@ ("template".to_string(), format!("({relative})")) | ||
| } | ||
| #[test] | ||
| fn unreadable_submission_does_not_clear_the_editor() { | ||
| let mut app = app("en"); | ||
| app.editor.set_text("keep this text"); | ||
| let path = ensure_submission(&app.root, &app.problem, &app.state.settings).unwrap(); | ||
| std::fs::write(&path, [0xff]).unwrap(); | ||
| assert!(app.load_code_editor().is_err()); | ||
| assert_eq!(app.editor.text(), "keep this text"); | ||
| assert_eq!(std::fs::read(path).unwrap(), [0xff]); | ||
| assert_eq!(app.submission_status(&app.problem).0, "unreadable"); | ||
| } | ||
| #[test] | ||
| fn opening_a_stale_problem_cursor_clamps_to_the_bank() { | ||
| let mut app = app("en"); | ||
| let expected = app.bank.last().unwrap().id.clone(); | ||
| app.list_cursor = Some(app.bank.len() + 10); | ||
| app.open_selected_problem().unwrap(); | ||
| assert_eq!(app.problem.id, expected); | ||
| assert!(app.list_cursor.is_none()); | ||
| } | ||
| } |
+104
-24
@@ -40,23 +40,31 @@ use super::*; | ||
| pub(super) fn check_task(&mut self) { | ||
| let task = self.task_rx.as_ref().and_then(|rx| rx.try_recv().ok()); | ||
| if let Some(task) = task { | ||
| self.task_rx = None; | ||
| self.stop_busy(); | ||
| match task { | ||
| TaskResult::AiPrompt(output) => self.write_output(&output), | ||
| TaskResult::Next { | ||
| output, | ||
| old_problem, | ||
| fallback_to_local, | ||
| } => { | ||
| if let Err(error) = | ||
| self.finish_next_problem(output, old_problem, fallback_to_local) | ||
| { | ||
| self.write_text_output(&format!( | ||
| "{}\n{error}", | ||
| ui_text(&self.state.settings.ui_language, "next_failed") | ||
| )); | ||
| let task = self.task_rx.as_ref().map(Receiver::try_recv); | ||
| match task { | ||
| Some(Ok(task)) => { | ||
| self.task_rx = None; | ||
| self.stop_busy(); | ||
| match task { | ||
| TaskResult::AiPrompt(output) => self.write_output(&output), | ||
| TaskResult::Next { | ||
| output, | ||
| old_problem, | ||
| fallback_to_local, | ||
| } => { | ||
| if let Err(error) = | ||
| self.finish_next_problem(output, old_problem, fallback_to_local) | ||
| { | ||
| self.write_text_output(&format!( | ||
| "{}\n{error}", | ||
| ui_text(&self.state.settings.ui_language, "next_failed") | ||
| )); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Some(Err(TryRecvError::Disconnected)) => { | ||
| self.task_rx = None; | ||
| self.stop_busy(); | ||
| self.write_text_output(ui_text(&self.state.settings.ui_language, "task_failed")); | ||
| } | ||
| Some(Err(TryRecvError::Empty)) | None => {} | ||
| } | ||
@@ -66,5 +74,8 @@ } | ||
| pub(super) fn check_background_generation(&mut self) { | ||
| let output = self.generate_rx.as_ref().and_then(|rx| rx.try_recv().ok()); | ||
| let Some(result) = output else { | ||
| return; | ||
| let result = match self.generate_rx.as_ref().map(Receiver::try_recv) { | ||
| Some(Ok(result)) => result, | ||
| Some(Err(TryRecvError::Disconnected)) => AiGenerationResult::FailedToRun( | ||
| ui_text(&self.state.settings.ui_language, "task_failed").to_string(), | ||
| ), | ||
| Some(Err(TryRecvError::Empty)) | None => return, | ||
| }; | ||
@@ -75,6 +86,21 @@ self.generate_rx = None; | ||
| 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!( | ||
| "current problem {} is missing from the reloaded bank", | ||
| self.state.current_problem | ||
| )), | ||
| ) | ||
| } | ||
| Ok(bank) => { | ||
| let added = bank.len().saturating_sub(old_len); | ||
| self.bank = bank; | ||
| let _ = save_state(&self.root, &self.state); | ||
| if let Some(cursor) = self.list_cursor.as_mut() { | ||
| *cursor = (*cursor).min(self.bank.len() - 1); | ||
| } | ||
| (added, None) | ||
@@ -110,3 +136,7 @@ } | ||
| pub(super) fn check_update(&mut self) { | ||
| let result = self.update_rx.as_ref().and_then(|rx| rx.try_recv().ok()); | ||
| let result = match self.update_rx.as_ref().map(Receiver::try_recv) { | ||
| Some(Ok(result)) => Some(result), | ||
| Some(Err(TryRecvError::Disconnected)) => Some(UpdateCheck::Failed), | ||
| Some(Err(TryRecvError::Empty)) | None => None, | ||
| }; | ||
| let showing_update_check = | ||
@@ -168,3 +198,15 @@ self.output == ui_text(&self.state.settings.ui_language, "update_checking"); | ||
| pub(super) fn check_models(&mut self) { | ||
| let models = self.model_rx.as_ref().and_then(|rx| rx.try_recv().ok()); | ||
| let models = match self.model_rx.as_ref().map(Receiver::try_recv) { | ||
| Some(Ok(models)) => Some(models), | ||
| Some(Err(TryRecvError::Disconnected)) => { | ||
| self.available_models_provider.clear(); | ||
| Some(ModelCatalog { | ||
| models: Vec::new(), | ||
| message: Some( | ||
| ui_text(&self.state.settings.ui_language, "model_unavailable").to_string(), | ||
| ), | ||
| }) | ||
| } | ||
| Some(Err(TryRecvError::Empty)) | None => None, | ||
| }; | ||
| if let Some(catalog) = models { | ||
@@ -786,2 +828,40 @@ self.model_rx = None; | ||
| } | ||
| #[test] | ||
| fn disconnected_workers_clear_their_receivers() { | ||
| let mut app = localized_app("practicode-disconnected-workers", "en"); | ||
| let (tx, rx) = std::sync::mpsc::channel::<TaskResult>(); | ||
| drop(tx); | ||
| app.task_rx = Some(rx); | ||
| app.start_busy("ai", "codex"); | ||
| app.check_task(); | ||
| assert!(app.task_rx.is_none()); | ||
| assert!(app.busy_label.is_empty()); | ||
| let (tx, rx) = std::sync::mpsc::channel::<AiGenerationResult>(); | ||
| drop(tx); | ||
| app.generate_rx = Some(rx); | ||
| app.generate_started = Some(Instant::now()); | ||
| app.check_background_generation(); | ||
| assert!(app.generate_rx.is_none()); | ||
| assert!(matches!( | ||
| app.generate_notice, | ||
| Some(GenerationNotice::Failed { .. }) | ||
| )); | ||
| let (tx, rx) = std::sync::mpsc::channel::<UpdateCheck>(); | ||
| drop(tx); | ||
| app.update_rx = Some(rx); | ||
| app.check_update(); | ||
| assert!(app.update_rx.is_none()); | ||
| assert!(matches!(app.update_check, Some(UpdateCheck::Failed))); | ||
| let (tx, rx) = std::sync::mpsc::channel::<ModelCatalog>(); | ||
| drop(tx); | ||
| app.model_rx = Some(rx); | ||
| app.check_models(); | ||
| assert!(app.model_rx.is_none()); | ||
| assert!(app.model_message.is_some()); | ||
| } | ||
| } |
+12
-6
@@ -37,10 +37,16 @@ use crate::process::{run_capture, which}; | ||
| pub fn is_newer(latest: &str, current: &str) -> bool { | ||
| version_parts(latest) > version_parts(current) | ||
| matches!( | ||
| (version_parts(latest), version_parts(current)), | ||
| (Some(latest), Some(current)) if latest > current | ||
| ) | ||
| } | ||
| fn version_parts(version: &str) -> Vec<u64> { | ||
| version | ||
| .split('.') | ||
| .map(|part| part.parse::<u64>().unwrap_or(0)) | ||
| .collect() | ||
| fn version_parts(version: &str) -> Option<[u64; 3]> { | ||
| let mut parts = version.split('.'); | ||
| let version = [ | ||
| parts.next()?.parse().ok()?, | ||
| parts.next()?.parse().ok()?, | ||
| parts.next()?.parse().ok()?, | ||
| ]; | ||
| parts.next().is_none().then_some(version) | ||
| } |
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 2 instances
1761551
1.8%16237
0.29%11
-8.33%3
-25%