🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

practicode

Package Overview
Dependencies
Maintainers
1
Versions
25
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

practicode - npm Package Compare versions

Comparing version
0.2.0
to
0.2.1
+1
-0
.dockerignore

@@ -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",

@@ -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",

[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",

{
"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 =

@@ -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(&current) {
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(&current)
.with_context(|| format!("create directory {}", current.display()))?;
}
Err(error) => {
return Err(error).with_context(|| format!("inspect {}", current.display()));
}
}
}
Ok(())
}

@@ -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 @@

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 @@

@@ -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)

@@ -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!(

@@ -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);
}
}

@@ -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 @@

@@ -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(&notes);
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"]);
}
}

@@ -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);

@@ -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());
}
}

@@ -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());
}
}

@@ -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)
}