practicode
Advanced tools
+11
-1
| #!/usr/bin/env node | ||
| const { spawnSync } = require("node:child_process"); | ||
| const { existsSync } = require("node:fs"); | ||
| const { existsSync, mkdirSync } = require("node:fs"); | ||
| const { homedir } = require("node:os"); | ||
| const path = require("node:path"); | ||
@@ -76,2 +77,7 @@ | ||
| const dataHome = path.resolve( | ||
| process.env.PRACTICODE_HOME || path.join(homedir(), ".practicode"), | ||
| ); | ||
| mkdirSync(dataHome, { recursive: true }); | ||
| const runArgs = [ | ||
@@ -99,2 +105,4 @@ "run", | ||
| `type=bind,source=${process.cwd()},target=/workspace`, | ||
| "--mount", | ||
| `type=bind,source=${dataHome},target=/data`, | ||
| "-w", | ||
@@ -107,2 +115,4 @@ "/workspace", | ||
| "-e", | ||
| "PRACTICODE_HOME=/data", | ||
| "-e", | ||
| "PRACTICODE_NO_UPDATE_CHECK=1", | ||
@@ -109,0 +119,0 @@ ]; |
+1
-1
@@ -456,3 +456,3 @@ # This file is automatically @generated by Cargo. | ||
| name = "practicode" | ||
| version = "0.1.19" | ||
| version = "0.1.20" | ||
| dependencies = [ | ||
@@ -459,0 +459,0 @@ "anyhow", |
+1
-1
| [package] | ||
| name = "practicode" | ||
| version = "0.1.19" | ||
| version = "0.1.20" | ||
| edition = "2024" | ||
@@ -5,0 +5,0 @@ description = "Local-first coding-test practice in a Rust terminal UI with optional AI help." |
| # Architecture | ||
| Practicode is local-first: user data stays under `.practicode/`, `problems/`, and `submissions/`. | ||
| Practicode is local-first: user data stays under `PRACTICODE_HOME` or `~/.practicode` by default. | ||
@@ -18,2 +18,3 @@ ## Source Layout | ||
| - `src/core/profile.rs` owns user-profile defaults and normalization helpers. | ||
| - `src/lib.rs` resolves the application data root and migrates legacy current-directory data before loading the TUI. | ||
| - `src/tui.rs` owns the `PracticodeApp` state shell, construction, run loop, and test accessors. Keep new TUI behavior in nested `src/tui/` modules. | ||
@@ -20,0 +21,0 @@ - `src/tui/actions.rs` owns user actions such as run, next, generate, language/theme/profile changes. |
@@ -13,3 +13,3 @@ # Contributing | ||
| - Check [ARCHITECTURE.md](ARCHITECTURE.md) before adding commands, settings, provider behavior, or persisted state. | ||
| - Do not commit local practice data from `.practicode/`, `problems/`, or `submissions/`. | ||
| - Do not commit legacy practice data from `.practicode/`, `problems/`, or `submissions/`, or copy global `~/.practicode` data into the repository. | ||
| - Do not include secrets, tokens, private prompts, or generated answer keys in docs or examples. | ||
@@ -95,11 +95,11 @@ | ||
| AI generation reads [problem-authoring-notes.md](problem-authoring-notes.md) every time it creates a problem. | ||
| Use [problem-authoring-notes.md](problem-authoring-notes.md) when changing problem-generation behavior. | ||
| Local generated data stays ignored by git: | ||
| Runtime paths are relative to `PRACTICODE_HOME` or `~/.practicode`: | ||
| | Path | Purpose | | ||
| | --- | --- | | ||
| | `.practicode/problem_bank.json` | Local/custom/generated problems | | ||
| | `.practicode/problem_notes.md` | Personal problem-generation notes | | ||
| | `.practicode/problem-state.json` | Current problem, history, settings | | ||
| | `problem_bank.json` | Local/custom/generated problems | | ||
| | `problem_notes.md` | Personal problem-generation notes | | ||
| | `problem-state.json` | Current problem, history, settings | | ||
| | `problems/` | Generated problem markdown/index files | | ||
@@ -106,0 +106,0 @@ | `submissions/` | Local answer files | |
@@ -13,3 +13,3 @@ # Problem Authoring Notes | ||
| - Include enough hidden cases to catch empty/min/max, duplicates, ties, ordering, and whitespace mistakes. | ||
| - Keep answers for `python`, `ts`, `java`, and `rust` in `.practicode/problem_bank.json`; never put answers in `README.md`. | ||
| - Keep answers for `python`, `ts`, `java`, and `rust` in `problem_bank.json` at the application data root; never put answers in `README.md`. | ||
| - Do not create `solution.*`, `test_solution.*`, or answer-revealing files inside `problems/NNN-slug/`. | ||
@@ -26,3 +26,3 @@ The learner's editable code belongs under `submissions/`, and answer keys belong only in the local bank or built-in data. | ||
| If `.practicode/problem_notes.md` exists, read it too. That file is for personal themes like: | ||
| If `problem_notes.md` exists at the application data root, read it too. That file is for personal themes like: | ||
@@ -29,0 +29,0 @@ ```text |
+1
-1
| { | ||
| "name": "practicode", | ||
| "version": "0.1.19", | ||
| "version": "0.1.20", | ||
| "description": "Local-first coding-test practice in a Rust terminal UI with optional AI help.", | ||
@@ -5,0 +5,0 @@ "license": "MIT", |
+25
-17
| # practicode | ||
|  | ||
|  | ||
|  | ||
|  | ||
|  | ||
|  | ||
|  | ||
| [Socket.dev package health](https://socket.dev/npm/package/practicode) | ||
| [](https://www.rust-lang.org/) | ||
| [](https://ratatui.rs/) | ||
| [](#local-data) | ||
| [](#commands) | ||
| [](https://crates.io/crates/practicode) | ||
| [](https://www.npmjs.com/package/practicode) | ||
| [](https://github.com/baba9811/practicode/actions/workflows/ci.yml) | ||
| [](https://socket.dev/npm/package/practicode) | ||
@@ -44,2 +44,4 @@ Personal coding practice, right in your terminal. | ||
| User data is stored under `~/.practicode` by default, regardless of the directory where you run the command. | ||
| <details> | ||
@@ -138,3 +140,3 @@ <summary>Cargo</summary> | ||
| The launcher builds a local `practicode-sandbox:<version>` image, then runs the TUI in Docker with the current directory mounted at `/workspace`. The container runs without network access, with a read-only root filesystem, a writable `/tmp`, dropped Linux capabilities, `no-new-privileges`, and CPU/memory/process limits. Your current directory is still writable so `.practicode/`, `problems/`, and `submissions/` can be saved. | ||
| The launcher builds a local `practicode-sandbox:<version>` image, then runs the TUI in Docker. The current directory is mounted at `/workspace` for legacy-data migration, while the host data directory is mounted at `/data` so progress survives container removal. The container runs without network access, with a read-only root filesystem, a writable `/tmp`, dropped Linux capabilities, `no-new-privileges`, and CPU/memory/process limits. | ||
@@ -223,14 +225,20 @@ Install Docker first if needed: | ||
| Generated problems and submissions stay local: | ||
| Generated problems, settings, and submissions live in one user-data directory: | ||
| | Path | Purpose | | ||
| | --- | --- | | ||
| | `.practicode/problem_bank.json` | Local/custom/generated problems | | ||
| | `.practicode/problem_notes.md` | Optional personal problem-generation notes | | ||
| | `.practicode/problem-state.json` | Current problem, history, and settings | | ||
| | `problems/` | Generated problem markdown/index files | | ||
| | `submissions/` | Your answer files | | ||
| | `~/.practicode/problem_bank.json` | Local/custom/generated problems | | ||
| | `~/.practicode/problem_notes.md` | Optional personal problem-generation notes | | ||
| | `~/.practicode/problem-state.json` | Current problem, history, and settings | | ||
| | `~/.practicode/problems/` | Generated problem markdown/index files | | ||
| | `~/.practicode/submissions/` | Your answer files | | ||
| Those paths are ignored by git. | ||
| On Windows, `~` means `%USERPROFILE%`. Set `PRACTICODE_HOME` to use another directory: | ||
| ```bash | ||
| PRACTICODE_HOME=/path/to/practicode-data practicode | ||
| ``` | ||
| When upgrading from `0.1.19` or earlier, launch `practicode` once from the old practice directory while the new data directory is empty. State, problems, and submissions are copied into the new location; the originals are not changed, and disposable build output is not copied. If the old practice directory was your home directory itself, choose a new empty `PRACTICODE_HOME` and keep that override configured afterward; automatic sibling-folder copying is disabled when the old metadata path already equals the default global path. | ||
| ## Safety | ||
@@ -243,3 +251,3 @@ | ||
| - `settings.ai_next_command` can run a custom shell command. Save only commands you trust. | ||
| - Do not commit tokens, private prompts, `.env`, `.npmrc`, `.practicode/`, `problems/`, or `submissions/`. | ||
| - Do not publish tokens, private prompts, `.env`, `.npmrc`, or the contents of your practicode data directory. | ||
@@ -246,0 +254,0 @@ Security reporting details live in [SECURITY.md](SECURITY.md). |
+1
-1
@@ -7,3 +7,3 @@ # Security Policy | ||
| Do not include tokens, private prompts, `.env`, `.npmrc`, `.practicode/`, `problems/`, or `submissions/` contents in public reports. | ||
| Do not include tokens, private prompts, `.env`, `.npmrc`, or `~/.practicode` contents in public reports. | ||
@@ -10,0 +10,0 @@ ## Scope |
+5
-4
@@ -227,3 +227,3 @@ use crate::{ | ||
| format!( | ||
| "Read AGENTS.md, docs/problem-authoring-notes.md if present, .practicode/problem_notes.md if present, problems/INDEX.md if present, .practicode/problem_bank.json if present, and .practicode/problem-state.json. Create exactly one new non-duplicate coding practice problem. The built-in 001-hello-world already exists, so do not duplicate it. User request: {}. User profile: difficulty preference: {}; preferred topics: {}; avoid topics: {}; code language: {}; UI language: {}; generated answer languages: {}; generated UI languages: {}. Treat difficulty auto as gradual progression from state; otherwise prefer the requested difficulty unless the direct user request conflicts. Make the smallest valid edits: update .practicode/problem_bank.json, one problem directory, problems/INDEX.md, and .practicode/problem-state.json. Do not include the answer in the problem statement. Do not create solution.*, test_solution.*, or any answer-revealing file inside the problem directory.", | ||
| "Read problem_notes.md if present, problems/INDEX.md if present, problem_bank.json if present, and problem-state.json. Create exactly one new non-duplicate coding practice problem. The built-in 001-hello-world already exists, so do not duplicate it. User request: {}. User profile: difficulty preference: {}; preferred topics: {}; avoid topics: {}; code language: {}; UI language: {}; generated answer languages: {}; generated UI languages: {}. Treat difficulty auto as gradual progression from state; otherwise prefer the requested difficulty unless the direct user request conflicts. Make the smallest valid edits: update problem_bank.json, one problem directory, problems/INDEX.md, and problem-state.json. Do not include the answer in the problem statement. Do not create solution.*, test_solution.*, or any answer-revealing file inside the problem directory.", | ||
| if request.is_empty() { | ||
@@ -246,3 +246,3 @@ "(none)" | ||
| format!( | ||
| "Read AGENTS.md, docs/problem-authoring-notes.md if present, .practicode/problem_notes.md if present, problems/INDEX.md if present, .practicode/problem_bank.json if present, and .practicode/problem-state.json. Create exactly one new non-duplicate coding practice problem for later use. The built-in 001-hello-world already exists, so do not duplicate it. User request: {}. User profile: difficulty preference: {}; preferred topics: {}; avoid topics: {}; current code language: {}; current UI language: {}; generated answer languages: {}; generated UI languages: {}. Treat difficulty auto as gradual progression from state; otherwise prefer the requested difficulty unless the direct user request conflicts. Make the smallest valid edits: update .practicode/problem_bank.json, one problem directory, and problems/INDEX.md. Preserve .practicode/problem-state.json current_problem, history, solved, and settings; do not switch the current problem. Do not include the answer in the problem statement. Do not create solution.*, test_solution.*, or any answer-revealing file inside the problem directory.", | ||
| "Read problem_notes.md if present, problems/INDEX.md if present, problem_bank.json if present, and problem-state.json. Create exactly one new non-duplicate coding practice problem for later use. The built-in 001-hello-world already exists, so do not duplicate it. User request: {}. User profile: difficulty preference: {}; preferred topics: {}; avoid topics: {}; current code language: {}; current UI language: {}; generated answer languages: {}; generated UI languages: {}. Treat difficulty auto as gradual progression from state; otherwise prefer the requested difficulty unless the direct user request conflicts. Make the smallest valid edits: update problem_bank.json, one problem directory, and problems/INDEX.md. Preserve problem-state.json current_problem, history, solved, and settings; do not switch the current problem. Do not include the answer in the problem statement. Do not create solution.*, test_solution.*, or any answer-revealing file inside the problem directory.", | ||
| if request.is_empty() { | ||
@@ -411,2 +411,3 @@ "(none)" | ||
| "exec", | ||
| "--skip-git-repo-check", | ||
| "--cd", | ||
@@ -481,3 +482,3 @@ &root.display().to_string(), | ||
| let mut exec = format!( | ||
| "codex exec --ephemeral --cd {} --sandbox workspace-write", | ||
| "codex exec --ephemeral --skip-git-repo-check --cd {} --sandbox workspace-write", | ||
| sh_quote(&root.display().to_string()) | ||
@@ -499,3 +500,3 @@ ); | ||
| let mut exec = format!( | ||
| "codex exec --ephemeral --cd {} --sandbox workspace-write", | ||
| "codex exec --ephemeral --skip-git-repo-check --cd {} --sandbox workspace-write", | ||
| sh_quote(&root.display().to_string()) | ||
@@ -502,0 +503,0 @@ ); |
@@ -77,3 +77,3 @@ use super::*; | ||
| }; | ||
| let run_dir = root.join(".practicode/build").join(id).join("run"); | ||
| let run_dir = root.join("build").join(id).join("run"); | ||
| if let Err(error) = fs::create_dir_all(&run_dir) { | ||
@@ -206,3 +206,3 @@ return JudgeResult { | ||
| let build = root | ||
| .join(".practicode/build") | ||
| .join("build") | ||
| .join(path.parent().and_then(Path::file_name).unwrap_or_default()) | ||
@@ -238,3 +238,3 @@ .join("java"); | ||
| let build = root | ||
| .join(".practicode/build") | ||
| .join("build") | ||
| .join(path.parent().and_then(Path::file_name).unwrap_or_default()); | ||
@@ -241,0 +241,0 @@ fs::create_dir_all(&build)?; |
@@ -8,5 +8,5 @@ use super::*; | ||
| pub const CLAUDE_AI_EFFORTS: &[&str] = &["auto", "low", "medium", "high", "xhigh", "max"]; | ||
| pub const BANK_PATH: &str = ".practicode/problem_bank.json"; | ||
| pub const STATE_PATH: &str = ".practicode/problem-state.json"; | ||
| pub const PROBLEM_NOTES_PATH: &str = ".practicode/problem_notes.md"; | ||
| pub const BANK_PATH: &str = "problem_bank.json"; | ||
| pub const STATE_PATH: &str = "problem-state.json"; | ||
| pub const PROBLEM_NOTES_PATH: &str = "problem_notes.md"; | ||
@@ -13,0 +13,0 @@ #[derive(Clone, Debug, Serialize, Deserialize)] |
+557
-4
@@ -1,4 +0,10 @@ | ||
| use anyhow::{Context, Result}; | ||
| use anyhow::{Context, Result, bail}; | ||
| use crossterm::{cursor::SetCursorStyle, event::DisableMouseCapture, execute}; | ||
| use std::{env, io::stdout}; | ||
| use std::{ | ||
| env, | ||
| ffi::OsString, | ||
| fs::{self, File, OpenOptions}, | ||
| io::{self, stdout}, | ||
| path::{Path, PathBuf}, | ||
| }; | ||
@@ -13,2 +19,4 @@ pub mod ai; | ||
| const LEGACY_MIGRATION_MARKER: &str = ".legacy-migration-in-progress"; | ||
| pub fn cli_help_text() -> &'static str { | ||
@@ -30,3 +38,2 @@ "practicode - local-first coding-test practice in your terminal | ||
| pub fn run_cli() -> Result<()> { | ||
| let root = env::current_dir().context("read current directory")?; | ||
| let args = env::args().skip(1).collect::<Vec<_>>(); | ||
@@ -47,2 +54,13 @@ if args | ||
| } | ||
| let launch_dir = env::current_dir().context("read current directory")?; | ||
| let root = absolute_data_root( | ||
| &launch_dir, | ||
| resolve_data_root( | ||
| env::var_os("PRACTICODE_HOME"), | ||
| env::var_os("HOME"), | ||
| env::var_os("USERPROFILE"), | ||
| )?, | ||
| ); | ||
| migrate_legacy_data(&launch_dir, &root)?; | ||
| if args.iter().any(|arg| arg == "--smoke") { | ||
@@ -72,6 +90,277 @@ let bank = core::load_bank(&root)?; | ||
| fn non_empty_path(value: Option<OsString>) -> Option<PathBuf> { | ||
| value.filter(|value| !value.is_empty()).map(PathBuf::from) | ||
| } | ||
| fn resolve_data_root( | ||
| practicode_home: Option<OsString>, | ||
| home: Option<OsString>, | ||
| user_profile: Option<OsString>, | ||
| ) -> Result<PathBuf> { | ||
| if let Some(path) = non_empty_path(practicode_home) { | ||
| return Ok(path); | ||
| } | ||
| if let Some(path) = non_empty_path(home) { | ||
| return Ok(path.join(".practicode")); | ||
| } | ||
| if let Some(path) = non_empty_path(user_profile) { | ||
| return Ok(path.join(".practicode")); | ||
| } | ||
| bail!("cannot find a user home directory; set PRACTICODE_HOME") | ||
| } | ||
| fn absolute_data_root(launch_dir: &Path, root: PathBuf) -> PathBuf { | ||
| if root.is_absolute() { | ||
| root | ||
| } else { | ||
| launch_dir.join(root) | ||
| } | ||
| } | ||
| fn migrate_legacy_data(launch_dir: &Path, root: &Path) -> Result<()> { | ||
| let legacy_metadata = launch_dir.join(".practicode"); | ||
| if !path_exists( | ||
| &legacy_metadata.join(core::STATE_PATH), | ||
| "inspect legacy marker", | ||
| )? && !path_exists( | ||
| &legacy_metadata.join(core::BANK_PATH), | ||
| "inspect legacy marker", | ||
| )? { | ||
| return Ok(()); | ||
| } | ||
| let same_metadata = same_existing_path(&legacy_metadata, root)?; | ||
| if same_metadata { | ||
| return Ok(()); | ||
| } | ||
| let marker = root.join(LEGACY_MIGRATION_MARKER); | ||
| let migration_in_progress = match fs::symlink_metadata(&marker) { | ||
| Ok(metadata) if metadata.file_type().is_file() => true, | ||
| Ok(_) => bail!( | ||
| "migration marker {} is not a regular file", | ||
| marker.display() | ||
| ), | ||
| Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, | ||
| Err(error) => { | ||
| return Err(error) | ||
| .with_context(|| format!("inspect migration marker {}", marker.display())); | ||
| } | ||
| }; | ||
| if !migration_in_progress | ||
| && (path_exists(&root.join(core::STATE_PATH), "inspect destination")? | ||
| || path_exists(&root.join(core::BANK_PATH), "inspect destination")?) | ||
| { | ||
| return Ok(()); | ||
| } | ||
| fs::create_dir_all(root).with_context(|| format!("create data root {}", root.display()))?; | ||
| let canonical_root = validate_migration_root(launch_dir, root)?; | ||
| if !migration_in_progress { | ||
| OpenOptions::new() | ||
| .write(true) | ||
| .create_new(true) | ||
| .open(&marker) | ||
| .with_context(|| format!("create migration marker {}", marker.display()))?; | ||
| } | ||
| for name in [core::STATE_PATH, core::BANK_PATH, core::PROBLEM_NOTES_PATH] { | ||
| copy_file_if_missing( | ||
| &legacy_metadata.join(name), | ||
| &root.join(name), | ||
| &canonical_root, | ||
| )?; | ||
| } | ||
| for name in ["problems", "submissions"] { | ||
| copy_tree_missing(&launch_dir.join(name), &root.join(name), &canonical_root)?; | ||
| } | ||
| fs::remove_file(&marker) | ||
| .with_context(|| format!("remove migration marker {}", marker.display()))?; | ||
| Ok(()) | ||
| } | ||
| fn path_exists(path: &Path, action: &str) -> Result<bool> { | ||
| path.try_exists() | ||
| .with_context(|| format!("{action} {}", path.display())) | ||
| } | ||
| fn validate_migration_root(launch_dir: &Path, root: &Path) -> Result<PathBuf> { | ||
| let root = | ||
| fs::canonicalize(root).with_context(|| format!("resolve data root {}", root.display()))?; | ||
| for name in ["problems", "submissions"] { | ||
| let source = launch_dir.join(name); | ||
| let metadata = match fs::symlink_metadata(&source) { | ||
| Ok(metadata) => metadata, | ||
| Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, | ||
| Err(error) => { | ||
| return Err(error).with_context(|| format!("inspect {}", source.display())); | ||
| } | ||
| }; | ||
| if !metadata.file_type().is_dir() { | ||
| continue; | ||
| } | ||
| let source = fs::canonicalize(&source) | ||
| .with_context(|| format!("resolve legacy directory {}", source.display()))?; | ||
| if root.starts_with(&source) { | ||
| bail!( | ||
| "data root {} cannot be inside legacy directory {}", | ||
| root.display(), | ||
| source.display() | ||
| ); | ||
| } | ||
| } | ||
| Ok(root) | ||
| } | ||
| fn same_existing_path(left: &Path, right: &Path) -> Result<bool> { | ||
| let left = fs::canonicalize(left) | ||
| .with_context(|| format!("resolve legacy metadata {}", left.display()))?; | ||
| match fs::canonicalize(right) { | ||
| Ok(right) => Ok(left == right), | ||
| Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), | ||
| Err(error) => Err(error).with_context(|| format!("resolve data root {}", right.display())), | ||
| } | ||
| } | ||
| fn copy_file_if_missing(source: &Path, destination: &Path, root: &Path) -> Result<()> { | ||
| let metadata = match fs::symlink_metadata(source) { | ||
| Ok(metadata) => metadata, | ||
| Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), | ||
| Err(error) => { | ||
| return Err(error).with_context(|| format!("inspect {}", source.display())); | ||
| } | ||
| }; | ||
| if !metadata.file_type().is_file() { | ||
| return Ok(()); | ||
| } | ||
| match fs::symlink_metadata(destination) { | ||
| Ok(metadata) if metadata.file_type().is_symlink() => bail!( | ||
| "destination file symlink {} is not allowed", | ||
| destination.display() | ||
| ), | ||
| Ok(_) => return Ok(()), | ||
| Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} | ||
| Err(error) => { | ||
| return Err(error).with_context(|| format!("inspect {}", destination.display())); | ||
| } | ||
| } | ||
| let parent = destination | ||
| .parent() | ||
| .with_context(|| format!("find parent for {}", destination.display()))?; | ||
| let parent = fs::canonicalize(parent) | ||
| .with_context(|| format!("resolve destination directory {}", parent.display()))?; | ||
| if !parent.starts_with(root) { | ||
| bail!( | ||
| "destination directory {} is outside data root {}", | ||
| parent.display(), | ||
| root.display() | ||
| ); | ||
| } | ||
| let mut input = File::open(source).with_context(|| format!("open {}", source.display()))?; | ||
| let mut output = OpenOptions::new() | ||
| .write(true) | ||
| .create_new(true) | ||
| .open(destination) | ||
| .with_context(|| format!("create {} without overwriting", destination.display()))?; | ||
| if let Err(error) = io::copy(&mut input, &mut output) { | ||
| let _ = fs::remove_file(destination); | ||
| return Err(error).with_context(|| { | ||
| format!( | ||
| "copy legacy data {} to {}", | ||
| source.display(), | ||
| destination.display() | ||
| ) | ||
| }); | ||
| } | ||
| Ok(()) | ||
| } | ||
| fn copy_tree_missing(source: &Path, destination: &Path, root: &Path) -> Result<()> { | ||
| let metadata = match fs::symlink_metadata(source) { | ||
| Ok(metadata) => metadata, | ||
| Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), | ||
| Err(error) => { | ||
| return Err(error).with_context(|| format!("inspect {}", source.display())); | ||
| } | ||
| }; | ||
| if !metadata.file_type().is_dir() { | ||
| return Ok(()); | ||
| } | ||
| match fs::symlink_metadata(destination) { | ||
| Ok(metadata) if metadata.file_type().is_dir() => { | ||
| let destination = fs::canonicalize(destination).with_context(|| { | ||
| format!("resolve destination directory {}", destination.display()) | ||
| })?; | ||
| if !destination.starts_with(root) { | ||
| bail!( | ||
| "destination directory {} is outside data root {}", | ||
| destination.display(), | ||
| root.display() | ||
| ); | ||
| } | ||
| } | ||
| Ok(_) => bail!( | ||
| "destination directory {} is not a regular directory", | ||
| destination.display() | ||
| ), | ||
| Err(error) if error.kind() == std::io::ErrorKind::NotFound => { | ||
| let parent = destination | ||
| .parent() | ||
| .with_context(|| format!("find parent for {}", destination.display()))?; | ||
| let parent = fs::canonicalize(parent) | ||
| .with_context(|| format!("resolve destination directory {}", parent.display()))?; | ||
| if !parent.starts_with(root) { | ||
| bail!( | ||
| "destination directory {} is outside data root {}", | ||
| parent.display(), | ||
| root.display() | ||
| ); | ||
| } | ||
| fs::create_dir(destination) | ||
| .with_context(|| format!("create directory {}", destination.display()))?; | ||
| } | ||
| Err(error) => { | ||
| return Err(error).with_context(|| format!("inspect {}", destination.display())); | ||
| } | ||
| } | ||
| for entry in fs::read_dir(source) | ||
| .with_context(|| format!("read legacy directory {}", source.display()))? | ||
| { | ||
| let entry = entry.with_context(|| format!("read entry in {}", source.display()))?; | ||
| let file_type = entry | ||
| .file_type() | ||
| .with_context(|| format!("inspect {}", entry.path().display()))?; | ||
| let target = destination.join(entry.file_name()); | ||
| if file_type.is_dir() { | ||
| copy_tree_missing(&entry.path(), &target, root)?; | ||
| } else if file_type.is_file() { | ||
| copy_file_if_missing(&entry.path(), &target, root)?; | ||
| } | ||
| } | ||
| Ok(()) | ||
| } | ||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::cli_help_text; | ||
| use super::*; | ||
| use std::{ | ||
| ffi::OsString, | ||
| fs, | ||
| path::PathBuf, | ||
| time::{SystemTime, UNIX_EPOCH}, | ||
| }; | ||
| fn temp_root(name: &str) -> PathBuf { | ||
| let nanos = SystemTime::now() | ||
| .duration_since(UNIX_EPOCH) | ||
| .unwrap() | ||
| .as_nanos(); | ||
| let root = env::temp_dir().join(format!( | ||
| "practicode-lib-{name}-{}-{nanos}", | ||
| std::process::id() | ||
| )); | ||
| fs::create_dir_all(&root).unwrap(); | ||
| root | ||
| } | ||
| #[test] | ||
@@ -84,2 +373,266 @@ fn cli_help_lists_non_interactive_flags_and_tui_exit() { | ||
| } | ||
| #[test] | ||
| fn data_root_prefers_practicode_home() { | ||
| let root = resolve_data_root( | ||
| Some(OsString::from("/custom")), | ||
| Some(OsString::from("/home/user")), | ||
| Some(OsString::from("/users/user")), | ||
| ) | ||
| .unwrap(); | ||
| assert_eq!(root, PathBuf::from("/custom")); | ||
| } | ||
| #[test] | ||
| fn data_root_uses_home() { | ||
| let root = resolve_data_root(None, Some(OsString::from("/home/user")), None).unwrap(); | ||
| assert_eq!(root, PathBuf::from("/home/user/.practicode")); | ||
| } | ||
| #[test] | ||
| fn data_root_uses_user_profile_when_home_is_empty() { | ||
| let root = resolve_data_root( | ||
| None, | ||
| Some(OsString::new()), | ||
| Some(OsString::from(r"C:\Users\user")), | ||
| ) | ||
| .unwrap(); | ||
| assert_eq!(root, PathBuf::from(r"C:\Users\user").join(".practicode")); | ||
| } | ||
| #[test] | ||
| fn data_root_requires_a_home_directory() { | ||
| let error = resolve_data_root(None, None, None).unwrap_err().to_string(); | ||
| assert!(error.contains("PRACTICODE_HOME")); | ||
| } | ||
| #[test] | ||
| fn relative_data_root_is_resolved_from_the_launch_directory() { | ||
| assert_eq!( | ||
| absolute_data_root(Path::new("/launch"), PathBuf::from("data")), | ||
| PathBuf::from("/launch/data") | ||
| ); | ||
| } | ||
| #[test] | ||
| fn legacy_migration_copies_user_data_without_cache_or_overwrite() { | ||
| let launch = temp_root("legacy-copy-launch"); | ||
| let root = temp_root("legacy-copy-root"); | ||
| let metadata = launch.join(".practicode"); | ||
| fs::create_dir_all(metadata.join("build")).unwrap(); | ||
| fs::create_dir_all(launch.join("problems/002-echo")).unwrap(); | ||
| fs::create_dir_all(launch.join("submissions/002-echo")).unwrap(); | ||
| fs::write(metadata.join("problem-state.json"), "old state").unwrap(); | ||
| fs::write(metadata.join("problem_bank.json"), "old bank").unwrap(); | ||
| fs::write(metadata.join("problem_notes.md"), "old notes").unwrap(); | ||
| fs::write(metadata.join("build/cache"), "cache").unwrap(); | ||
| fs::write(launch.join("problems/INDEX.md"), "index").unwrap(); | ||
| fs::write(launch.join("problems/002-echo/README.md"), "problem").unwrap(); | ||
| fs::write( | ||
| launch.join("submissions/002-echo/solution.rs"), | ||
| "fn main() {}", | ||
| ) | ||
| .unwrap(); | ||
| fs::write(root.join("problem_notes.md"), "keep notes").unwrap(); | ||
| migrate_legacy_data(&launch, &root).unwrap(); | ||
| assert_eq!( | ||
| fs::read_to_string(root.join("problem-state.json")).unwrap(), | ||
| "old state" | ||
| ); | ||
| assert_eq!( | ||
| fs::read_to_string(root.join("problem_bank.json")).unwrap(), | ||
| "old bank" | ||
| ); | ||
| assert_eq!( | ||
| fs::read_to_string(root.join("problem_notes.md")).unwrap(), | ||
| "keep notes" | ||
| ); | ||
| assert_eq!( | ||
| fs::read_to_string(root.join("problems/002-echo/README.md")).unwrap(), | ||
| "problem" | ||
| ); | ||
| assert_eq!( | ||
| fs::read_to_string(root.join("submissions/002-echo/solution.rs")).unwrap(), | ||
| "fn main() {}" | ||
| ); | ||
| assert!(!root.join("build").exists()); | ||
| fs::remove_dir_all(launch).unwrap(); | ||
| fs::remove_dir_all(root).unwrap(); | ||
| } | ||
| #[test] | ||
| fn legacy_migration_skips_a_populated_destination() { | ||
| let launch = temp_root("legacy-skip-launch"); | ||
| let root = temp_root("legacy-skip-root"); | ||
| fs::create_dir_all(launch.join(".practicode")).unwrap(); | ||
| fs::create_dir_all(launch.join("problems")).unwrap(); | ||
| fs::write(launch.join(".practicode/problem-state.json"), "old state").unwrap(); | ||
| fs::write(launch.join("problems/INDEX.md"), "old index").unwrap(); | ||
| fs::write(root.join("problem-state.json"), "new state").unwrap(); | ||
| migrate_legacy_data(&launch, &root).unwrap(); | ||
| assert_eq!( | ||
| fs::read_to_string(root.join("problem-state.json")).unwrap(), | ||
| "new state" | ||
| ); | ||
| assert!(!root.join("problems").exists()); | ||
| fs::remove_dir_all(launch).unwrap(); | ||
| fs::remove_dir_all(root).unwrap(); | ||
| } | ||
| #[test] | ||
| fn legacy_migration_resumes_an_interrupted_copy() { | ||
| let launch = temp_root("legacy-resume-launch"); | ||
| let root = temp_root("legacy-resume-root"); | ||
| fs::create_dir_all(launch.join(".practicode")).unwrap(); | ||
| fs::create_dir_all(launch.join("problems")).unwrap(); | ||
| fs::write(launch.join(".practicode/problem-state.json"), "state").unwrap(); | ||
| fs::write(launch.join(".practicode/problem_bank.json"), "bank").unwrap(); | ||
| fs::write(launch.join("problems/INDEX.md"), "index").unwrap(); | ||
| fs::write(root.join("problem-state.json"), "state").unwrap(); | ||
| fs::write(root.join(".legacy-migration-in-progress"), "").unwrap(); | ||
| migrate_legacy_data(&launch, &root).unwrap(); | ||
| assert_eq!( | ||
| fs::read_to_string(root.join("problem_bank.json")).unwrap(), | ||
| "bank" | ||
| ); | ||
| assert_eq!( | ||
| fs::read_to_string(root.join("problems/INDEX.md")).unwrap(), | ||
| "index" | ||
| ); | ||
| assert!(!root.join(".legacy-migration-in-progress").exists()); | ||
| fs::remove_dir_all(launch).unwrap(); | ||
| fs::remove_dir_all(root).unwrap(); | ||
| } | ||
| #[test] | ||
| fn migration_root_rejects_a_legacy_source_descendant() { | ||
| let launch = temp_root("legacy-descendant"); | ||
| let root = launch.join("problems/data"); | ||
| fs::create_dir_all(&root).unwrap(); | ||
| let error = validate_migration_root(&launch, &root) | ||
| .unwrap_err() | ||
| .to_string(); | ||
| assert!(error.contains("inside legacy")); | ||
| fs::remove_dir_all(launch).unwrap(); | ||
| } | ||
| #[test] | ||
| fn legacy_migration_reports_marker_lookup_errors() { | ||
| let launch = temp_root("legacy-marker-error"); | ||
| let root = temp_root("legacy-marker-error-root"); | ||
| fs::write(launch.join(".practicode"), "not a directory").unwrap(); | ||
| let error = migrate_legacy_data(&launch, &root).unwrap_err().to_string(); | ||
| assert!(error.contains("inspect legacy marker")); | ||
| fs::remove_dir_all(launch).unwrap(); | ||
| fs::remove_dir_all(root).unwrap(); | ||
| } | ||
| #[test] | ||
| #[cfg(unix)] | ||
| fn legacy_migration_does_not_follow_destination_symlinks() { | ||
| use std::os::unix::fs::symlink; | ||
| let launch = temp_root("legacy-symlink-launch"); | ||
| let root = temp_root("legacy-symlink-root"); | ||
| let outside = temp_root("legacy-symlink-outside"); | ||
| fs::create_dir_all(launch.join(".practicode")).unwrap(); | ||
| fs::create_dir_all(launch.join("problems")).unwrap(); | ||
| fs::write(launch.join(".practicode/problem-state.json"), "state").unwrap(); | ||
| fs::write(launch.join(".practicode/problem_bank.json"), "bank").unwrap(); | ||
| fs::write(launch.join(".practicode/problem_notes.md"), "notes").unwrap(); | ||
| fs::write(launch.join("problems/INDEX.md"), "index").unwrap(); | ||
| symlink(outside.join("notes.md"), root.join("problem_notes.md")).unwrap(); | ||
| symlink(&outside, root.join("problems")).unwrap(); | ||
| let error = migrate_legacy_data(&launch, &root).unwrap_err().to_string(); | ||
| assert!(error.contains("destination")); | ||
| assert!(!outside.join("notes.md").exists()); | ||
| assert!(!outside.join("INDEX.md").exists()); | ||
| fs::remove_dir_all(launch).unwrap(); | ||
| fs::remove_dir_all(root).unwrap(); | ||
| fs::remove_dir_all(outside).unwrap(); | ||
| } | ||
| #[test] | ||
| #[cfg(unix)] | ||
| fn legacy_migration_rejects_a_destination_file_symlink() { | ||
| use std::os::unix::fs::symlink; | ||
| let launch = temp_root("legacy-file-symlink-launch"); | ||
| let root = temp_root("legacy-file-symlink-root"); | ||
| let outside = temp_root("legacy-file-symlink-outside"); | ||
| fs::create_dir_all(launch.join(".practicode")).unwrap(); | ||
| fs::write(launch.join(".practicode/problem-state.json"), "state").unwrap(); | ||
| fs::write(launch.join(".practicode/problem_notes.md"), "notes").unwrap(); | ||
| symlink(outside.join("notes.md"), root.join("problem_notes.md")).unwrap(); | ||
| let error = migrate_legacy_data(&launch, &root).unwrap_err().to_string(); | ||
| assert!(error.contains("destination file symlink")); | ||
| assert!(!outside.join("notes.md").exists()); | ||
| fs::remove_dir_all(launch).unwrap(); | ||
| fs::remove_dir_all(root).unwrap(); | ||
| fs::remove_dir_all(outside).unwrap(); | ||
| } | ||
| #[test] | ||
| #[cfg(unix)] | ||
| fn legacy_migration_rejects_a_marker_symlink() { | ||
| use std::os::unix::fs::symlink; | ||
| let launch = temp_root("legacy-marker-symlink-launch"); | ||
| let root = temp_root("legacy-marker-symlink-root"); | ||
| let outside = temp_root("legacy-marker-symlink-outside"); | ||
| fs::create_dir_all(launch.join(".practicode")).unwrap(); | ||
| fs::write(launch.join(".practicode/problem-state.json"), "state").unwrap(); | ||
| symlink(outside.join("marker"), root.join(LEGACY_MIGRATION_MARKER)).unwrap(); | ||
| let error = migrate_legacy_data(&launch, &root).unwrap_err().to_string(); | ||
| assert!(error.contains("migration marker")); | ||
| assert!(!outside.join("marker").exists()); | ||
| fs::remove_dir_all(launch).unwrap(); | ||
| fs::remove_dir_all(root).unwrap(); | ||
| fs::remove_dir_all(outside).unwrap(); | ||
| } | ||
| #[test] | ||
| fn legacy_migration_does_not_copy_siblings_when_metadata_is_already_global() { | ||
| let launch = temp_root("legacy-same-root"); | ||
| let root = launch.join(".practicode"); | ||
| fs::create_dir_all(&root).unwrap(); | ||
| fs::create_dir_all(launch.join("problems")).unwrap(); | ||
| fs::create_dir_all(launch.join("submissions/002-echo")).unwrap(); | ||
| fs::write(root.join("problem-state.json"), "state").unwrap(); | ||
| fs::write(launch.join("problems/INDEX.md"), "index").unwrap(); | ||
| fs::write( | ||
| launch.join("submissions/002-echo/solution.py"), | ||
| "print('echo')", | ||
| ) | ||
| .unwrap(); | ||
| migrate_legacy_data(&launch, &root).unwrap(); | ||
| assert!(!root.join("problems").exists()); | ||
| assert!(!root.join("submissions").exists()); | ||
| fs::remove_dir_all(launch).unwrap(); | ||
| } | ||
| } |
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
1288824
1.72%9396
0.1%270
3.05%6
20%