🎩 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
22
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.1.19
to
0.1.20
+11
-1
bin/practicode.js
#!/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",

[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

{
"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
![Rust](https://img.shields.io/badge/Rust-terminal%20app-000000?logo=rust&logoColor=white)
![Ratatui](https://img.shields.io/badge/Ratatui-TUI-00B4D8)
![Local first](https://img.shields.io/badge/local--first-practice-14B8A6)
![AI ready](https://img.shields.io/badge/AI-Codex%20%2B%20Claude-111827)
![crates.io](https://img.shields.io/crates/v/practicode?logo=rust)
![npm](https://img.shields.io/npm/v/practicode?logo=npm)
![CI](https://github.com/baba9811/practicode/actions/workflows/ci.yml/badge.svg)
[Socket.dev package health](https://socket.dev/npm/package/practicode)
[![Rust](https://img.shields.io/badge/Rust-terminal%20app-000000?logo=rust&logoColor=white)](https://www.rust-lang.org/)
[![Ratatui](https://img.shields.io/badge/Ratatui-TUI-00B4D8)](https://ratatui.rs/)
[![Local first](https://img.shields.io/badge/local--first-practice-14B8A6)](#local-data)
[![AI ready](https://img.shields.io/badge/AI-Codex%20%2B%20Claude-111827)](#commands)
[![crates.io](https://img.shields.io/crates/v/practicode?logo=rust)](https://crates.io/crates/practicode)
[![npm](https://img.shields.io/npm/v/practicode?logo=npm)](https://www.npmjs.com/package/practicode)
[![CI](https://github.com/baba9811/practicode/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/baba9811/practicode/actions/workflows/ci.yml)
[![Socket.dev package health](https://img.shields.io/badge/Socket.dev-package%20health-5E69EE)](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).

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

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

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