use std::{
path::Path,
process::{Command, Output},
};
use tempfile::TempDir;
fn alix(args: &[&str]) -> Output {
let home = TempDir::new().unwrap();
Command::new(env!("CARGO_BIN_EXE_alix"))
.args(args)
.env("HOME", home.path())
.env("XDG_CONFIG_HOME", home.path())
.env("XDG_DATA_HOME", home.path())
.output()
.expect("failed to run the alix binary")
}
fn alix_env(args: &[&str], home: &Path, extra_env: &[(&str, &str)]) -> Output {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_alix"));
cmd.args(args)
.env("HOME", home)
.env("XDG_CONFIG_HOME", home)
.env("XDG_DATA_HOME", home);
for (k, v) in extra_env {
cmd.env(k, v);
}
cmd.output().expect("failed to run the alix binary")
}
fn write(dir: &Path, name: &str, contents: &str) -> String {
let path = dir.join(name);
std::fs::write(&path, contents).unwrap();
path.to_str().unwrap().to_string()
}
fn stdout(out: &Output) -> String {
String::from_utf8_lossy(&out.stdout).into_owned()
}
fn stderr(out: &Output) -> String {
String::from_utf8_lossy(&out.stderr).into_owned()
}
const VALID_DECK: &str = "# What is 2 + 2?\n 4\n";
#[test]
fn check_accepts_a_valid_deck() {
let dir = TempDir::new().unwrap();
let deck = write(dir.path(), "math.txt", VALID_DECK);
let out = alix(&["doctor", &deck]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
assert!(stdout(&out).contains("1 cards"), "stdout: {}", stdout(&out));
}
#[test]
fn a_deck_file_argument_errors_with_a_picker_pointer() {
let dir = TempDir::new().unwrap();
let deck = write(dir.path(), "a.txt", VALID_DECK);
let out = alix(&[&deck]);
assert!(!out.status.success(), "a deck-file argument should error");
assert!(
stderr(&out).contains("was removed"),
"stderr: {}",
stderr(&out)
);
}
#[test]
fn the_review_subcommand_is_gone() {
let out = alix(&["review", "x.txt"]);
assert!(
!out.status.success(),
"the review subcommand should be gone"
);
}
#[test]
fn check_rejects_a_malformed_deck() {
let dir = TempDir::new().unwrap();
let deck = write(dir.path(), "broken.txt", "# a front with no answer\n");
let out = alix(&["doctor", &deck]);
assert!(
!out.status.success(),
"a malformed deck should fail the check"
);
assert!(stderr(&out).contains("error:"), "stderr: {}", stderr(&out));
}
#[test]
fn doctor_warns_about_a_malformed_deadline_without_failing() {
let dir = TempDir::new().unwrap();
let ws = dir.path();
std::fs::write(ws.join("alix.toml"), "").unwrap();
std::fs::write(
ws.join("alix.local.toml"),
"[review]\ndeadline = \"soonish\"\n",
)
.unwrap();
write(ws, "cards.txt", VALID_DECK);
let out = alix(&["doctor", ws.to_str().unwrap()]);
assert!(
out.status.success(),
"warnings should not fail the doctor check"
);
let err = stderr(&out);
assert!(err.contains("deadline"), "stderr: {err}");
assert!(err.contains("warning"), "stderr: {err}");
}
#[test]
fn workspace_init_writes_both_documented_manifests() {
let dir = TempDir::new().unwrap();
let ws = dir.path().join("fresh");
let out = alix(&["workspace", "init", ws.to_str().unwrap(), "--title", "T"]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
let manifest = std::fs::read_to_string(ws.join("alix.toml")).unwrap();
assert!(manifest.contains("title = \"T\""), "{manifest}");
assert!(manifest.contains("[defaults]"), "headers stay uncommented");
let local = std::fs::read_to_string(ws.join("alix.local.toml")).unwrap();
assert!(local.contains("[review]"), "headers stay uncommented");
assert!(local.contains("never shared"), "{local}");
assert!(ws.join("assets").is_dir());
}
#[test]
fn workspace_deadline_shows_sets_and_clears() {
let dir = TempDir::new().unwrap();
let ws = dir.path().join("ws");
std::fs::create_dir(&ws).unwrap();
std::fs::write(ws.join("alix.toml"), "title = \"Ws\"\n").unwrap();
std::fs::write(ws.join("cards.txt"), "# Q?\n A\n").unwrap();
let out = alix(&["workspace", "deadline", ws.to_str().unwrap()]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
assert!(
stdout(&out).contains("no deadline"),
"stdout: {}",
stdout(&out)
);
let out = alix(&["workspace", "deadline", ws.to_str().unwrap(), "2099-01-02"]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
assert!(
std::fs::read_to_string(ws.join("alix.local.toml"))
.unwrap()
.contains("2099-01-02")
);
let out = alix(&["workspace", "deadline", ws.to_str().unwrap()]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
let show_output = stdout(&out);
assert!(show_output.contains("2099-01-02"), "stdout: {show_output}");
assert!(show_output.contains("days"), "stdout: {show_output}");
let out = alix(&["workspace", "deadline", ws.to_str().unwrap(), "clear"]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
assert!(
!std::fs::read_to_string(ws.join("alix.local.toml"))
.unwrap()
.contains("deadline")
);
let out = alix(&["workspace", "deadline", ws.to_str().unwrap(), "not-a-date"]);
assert!(!out.status.success(), "stderr: {}", stderr(&out));
}
#[test]
fn workspace_deadline_rejects_non_workspace_non_decks_dir() {
let dir = TempDir::new().unwrap();
let empty_dir = dir.path().join("empty");
std::fs::create_dir(&empty_dir).unwrap();
let out = alix(&["workspace", "deadline", empty_dir.to_str().unwrap()]);
assert!(!out.status.success(), "stderr: {}", stderr(&out));
let err = stderr(&out);
assert!(
err.contains("not a workspace")
|| err.contains("not a decks folder")
|| err.contains("not a workspace or decks folder"),
"stderr should mention neither workspace nor decks folder: {err}"
);
}
#[test]
fn workspace_deadline_rejects_a_decks_folder_without_a_manifest() {
let dir = TempDir::new().unwrap();
let plain = dir.path().join("plain");
std::fs::create_dir(&plain).unwrap();
write(&plain, "cards.txt", "# Q?\n A\n");
let out = alix(&["workspace", "deadline", plain.to_str().unwrap()]);
assert!(!out.status.success(), "stderr: {}", stderr(&out));
let err = stderr(&out);
assert!(err.contains("workspace init"), "stderr: {err}");
}
#[test]
fn stats_on_a_folder_reports_every_deck_inside() {
let dir = TempDir::new().unwrap();
write(dir.path(), "alpha.txt", "# a?\n a\n");
write(dir.path(), "beta.txt", "# b?\n b\n");
let store = dir.path().join("elsewhere.json");
let out = alix(&[
"stats",
dir.path().to_str().unwrap(),
"--store",
store.to_str().unwrap(),
]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
let text = stdout(&out);
assert!(text.contains("alpha"), "stdout: {text}");
assert!(text.contains("beta"), "stdout: {text}");
}
#[test]
fn reset_on_a_workspace_clears_every_member_in_its_own_store() {
let dir = TempDir::new().unwrap();
let ws = dir.path().join("eng");
std::fs::create_dir(&ws).unwrap();
std::fs::write(ws.join("alix.toml"), "title = \"Eng\"\n").unwrap();
let a = write(&ws, "a.txt", "# qa\n ans-a\n");
let b = write(&ws, "b.txt", "# qb\n ans-b\n");
let store_path = ws.join("progress.json");
let mut store = alix::store::Store::open(&store_path).unwrap();
for deck in [&a, &b] {
let cards = alix::parser::parse_str(
Path::new(deck).file_name().unwrap().to_str().unwrap(),
&std::fs::read_to_string(deck).unwrap(),
)
.unwrap();
store.get_or_insert(cards[0].id(), 0);
}
store.set_deck_mastered("a.txt", 0);
store.save().unwrap();
let out = alix(&["reset", ws.to_str().unwrap(), "--yes"]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
let reloaded = alix::store::Store::open(&store_path).unwrap();
assert_eq!(0, reloaded.len(), "member card progress should be gone");
assert!(
!reloaded.deck_mastered("a.txt"),
"the mastered flag should be cleared"
);
}
#[test]
fn stats_reports_a_fresh_deck_against_an_empty_store() {
let dir = TempDir::new().unwrap();
let deck = write(dir.path(), "math.txt", VALID_DECK);
let store = dir.path().join("progress.json"); let out = alix(&["stats", &deck, "--store", store.to_str().unwrap()]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
assert!(
stdout(&out).contains("not started"),
"stdout: {}",
stdout(&out)
);
}
#[test]
fn reset_all_clears_a_seeded_store() {
let dir = TempDir::new().unwrap();
let store = write(
dir.path(),
"progress.json",
r#"{"version":1,"cards":{"123":{"acquired_ms":0}}}"#,
);
let out = alix(&["reset", "--all", "--yes", "--store", &store]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
assert!(
stdout(&out).contains("Reset 1 card(s)."),
"stdout: {}",
stdout(&out)
);
let after = std::fs::read_to_string(&store).unwrap();
assert!(!after.contains("123"), "store still has the card: {after}");
}
fn sample_virtual_card(parent: &str) -> alix::store::VirtualCard {
let text = "# front\n\tback\n";
let id = alix::parser::parse_str(parent, text).unwrap()[0].id();
alix::store::VirtualCard {
id,
kind: alix::store::VirtualKind::Remediation,
parent: parent.to_string(),
text: text.to_string(),
created_ms: 0,
}
}
#[test]
fn reset_all_clears_virtual_cards() {
let dir = TempDir::new().unwrap();
let store_path = dir.path().join("progress.json");
let mut store = alix::store::Store::open(&store_path).unwrap();
let vc = sample_virtual_card("math.txt");
let id = vc.id;
store.insert_virtual(vc);
store.get_or_insert(id, 0); store.save().unwrap();
let out = alix(&[
"reset",
"--all",
"--yes",
"--store",
store_path.to_str().unwrap(),
]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
assert!(
!stdout(&out).contains("No stored progress"),
"a virtual-only store wrongly reported nothing to reset: {}",
stdout(&out)
);
let reloaded = alix::store::Store::open(&store_path).unwrap();
assert_eq!(0, reloaded.iter_virtual_cards().count());
}
#[test]
fn deck_reset_drops_that_decks_virtual_cards() {
let dir = TempDir::new().unwrap();
let deck = write(dir.path(), "math.txt", VALID_DECK);
let store_path = dir.path().join("progress.json");
let mut store = alix::store::Store::open(&store_path).unwrap();
let math_vc = sample_virtual_card("math.txt");
let math_id = math_vc.id;
store.insert_virtual(math_vc);
let other_vc = sample_virtual_card("other.txt");
let other_id = other_vc.id;
store.insert_virtual(other_vc);
store.save().unwrap();
let out = alix(&[
"reset",
&deck,
"--yes",
"--store",
store_path.to_str().unwrap(),
]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
let reloaded = alix::store::Store::open(&store_path).unwrap();
assert!(
reloaded.get_virtual(math_id).is_none(),
"the reset deck's own virtual card should be dropped"
);
assert!(
reloaded.get_virtual(other_id).is_some(),
"another deck's virtual card should survive"
);
}
#[test]
fn deck_reset_without_yes_leaves_store_unchanged() {
let dir = TempDir::new().unwrap();
let deck = write(dir.path(), "math.txt", VALID_DECK);
let store_path = dir.path().join("progress.json");
let card_id = alix::deck::Deck::load(&deck).unwrap().cards[0].id();
let mut store = alix::store::Store::open(&store_path).unwrap();
store.get_or_insert(card_id, 0);
store.set_deck_mastered("math.txt", 0);
store.insert_virtual(sample_virtual_card("math.txt"));
store.save().unwrap();
let before = std::fs::read_to_string(&store_path).unwrap();
let out = alix(&["reset", &deck, "--store", store_path.to_str().unwrap()]);
assert!(
!out.status.success(),
"a no-TTY reset without --yes should error"
);
let after = std::fs::read_to_string(&store_path).unwrap();
assert_eq!(
before, after,
"the store on disk must be untouched by a declined/failed reset"
);
let reloaded = alix::store::Store::open(&store_path).unwrap();
assert!(reloaded.deck_mastered("math.txt"), "mastered flag wiped");
assert!(reloaded.get(card_id).is_some(), "authored progress wiped");
assert_eq!(
1,
reloaded.virtual_cards_for("math.txt").len(),
"virtual card wiped"
);
}
#[test]
fn confirmed_virtual_only_deck_reset_clears_virtual() {
let dir = TempDir::new().unwrap();
let deck = write(dir.path(), "math.txt", VALID_DECK);
let store_path = dir.path().join("progress.json");
let mut store = alix::store::Store::open(&store_path).unwrap();
store.insert_virtual(sample_virtual_card("math.txt"));
store.save().unwrap();
let out = alix(&[
"reset",
&deck,
"--yes",
"--store",
store_path.to_str().unwrap(),
]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
let reloaded = alix::store::Store::open(&store_path).unwrap();
assert_eq!(0, reloaded.virtual_cards_for("math.txt").len());
}
#[test]
fn a_progress_file_of_any_version_loads_pre_1_0() {
let dir = TempDir::new().unwrap();
let deck = write(dir.path(), "math.txt", VALID_DECK);
let store = write(dir.path(), "progress.json", r#"{"version":999,"cards":{}}"#);
let out = alix(&["stats", &deck, "--store", &store]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
}
#[test]
fn a_corrupt_progress_file_fails_without_overwriting_it() {
let dir = TempDir::new().unwrap();
let deck = write(dir.path(), "math.txt", VALID_DECK);
let garbage = "{ this is not valid json";
let store = write(dir.path(), "progress.json", garbage);
let out = alix(&["stats", &deck, "--store", &store]);
assert!(
!out.status.success(),
"a corrupt store should fail the command"
);
assert_eq!(garbage, std::fs::read_to_string(&store).unwrap());
}
fn fake_claude(dir: &Path, reply: &str) -> String {
use std::os::unix::fs::PermissionsExt;
let reply_path = dir.join("fake-reply.txt");
std::fs::write(&reply_path, reply).unwrap();
let script = dir.join("fake-claude");
std::fs::write(
&script,
format!(
"#!/bin/sh\ncat >/dev/null; cat {}\n",
reply_path.to_str().unwrap()
),
)
.unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
script.to_str().unwrap().to_string()
}
#[test]
fn augment_target_format_caches_a_reshape() {
let dir = TempDir::new().unwrap();
let deck = write(dir.path(), "parts.txt", "# List the parts\n A, B, C\n");
let cli = fake_claude(
dir.path(),
r#"{"0": {"back": ["A", "B", "C"], "mode": "line"}}"#,
);
let config = write(
dir.path(),
"config.toml",
&format!("[ask]\ncommand = \"{cli}\"\ntimeout_secs = 10\n"),
);
let store = dir.path().join("p.json");
let out = alix(&[
"deck",
"augment",
&deck,
"--target",
"format",
"--store",
store.to_str().unwrap(),
"--config",
&config,
]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
let cached = std::fs::read_to_string(dir.path().join("augment.json")).unwrap();
assert!(cached.contains("\"A\""), "augment.json: {cached}");
assert!(cached.contains("LineByLine"), "augment.json: {cached}");
let deck_after = std::fs::read_to_string(&deck).unwrap();
assert_eq!(
deck_after, "# List the parts\n A, B, C\n",
"deck untouched"
);
}
#[test]
fn augment_target_format_also_covers_a_decks_virtual_card() {
let dir = TempDir::new().unwrap();
let deck = write(dir.path(), "parts.txt", "# List the parts\n A, B, C\n");
let store_path = dir.path().join("p.json");
let mut store = alix::store::Store::open(&store_path).unwrap();
let vc = sample_virtual_card("parts.txt");
let virtual_id = vc.id;
store.insert_virtual(vc);
store.save().unwrap();
let cli = fake_claude(dir.path(), r#"{"1": {"back": ["X", "Y"], "mode": "line"}}"#);
let config = write(
dir.path(),
"config.toml",
&format!("[ask]\ncommand = \"{cli}\"\ntimeout_secs = 10\n"),
);
let out = alix(&[
"deck",
"augment",
&deck,
"--target",
"format",
"--store",
store_path.to_str().unwrap(),
"--config",
&config,
]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
let cached = std::fs::read_to_string(dir.path().join("augment.json")).unwrap();
assert!(
cached.contains(&virtual_id.to_string()),
"augment.json should key a format entry by the virtual card's id: {cached}"
);
assert!(cached.contains("\"X\""), "augment.json: {cached}");
}
#[test]
fn augment_target_format_skips_an_orphaned_virtual_card_colliding_with_a_real_deck_card() {
let dir = TempDir::new().unwrap();
let deck_text = "# List the parts\n A, B, C\n";
let deck = write(dir.path(), "parts.txt", deck_text);
let real_id = alix::parser::parse_str("parts.txt", deck_text).unwrap()[0].id();
let store_path = dir.path().join("p.json");
let mut store = alix::store::Store::open(&store_path).unwrap();
store.insert_virtual(alix::store::VirtualCard {
id: real_id, kind: alix::store::VirtualKind::Remediation,
parent: "parts.txt".to_string(),
text: deck_text.to_string(),
created_ms: 0,
});
store.save().unwrap();
let cli = fake_claude(
dir.path(),
r#"{"0": {"back": ["A", "B", "C"], "mode": "line"}}"#,
);
let config = write(
dir.path(),
"config.toml",
&format!("[ask]\ncommand = \"{cli}\"\ntimeout_secs = 10\n"),
);
let out = alix(&[
"deck",
"augment",
&deck,
"--target",
"format",
"--store",
store_path.to_str().unwrap(),
"--config",
&config,
]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
assert!(
stdout(&out).contains("augmented 1 of 1 cards"),
"the colliding orphan must not be double-counted alongside the real card: {}",
stdout(&out)
);
}
#[test]
fn missing_backend_reports_install_hint() {
let dir = TempDir::new().unwrap();
let config = write(
dir.path(),
"config.toml",
"[ask]\ncommand = \"/nonexistent/claude-xyz\"\ntimeout_secs = 5\n",
);
let out = alix(&[
"generate",
"https://example.org/page",
"--config",
&config,
"--print",
]);
let err = stderr(&out);
assert!(!out.status.success(), "a missing backend must fail: {err}");
assert!(
err.contains("is it installed"),
"should hint at installation: {err}"
);
}
fn make_large_tree(dir: &std::path::Path, n: usize, bytes: usize) {
let content = vec![0u8; bytes];
for i in 0..n {
std::fs::write(dir.join(format!("f{i}.bin")), &content).unwrap();
}
}
#[test]
fn oversized_local_source_without_yes_bails_with_guidance() {
let dir = TempDir::new().unwrap();
make_large_tree(dir.path(), 10, 600_000); let config = write(
dir.path(),
"config.toml",
"[ask]\ncommand = \"/nonexistent/claude-xyz\"\ntimeout_secs = 5\n",
);
let src = dir.path().to_str().unwrap();
let out = alix(&["generate", src, "--config", &config, "--print"]);
let err = stderr(&out);
assert!(!out.status.success(), "should fail without --yes: {err}");
assert!(err.contains("--yes"), "error must mention --yes: {err}");
assert!(
err.contains("large source tree") || err.contains("files"),
"error must describe the source size: {err}"
);
}
#[test]
fn oversized_local_source_with_yes_proceeds_past_the_guard() {
let dir = TempDir::new().unwrap();
make_large_tree(dir.path(), 10, 600_000); let config = write(
dir.path(),
"config.toml",
"[ask]\ncommand = \"/nonexistent/claude-xyz\"\ntimeout_secs = 5\n",
);
let src = dir.path().to_str().unwrap();
let out = alix(&["generate", src, "--yes", "--config", &config, "--print"]);
let err = stderr(&out);
assert!(
!err.contains("pass --yes to proceed"),
"guard must not fire with --yes: {err}"
);
assert!(
err.contains("is it installed") || err.contains("nonexistent"),
"should reach the backend: {err}"
);
}
#[test]
fn undersized_local_source_proceeds_without_yes() {
let dir = TempDir::new().unwrap();
write(dir.path(), "small.txt", "hello world\n");
let config = write(
dir.path(),
"config.toml",
"[ask]\ncommand = \"/nonexistent/claude-xyz\"\ntimeout_secs = 5\n",
);
let out = alix(&[
"generate",
dir.path().to_str().unwrap(),
"--config",
&config,
"--print",
]);
let err = stderr(&out);
assert!(
!err.contains("pass --yes to proceed"),
"guard must not fire for small trees: {err}"
);
assert!(
err.contains("Exploring") || err.contains("is it installed"),
"should reach the exploration/backend: {err}"
);
}
#[test]
fn a_populated_workspace_no_longer_blocks_the_build() {
let dir = TempDir::new().unwrap();
let src = dir.path().join("src");
std::fs::create_dir_all(&src).unwrap();
write(&src, "notes.md", "# some source material\n");
let ws = dir.path().join("ws");
std::fs::create_dir_all(&ws).unwrap();
write(&ws, "existing.txt", "# q\n a\n");
let config = write(
dir.path(),
"config.toml",
"[ask]\ncommand = \"/nonexistent/claude-xyz\"\ntimeout_secs = 5\n",
);
let out = alix(&[
"generate",
src.to_str().unwrap(),
"--workspace",
ws.to_str().unwrap(),
"--config",
&config,
]);
let err = stderr(&out);
assert!(
!err.contains("already has files"),
"the populated-dest guard is gone: {err}"
);
assert!(
err.contains("is it installed") || err.contains("nonexistent"),
"should get past the destination check to the exploration/backend failure: {err}"
);
}
#[test]
fn a_leftover_staging_dir_blocks_a_headless_rebuild_until_confirmed() {
let dir = TempDir::new().unwrap();
let src = dir.path().join("src");
std::fs::create_dir_all(&src).unwrap();
write(&src, "notes.md", "# some source material\n");
let ws = dir.path().join("ws");
std::fs::create_dir_all(&ws).unwrap();
let staging = dir.path().join(".ws.building");
std::fs::create_dir_all(&staging).unwrap();
write(&staging, "orphan.txt", "# q\n a\n");
let config = write(
dir.path(),
"config.toml",
"[ask]\ncommand = \"/nonexistent/claude-xyz\"\ntimeout_secs = 5\n",
);
let out = alix(&[
"generate",
src.to_str().unwrap(),
"--workspace",
ws.to_str().unwrap(),
"--config",
&config,
]);
assert!(!out.status.success(), "should fail without confirmation");
let err = stderr(&out);
assert!(
err.contains(staging.to_str().unwrap())
|| err.contains("holds files from a previous build"),
"should mention the staging path or the confirm question: {err}"
);
assert!(
!err.contains("Exploring"),
"should bail before spending on exploration: {err}"
);
assert!(
staging.is_dir(),
"declining must leave the staging dir alone"
);
let out = alix(&[
"generate",
src.to_str().unwrap(),
"--workspace",
ws.to_str().unwrap(),
"--config",
&config,
"--yes",
]);
let err = stderr(&out);
assert!(
err.contains("is it installed") || err.contains("nonexistent"),
"should get past the staging confirm to the exploration/backend failure: {err}"
);
}
#[test]
fn deck_check_validates_like_the_old_check() {
let dir = TempDir::new().unwrap();
let deck = write(dir.path(), "math.txt", VALID_DECK);
let out = alix(&["doctor", &deck]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
assert!(stdout(&out).contains("1 cards"), "stdout: {}", stdout(&out));
}
#[test]
fn bare_check_is_gone() {
let dir = TempDir::new().unwrap();
let deck = write(dir.path(), "math.txt", VALID_DECK);
let out = alix(&["check", &deck]);
assert!(
!out.status.success(),
"the old `alix check` must be gone (clap should error)"
);
}
#[test]
fn doctor_backends_reports_a_missing_backend() {
let dir = TempDir::new().unwrap();
let config = write(
dir.path(),
"config.toml",
"[ask]\ncommand = \"/nonexistent/no-such-cli\"\ntimeout_secs = 5\n",
);
let out = alix(&["doctor", "--backends", "--config", &config]);
assert!(
!out.status.success(),
"a missing backend must exit with failure"
);
let combined = format!("{}{}", stdout(&out), stderr(&out));
assert!(
combined.contains("installed") || combined.contains("not found") || combined.contains('✗'),
"output should report the failure: {combined}"
);
}
#[test]
fn doctor_backends_reports_a_working_backend() {
let dir = TempDir::new().unwrap();
let cli = fake_claude(dir.path(), "OK");
let config = write(
dir.path(),
"config.toml",
&format!("[ask]\ncommand = \"{cli}\"\ntimeout_secs = 10\n"),
);
let out = alix(&["doctor", "--backends", "--config", &config]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
let combined = format!("{}{}", stdout(&out), stderr(&out));
assert!(
combined.contains('✓') || combined.contains("ready") || combined.contains("ok"),
"output should report success: {combined}"
);
}
#[test]
fn doctor_all_backends_probes_each() {
let dir = TempDir::new().unwrap();
let config = write(dir.path(), "config.toml", "[ask]\ntimeout_secs = 5\n");
let out = alix(&["doctor", "--all-backends", "--config", &config]);
let combined = format!("{}{}", stdout(&out), stderr(&out));
for name in ["claude", "gemini", "codex", "copilot"] {
assert!(
combined.contains(name),
"output must mention '{name}': {combined}"
);
}
}
fn both_depths_due_card(card_id: u64) -> String {
format!(
r#""{card_id}":{{"acquired_ms":1000,"recall":{{"stability":10.0,"difficulty":5.0,"reps":5,"lapses":0,"state":2,"scheduled_days":20,"last_review_ms":1000,"due_ms":2000,"learning_goods":2}},"reconstruct":{{"stability":8.0,"difficulty":5.0,"reps":3,"lapses":0,"state":1,"scheduled_days":10,"last_review_ms":1000,"due_ms":2000,"learning_goods":1}},"recognized_ms":1000,"total_reviews":5,"total_passes":5}}"#
)
}
#[test]
fn list_shows_per_depth_labels_and_recognized_mark() {
let dir = TempDir::new().unwrap();
let deck_text = "# Q1\n\tA1\n\n# Q2\n\tA2\n";
let deck = write(dir.path(), "cards.txt", deck_text);
let cards = alix::parser::parse_str("cards.txt", deck_text).unwrap();
let (id1, id2) = (cards[0].id(), cards[1].id());
let card1 = both_depths_due_card(id1);
let card2 = format!(
r#""{id2}":{{"acquired_ms":1000,"recall":{{"stability":1.0,"difficulty":5.0,"reps":1,"lapses":0,"state":1,"scheduled_days":0,"last_review_ms":1000,"due_ms":2000,"learning_goods":1}},"total_reviews":1,"total_passes":1}}"#
);
let store = write(
dir.path(),
"store.json",
&format!(r#"{{"version":1,"cards":{{{card1},{card2}}}}}"#),
);
let out = alix(&["list", &deck, "--store", &store]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
let result = stdout(&out);
assert!(
result.contains("[ review| learning]✓"),
"recall slot first, then reconstruct, then the recognized mark: {result}"
);
assert!(
result.contains("[ learning| -] "),
"a depth without a schedule shows '-': {result}"
);
}
#[test]
fn stats_shows_per_depth_due_counts() {
let dir = TempDir::new().unwrap();
let deck_text = "# Q1\n\tA1\n";
let deck = write(dir.path(), "stats.txt", deck_text);
let card_id = alix::parser::parse_str("stats.txt", deck_text).unwrap()[0].id();
let card = both_depths_due_card(card_id);
let store = write(
dir.path(),
"store.json",
&format!(r#"{{"version":1,"cards":{{{card}}}}}"#),
);
let out = alix(&["stats", &deck, "--store", &store]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
let result = stdout(&out);
assert!(
result.contains("due now (recall): 1"),
"the past-due recall schedule must be counted: {result}"
);
assert!(
result.contains("due now (reconstruct): 1"),
"the past-due reconstruct schedule must be counted: {result}"
);
}
#[test]
fn a_nonexistent_target_errors_neither_deck_nor_folder() {
let dir = TempDir::new().unwrap();
let ghost = dir.path().join("ghost");
let out = alix(&["stats", ghost.to_str().unwrap()]);
assert!(!out.status.success());
assert!(
stderr(&out).contains("neither a deck file nor a folder"),
"stderr: {}",
stderr(&out)
);
}
#[test]
fn an_empty_folder_target_errors_no_decks() {
let dir = TempDir::new().unwrap();
let empty = dir.path().join("empty");
std::fs::create_dir(&empty).unwrap();
let out = alix(&["stats", empty.to_str().unwrap()]);
assert!(!out.status.success());
assert!(
stderr(&out).contains("no decks in"),
"stderr: {}",
stderr(&out)
);
}
#[test]
fn importing_into_a_nonexistent_workspace_errors() {
let dir = TempDir::new().unwrap();
let tsv = write(dir.path(), "cards.tsv", "Q1\tA1\n");
let ghost_ws = dir.path().join("ghost-ws");
let out = alix(&[
"deck",
"import",
&tsv,
"--workspace",
ghost_ws.to_str().unwrap(),
]);
assert!(!out.status.success());
assert!(
stderr(&out).contains("no folder at"),
"stderr: {}",
stderr(&out)
);
}
#[test]
fn a_nonexistent_launch_dir_errors_not_a_folder() {
let dir = TempDir::new().unwrap();
let ghost = dir.path().join("ghost");
let out = alix(&[ghost.to_str().unwrap()]);
assert!(!out.status.success());
assert!(
stderr(&out).contains("is not a folder"),
"stderr: {}",
stderr(&out)
);
}
#[test]
fn the_launcher_reports_an_unreadable_config_path() {
let dir = TempDir::new().unwrap();
let bad_config = dir.path().join("nope.toml"); let out = alix(&[
dir.path().to_str().unwrap(),
"--config",
bad_config.to_str().unwrap(),
]);
assert!(!out.status.success());
assert!(
stderr(&out).contains("cannot read config file"),
"stderr: {}",
stderr(&out)
);
}
#[test]
fn config_bare_shows_key_bindings_and_ask_settings() {
let out = alix(&["config"]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
let text = stdout(&out);
assert!(text.contains("key bindings:"), "{text}");
assert!(text.contains("ask:"), "{text}");
assert!(text.contains("generate:"), "{text}");
}
#[test]
fn config_init_writes_a_file_then_refuses_to_clobber_it() {
let home = TempDir::new().unwrap();
let out = alix_env(&["config", "--init"], home.path(), &[]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
assert!(stdout(&out).contains("wrote "), "{}", stdout(&out));
let out2 = alix_env(&["config", "--init"], home.path(), &[]);
assert!(
!out2.status.success(),
"a second --init must refuse to clobber"
);
assert!(
stderr(&out2).contains("already exists"),
"stderr: {}",
stderr(&out2)
);
}
#[test]
fn doctor_bare_reports_config_store_and_decks_sections() {
let out = alix(&["doctor"]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
let text = format!("{}{}", stdout(&out), stderr(&out));
assert!(text.contains("config"), "{text}");
assert!(text.contains("store"), "{text}");
assert!(text.contains("decks"), "{text}");
}
#[test]
fn bare_and_rooted_doctor_share_the_in_folder_store() {
let decks = tempfile::tempdir().unwrap();
std::fs::write(decks.path().join("q.txt"), "# Q\n A\n").unwrap();
let cfg = decks.path().join("config.toml");
std::fs::write(
&cfg,
format!("decks_dir = \"{}\"\n", decks.path().display()),
)
.unwrap();
let cfg = cfg.to_str().unwrap();
let dir = decks.path().to_str().unwrap();
let in_folder = decks.path().join("progress.json");
let in_folder = in_folder.display().to_string();
let bare = String::from_utf8_lossy(&alix(&["doctor", "--config", cfg]).stdout).into_owned();
assert!(bare.contains(&in_folder), "bare doctor store, got:\n{bare}");
let rooted =
String::from_utf8_lossy(&alix(&["doctor", dir, "--config", cfg]).stdout).into_owned();
assert!(
rooted.contains(&in_folder),
"rooted doctor store, got:\n{rooted}"
);
}
#[test]
fn doctor_on_a_folder_target_scopes_to_its_own_store() {
let dir = TempDir::new().unwrap();
write(dir.path(), "a.txt", VALID_DECK);
let out = alix(&["doctor", dir.path().to_str().unwrap()]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
assert!(stdout(&out).contains("1 decks"), "{}", stdout(&out));
}
#[test]
fn doctor_reports_a_broken_config_as_a_failing_finding() {
let dir = TempDir::new().unwrap();
let config = write(dir.path(), "config.toml", "[review]\nfrobnicate = 1\n");
let out = alix(&["doctor", "--config", &config]);
assert!(!out.status.success(), "a broken config should fail doctor");
let text = format!("{}{}", stdout(&out), stderr(&out));
assert!(text.contains("config"), "{text}");
}
#[test]
fn stats_on_a_loose_deck_resolves_the_decks_dir_root_store_like_review_does() {
let decks = tempfile::tempdir().unwrap();
let deck = write(decks.path(), "math.txt", VALID_DECK);
let cfg = decks.path().join("config.toml");
std::fs::write(
&cfg,
format!("decks_dir = \"{}\"\n", decks.path().display()),
)
.unwrap();
let cfg = cfg.to_str().unwrap();
let garbage = "{ this is not valid json";
std::fs::write(decks.path().join("progress.json"), garbage).unwrap();
let out = alix(&["stats", &deck, "--config", cfg]);
assert!(
!out.status.success(),
"stats must read <decks_dir>/progress.json, not fall back to the \
global store: stdout:\n{}",
stdout(&out)
);
}
#[test]
fn reset_all_clears_the_decks_dir_root_store_not_the_global_one() {
let decks = tempfile::tempdir().unwrap();
write(decks.path(), "math.txt", VALID_DECK);
let cfg = decks.path().join("config.toml");
std::fs::write(
&cfg,
format!("decks_dir = \"{}\"\n", decks.path().display()),
)
.unwrap();
let cfg = cfg.to_str().unwrap();
let garbage = "{ this is not valid json";
std::fs::write(decks.path().join("progress.json"), garbage).unwrap();
let out = alix(&["reset", "--all", "--yes", "--config", cfg]);
assert!(
!out.status.success(),
"reset --all must clear <decks_dir>/progress.json, not the global \
store: stdout:\n{}",
stdout(&out)
);
}
#[test]
fn share_on_a_nonexistent_path_errors() {
let dir = TempDir::new().unwrap();
let ghost = dir.path().join("ghost.txt");
let out = alix(&["share", ghost.to_str().unwrap()]);
assert!(!out.status.success());
assert!(
stderr(&out).contains("neither a deck file nor a folder"),
"stderr: {}",
stderr(&out)
);
}
#[test]
fn share_on_a_folder_with_no_decks_errors() {
let dir = TempDir::new().unwrap();
let empty = dir.path().join("empty");
std::fs::create_dir(&empty).unwrap();
let out = alix(&["share", empty.to_str().unwrap()]);
assert!(!out.status.success());
assert!(
stderr(&out).contains("nothing to share"),
"stderr: {}",
stderr(&out)
);
}
#[test]
fn share_zip_writes_an_archive_of_a_single_deck() {
let dir = TempDir::new().unwrap();
let deck = write(dir.path(), "math.txt", VALID_DECK);
let out_dir = dir.path().join("out");
std::fs::create_dir(&out_dir).unwrap();
let out = alix(&[
"share",
&deck,
"--zip",
"--output",
out_dir.to_str().unwrap(),
]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
assert!(out_dir.join("math.zip").is_file());
assert!(stdout(&out).contains("Wrote"), "{}", stdout(&out));
}
#[test]
fn share_zip_of_a_workspace_folder_strips_personal_state() {
let dir = TempDir::new().unwrap();
let ws = dir.path().join("eng");
std::fs::create_dir(&ws).unwrap();
std::fs::write(ws.join("alix.toml"), "title = \"Eng\"\n").unwrap();
write(&ws, "a.txt", "# q\n\ta\n");
write(&ws, "progress.json", "{}"); let out_dir = dir.path().join("out");
std::fs::create_dir(&out_dir).unwrap();
let out = alix(&[
"share",
ws.to_str().unwrap(),
"--zip",
"--output",
out_dir.to_str().unwrap(),
]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
let zip_path = out_dir.join("eng.zip");
assert!(zip_path.is_file());
let landed = dir.path().join("landed");
alix::share::unzip_to(&zip_path, &landed).unwrap();
assert!(landed.join("eng/a.txt").is_file());
assert!(!landed.join("eng/progress.json").exists());
}
#[test]
fn share_without_wormhole_installed_reports_the_install_hint() {
let dir = TempDir::new().unwrap();
let deck = write(dir.path(), "math.txt", VALID_DECK);
let out = alix_env(
&["share", &deck],
dir.path(),
&[("PATH", "/nonexistent-empty-bin")],
);
assert!(!out.status.success());
assert!(
stderr(&out).contains("is magic-wormhole installed?"),
"stderr: {}",
stderr(&out)
);
}
#[test]
fn receive_without_wormhole_installed_reports_the_install_hint() {
let dir = TempDir::new().unwrap();
let out = alix_env(
&["receive", "7-fake-code-xyz"],
dir.path(),
&[("PATH", "/nonexistent-empty-bin")],
);
assert!(!out.status.success());
assert!(
stderr(&out).contains("is magic-wormhole installed?"),
"stderr: {}",
stderr(&out)
);
}
#[test]
fn receive_a_zip_deck_lands_in_the_decks_dir() {
let home = TempDir::new().unwrap();
let src = TempDir::new().unwrap();
let deck = write(src.path(), "math.txt", VALID_DECK);
let zip_path = src.path().join("math.zip");
alix::share::zip_to(Path::new(&deck), &zip_path).unwrap();
let out = alix_env(&["receive", zip_path.to_str().unwrap()], home.path(), &[]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
assert!(home.path().join("decks/math.txt").is_file());
assert!(
stdout(&out).contains("shows up in the picker"),
"{}",
stdout(&out)
);
}
#[test]
fn receive_an_existing_deck_without_force_errors_then_force_overwrites() {
let home = TempDir::new().unwrap();
let src = TempDir::new().unwrap();
let deck = write(src.path(), "math.txt", VALID_DECK);
let zip_path = src.path().join("math.zip");
alix::share::zip_to(Path::new(&deck), &zip_path).unwrap();
let first = alix_env(&["receive", zip_path.to_str().unwrap()], home.path(), &[]);
assert!(first.status.success(), "stderr: {}", stderr(&first));
let second = alix_env(&["receive", zip_path.to_str().unwrap()], home.path(), &[]);
assert!(
!second.status.success(),
"should refuse to clobber without --force"
);
assert!(
stderr(&second).contains("pass --force to overwrite"),
"stderr: {}",
stderr(&second)
);
let third = alix_env(
&["receive", zip_path.to_str().unwrap(), "--force"],
home.path(),
&[],
);
assert!(third.status.success(), "stderr: {}", stderr(&third));
}
#[test]
fn receive_a_zip_folder_strips_leaked_personal_files() {
let home = TempDir::new().unwrap();
let src = TempDir::new().unwrap();
let ws = src.path().join("eng");
std::fs::create_dir(&ws).unwrap();
write(&ws, "a.txt", "# q\n\ta\n");
write(&ws, "progress.json", "{}"); let zip_path = src.path().join("eng.zip");
alix::share::zip_to(&ws, &zip_path).unwrap();
let out = alix_env(&["receive", zip_path.to_str().unwrap()], home.path(), &[]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
assert!(
stdout(&out).contains("stripped a leaked personal file: progress.json"),
"{}",
stdout(&out)
);
assert!(home.path().join("decks/eng/a.txt").is_file());
assert!(!home.path().join("decks/eng/progress.json").exists());
}
#[test]
fn receive_a_zip_folder_rejects_the_workspace_flag() {
let home = TempDir::new().unwrap();
let src = TempDir::new().unwrap();
let ws = src.path().join("eng");
std::fs::create_dir(&ws).unwrap();
write(&ws, "a.txt", "# q\n\ta\n");
let zip_path = src.path().join("eng.zip");
alix::share::zip_to(&ws, &zip_path).unwrap();
let out = alix_env(
&[
"receive",
zip_path.to_str().unwrap(),
"--workspace",
"/tmp/nonexistent-ws-for-alix-tests",
],
home.path(),
&[],
);
assert!(!out.status.success());
assert!(
stderr(&out).contains("--workspace places a received deck"),
"stderr: {}",
stderr(&out)
);
}
#[test]
fn receive_a_zip_folder_refuses_to_clobber_an_existing_dest() {
let home = TempDir::new().unwrap();
let src = TempDir::new().unwrap();
let ws = src.path().join("eng");
std::fs::create_dir(&ws).unwrap();
write(&ws, "a.txt", "# q\n\ta\n");
let zip_path = src.path().join("eng.zip");
alix::share::zip_to(&ws, &zip_path).unwrap();
let first = alix_env(&["receive", zip_path.to_str().unwrap()], home.path(), &[]);
assert!(first.status.success(), "stderr: {}", stderr(&first));
let second = alix_env(&["receive", zip_path.to_str().unwrap()], home.path(), &[]);
assert!(!second.status.success());
assert!(
stderr(&second).contains("already exists — move it aside first"),
"stderr: {}",
stderr(&second)
);
}
#[test]
fn generate_builds_checkpoints_into_an_existing_trace_stub() {
let dir = TempDir::new().unwrap();
let stub = write(dir.path(), "t.txt", "% trace: how it works\n% source: .\n");
let cli = fake_claude(dir.path(), "# checkpoint one\n\tsome point\n\t% at: 1\n");
let config = write(
dir.path(),
"config.toml",
&format!("[ask]\ncommand = \"{cli}\"\ntimeout_secs = 10\n"),
);
let out = alix(&["generate", &stub, "--config", &config, "--yes"]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
assert!(
stdout(&out).contains("Wrote 1 checkpoints"),
"{}",
stdout(&out)
);
let rewritten = std::fs::read_to_string(&stub).unwrap();
assert!(rewritten.contains("% trace: how it works"), "{rewritten}");
assert!(rewritten.contains("checkpoint one"), "{rewritten}");
}
#[test]
fn generate_trace_plan_prints_the_suggestion_menu() {
let dir = TempDir::new().unwrap();
write(dir.path(), "notes.md", "some source material\n");
let cli = fake_claude(dir.path(), "1. [trace] how X becomes Y\n % source: .\n");
let config = write(
dir.path(),
"config.toml",
&format!("[ask]\ncommand = \"{cli}\"\ntimeout_secs = 10\n"),
);
let out = alix(&[
"generate",
dir.path().to_str().unwrap(),
"--trace",
"--plan",
"--config",
&config,
]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
assert!(stdout(&out).contains("how X becomes Y"), "{}", stdout(&out));
assert!(
stdout(&out).contains("Paste a suggestion into a new deck"),
"{}",
stdout(&out)
);
}
#[test]
fn generate_trace_walk_writes_an_explore_deck() {
let dir = TempDir::new().unwrap();
write(dir.path(), "notes.md", "some source material\n");
let cli = fake_claude(dir.path(), "# what it is\n\tsome point\n\t% at: 1\n");
let config = write(
dir.path(),
"config.toml",
&format!("[ask]\ncommand = \"{cli}\"\ntimeout_secs = 10\n"),
);
let out_path = dir.path().join("walk.txt");
let out = alix(&[
"generate",
dir.path().to_str().unwrap(),
"--trace",
"--config",
&config,
"--output",
out_path.to_str().unwrap(),
]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
let text = std::fs::read_to_string(&out_path).unwrap();
assert!(text.contains("% trace: exploring"), "{text}");
assert!(text.contains("% source:"), "{text}");
assert!(text.contains("what it is"), "{text}");
}
#[test]
fn generate_trace_walk_refuses_to_clobber_an_existing_output() {
let dir = TempDir::new().unwrap();
write(dir.path(), "notes.md", "some source material\n");
let cli = fake_claude(dir.path(), "# what it is\n\tsome point\n\t% at: 1\n");
let config = write(
dir.path(),
"config.toml",
&format!("[ask]\ncommand = \"{cli}\"\ntimeout_secs = 10\n"),
);
let out_path = dir.path().join("walk.txt");
write(dir.path(), "walk.txt", "already here\n");
let out = alix(&[
"generate",
dir.path().to_str().unwrap(),
"--trace",
"--config",
&config,
"--output",
out_path.to_str().unwrap(),
]);
assert!(!out.status.success());
assert!(
stderr(&out).contains("already exists; pass --force to overwrite"),
"stderr: {}",
stderr(&out)
);
}
#[test]
fn generate_single_deck_writes_a_deck_file() {
let dir = TempDir::new().unwrap();
let cli = fake_claude(dir.path(), "# Generated Q\n\tGenerated A\n");
let config = write(
dir.path(),
"config.toml",
&format!("[ask]\ncommand = \"{cli}\"\ntimeout_secs = 10\n"),
);
let ws = dir.path().join("ws");
std::fs::create_dir(&ws).unwrap();
let out = alix(&[
"generate",
"https://example.org/page",
"--config",
&config,
"--workspace",
ws.to_str().unwrap(),
"--output",
"gen",
]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
assert!(
stdout(&out).contains("Wrote 1 cards to"),
"{}",
stdout(&out)
);
assert!(ws.join("gen.txt").is_file());
}
#[test]
fn generate_single_deck_print_flag_prints_without_writing() {
let dir = TempDir::new().unwrap();
let cli = fake_claude(dir.path(), "# Generated Q\n\tGenerated A\n");
let config = write(
dir.path(),
"config.toml",
&format!("[ask]\ncommand = \"{cli}\"\ntimeout_secs = 10\n"),
);
let out = alix(&[
"generate",
"https://example.org/page",
"--config",
&config,
"--print",
]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
assert!(stdout(&out).contains("Generated Q"), "{}", stdout(&out));
assert!(
stderr(&out).contains("cards — not written; --print"),
"stderr: {}",
stderr(&out)
);
}
#[test]
fn generate_single_deck_refuses_to_clobber_without_force_then_force_overwrites() {
let dir = TempDir::new().unwrap();
let cli = fake_claude(dir.path(), "# Generated Q\n\tGenerated A\n");
let config = write(
dir.path(),
"config.toml",
&format!("[ask]\ncommand = \"{cli}\"\ntimeout_secs = 10\n"),
);
let ws = dir.path().join("ws");
std::fs::create_dir(&ws).unwrap();
let args = [
"generate",
"https://example.org/page",
"--config",
&config,
"--workspace",
ws.to_str().unwrap(),
"--output",
"gen",
];
let first = alix(&args);
assert!(first.status.success(), "stderr: {}", stderr(&first));
let second = alix(&args);
assert!(!second.status.success());
assert!(
stderr(&second).contains("already exists; pass --force to overwrite"),
"stderr: {}",
stderr(&second)
);
let mut forced = args.to_vec();
forced.push("--force");
let third = alix(&forced);
assert!(third.status.success(), "stderr: {}", stderr(&third));
}
#[test]
fn generate_on_a_directory_source_explores_then_falls_back_to_a_single_deck() {
let dir = TempDir::new().unwrap();
let src = dir.path().join("src");
std::fs::create_dir(&src).unwrap();
write(&src, "notes.md", "some source material\n");
let cli = fake_claude(dir.path(), "# Generated Q\n\tGenerated A\n");
let config = write(
dir.path(),
"config.toml",
&format!("[ask]\ncommand = \"{cli}\"\ntimeout_secs = 10\n"),
);
let ws = dir.path().join("ws");
std::fs::create_dir(&ws).unwrap();
let out = alix(&[
"generate",
src.to_str().unwrap(),
"--config",
&config,
"--workspace",
ws.to_str().unwrap(),
"--output",
"gen",
]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
assert!(
stderr(&out).contains("Exploring"),
"stderr: {}",
stderr(&out)
);
assert!(ws.join("gen.txt").is_file());
}
#[test]
fn augment_choices_caches_distractors_for_two_cards() {
let dir = TempDir::new().unwrap();
let deck = write(dir.path(), "quiz.txt", "# Q1\n\tA1\n\n# Q2\n\tA2\n");
let cli = fake_claude(dir.path(), r#"{"0": ["W1", "W2"], "1": ["W3", "W4"]}"#);
let config = write(
dir.path(),
"config.toml",
&format!("[ask]\ncommand = \"{cli}\"\ntimeout_secs = 10\n"),
);
let store = dir.path().join("p.json");
let out = alix(&[
"deck",
"augment",
&deck,
"--target",
"choices",
"--store",
store.to_str().unwrap(),
"--config",
&config,
]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
assert!(
stdout(&out).contains("augmented 2 of 2 cards"),
"{}",
stdout(&out)
);
let cached = std::fs::read_to_string(dir.path().join("augment.json")).unwrap();
assert!(cached.contains("W1"), "{cached}");
assert!(cached.contains("W3"), "{cached}");
}
#[test]
fn augment_notes_caches_a_trivia_note() {
let dir = TempDir::new().unwrap();
let deck = write(dir.path(), "quiz.txt", "# Q1\n\tA1\n");
let cli = fake_claude(dir.path(), r#"{"0": "a fun fact"}"#);
let config = write(
dir.path(),
"config.toml",
&format!("[ask]\ncommand = \"{cli}\"\ntimeout_secs = 10\n"),
);
let store = dir.path().join("p.json");
let out = alix(&[
"deck",
"augment",
&deck,
"--target",
"notes",
"--store",
store.to_str().unwrap(),
"--config",
&config,
]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
let cached = std::fs::read_to_string(dir.path().join("augment.json")).unwrap();
assert!(cached.contains("a fun fact"), "{cached}");
}
#[test]
fn augment_without_a_store_flag_caches_beside_the_decks_dir_root_store() {
let decks = tempfile::tempdir().unwrap();
let deck = write(decks.path(), "quiz.txt", "# Q1\n\tA1\n");
let cli = fake_claude(decks.path(), r#"{"0": "a fun fact"}"#);
let cfg = decks.path().join("config.toml");
std::fs::write(
&cfg,
format!(
"decks_dir = \"{}\"\n[ask]\ncommand = \"{cli}\"\ntimeout_secs = 10\n",
decks.path().display()
),
)
.unwrap();
let cfg = cfg.to_str().unwrap();
let out = alix(&[
"deck", "augment", &deck, "--target", "notes", "--config", cfg,
]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
let cached = std::fs::read_to_string(decks.path().join("augment.json")).unwrap();
assert!(cached.contains("a fun fact"), "{cached}");
}
#[test]
fn augment_questions_caches_a_reworded_variant() {
let dir = TempDir::new().unwrap();
let deck = write(dir.path(), "quiz.txt", "# Q1\n\tA1\n");
let cli = fake_claude(dir.path(), r#"{"0": ["Rephrased Q1?"]}"#);
let config = write(
dir.path(),
"config.toml",
&format!("[ask]\ncommand = \"{cli}\"\ntimeout_secs = 10\n"),
);
let store = dir.path().join("p.json");
let out = alix(&[
"deck",
"augment",
&deck,
"--target",
"questions",
"--store",
store.to_str().unwrap(),
"--config",
&config,
]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
let cached = std::fs::read_to_string(dir.path().join("augment.json")).unwrap();
assert!(cached.contains("Rephrased Q1?"), "{cached}");
}
#[test]
fn augment_questions_on_a_cloze_only_deck_errors() {
let dir = TempDir::new().unwrap();
let deck = write(
dir.path(),
"c.txt",
"# Complete\n% reveal: cloze\n\tThe capital of France is {{Paris}}.\n",
);
let config = write(
dir.path(),
"config.toml",
"[ask]\ncommand = \"/nonexistent/x\"\n",
);
let store = dir.path().join("p.json");
let out = alix(&[
"deck",
"augment",
&deck,
"--target",
"questions",
"--store",
store.to_str().unwrap(),
"--config",
&config,
]);
assert!(!out.status.success());
assert!(
stderr(&out).contains("no plain (non-cloze) cards to add question variants to"),
"stderr: {}",
stderr(&out)
);
}
#[test]
fn augment_keypoints_caches_decomposed_claims() {
let dir = TempDir::new().unwrap();
let deck = write(dir.path(), "quiz.txt", "# Q1\n\tA1\n");
let cli = fake_claude(dir.path(), r#"{"0": ["point one", "point two"]}"#);
let config = write(
dir.path(),
"config.toml",
&format!("[ask]\ncommand = \"{cli}\"\ntimeout_secs = 10\n"),
);
let store = dir.path().join("p.json");
let out = alix(&[
"deck",
"augment",
&deck,
"--target",
"keypoints",
"--store",
store.to_str().unwrap(),
"--config",
&config,
]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
let cached = std::fs::read_to_string(dir.path().join("augment.json")).unwrap();
assert!(cached.contains("point one"), "{cached}");
assert!(cached.contains("point two"), "{cached}");
}
#[test]
fn augment_topology_prints_and_caches_the_walk() {
let dir = TempDir::new().unwrap();
let deck = write(dir.path(), "quiz.txt", "# Q1\n\tA1\n\n# Q2\n\tA2\n");
let cli = fake_claude(
dir.path(),
r#"{"principle": "by difficulty", "edges": [{"from": 0, "to": 1, "label": "builds on"}], "walk": [0, 1], "regions": []}"#,
);
let config = write(
dir.path(),
"config.toml",
&format!("[ask]\ncommand = \"{cli}\"\ntimeout_secs = 10\n"),
);
let store = dir.path().join("p.json");
let out = alix(&[
"deck",
"augment",
&deck,
"--target",
"topology",
"--store",
store.to_str().unwrap(),
"--config",
&config,
]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
let text = stdout(&out);
assert!(text.contains("topology 'pedagogical order'"), "{text}");
assert!(text.contains("by difficulty"), "{text}");
assert!(text.contains("(1 topology stored for this deck)"), "{text}");
}
#[test]
fn augment_on_an_empty_deck_errors_without_calling_the_backend() {
let dir = TempDir::new().unwrap();
let deck = write(dir.path(), "empty.txt", "% title: Nothing\n");
let config = write(
dir.path(),
"config.toml",
"[ask]\ncommand = \"/nonexistent/x\"\n",
);
let store = dir.path().join("p.json");
let out = alix(&[
"deck",
"augment",
&deck,
"--target",
"choices",
"--store",
store.to_str().unwrap(),
"--config",
&config,
]);
assert!(!out.status.success());
assert!(
stderr(&out).contains("the deck has no cards to augment"),
"stderr: {}",
stderr(&out)
);
}
#[test]
fn deck_import_writes_a_deck_from_tsv() {
let dir = TempDir::new().unwrap();
let tsv = write(
dir.path(),
"cards.tsv",
"Capital of Japan?\tTokyo\nCapital of Italy?\tRome\n",
);
let out = alix(&["deck", "import", &tsv, "--output", "geo"]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
assert!(
stdout(&out).contains("Imported 2 cards into"),
"{}",
stdout(&out)
);
}
#[test]
fn deck_import_print_flag_prints_without_writing() {
let dir = TempDir::new().unwrap();
let tsv = write(dir.path(), "cards.tsv", "Q1\tA1\n");
let out = alix(&["deck", "import", &tsv, "--print"]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
assert!(stdout(&out).contains("# Q1"), "{}", stdout(&out));
assert!(
stderr(&out).contains("cards — not written; --print"),
"stderr: {}",
stderr(&out)
);
}
#[test]
fn deck_import_into_a_workspace_lands_there() {
let dir = TempDir::new().unwrap();
let tsv = write(dir.path(), "cards.tsv", "Q1\tA1\n");
let ws = dir.path().join("ws");
std::fs::create_dir(&ws).unwrap();
let out = alix(&[
"deck",
"import",
&tsv,
"--workspace",
ws.to_str().unwrap(),
"--output",
"geo",
]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
assert!(ws.join("geo.txt").is_file());
}
#[test]
fn deck_import_refuses_to_clobber_without_force_then_force_overwrites() {
let dir = TempDir::new().unwrap();
let tsv = write(dir.path(), "cards.tsv", "Q1\tA1\n");
let ws = dir.path().join("ws");
std::fs::create_dir(&ws).unwrap();
let args = [
"deck",
"import",
&tsv,
"--workspace",
ws.to_str().unwrap(),
"--output",
"geo",
];
let first = alix(&args);
assert!(first.status.success(), "stderr: {}", stderr(&first));
let second = alix(&args);
assert!(!second.status.success());
assert!(
stderr(&second).contains("already exists; pass --force to overwrite"),
"stderr: {}",
stderr(&second)
);
let mut forced = args.to_vec();
forced.push("--force");
let third = alix(&forced);
assert!(third.status.success(), "stderr: {}", stderr(&third));
}
#[test]
fn workspace_init_on_an_existing_workspace_errors() {
let dir = TempDir::new().unwrap();
let ws = dir.path().join("fresh");
let first = alix(&["workspace", "init", ws.to_str().unwrap()]);
assert!(first.status.success(), "stderr: {}", stderr(&first));
write(&ws, "a.txt", "# q\n\ta\n");
let second = alix(&["workspace", "init", ws.to_str().unwrap()]);
assert!(!second.status.success());
assert!(
stderr(&second).contains("is already a workspace"),
"stderr: {}",
stderr(&second)
);
}
#[test]
fn reset_without_target_or_flags_errors() {
let out = alix(&["reset"]);
assert!(!out.status.success());
assert!(
stderr(&out).contains("name a deck, folder, or workspace to reset"),
"stderr: {}",
stderr(&out)
);
}
#[test]
fn reset_all_on_an_empty_store_reports_nothing_to_reset() {
let dir = TempDir::new().unwrap();
let store = dir.path().join("progress.json"); let out = alix(&[
"reset",
"--all",
"--yes",
"--store",
store.to_str().unwrap(),
]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
assert!(
stdout(&out).contains("No stored progress to reset."),
"{}",
stdout(&out)
);
}
#[test]
fn reset_by_numeric_card_id_without_a_target() {
let dir = TempDir::new().unwrap();
let deck = write(dir.path(), "math.txt", VALID_DECK);
let store_path = dir.path().join("progress.json");
let card_id = alix::deck::Deck::load(&deck).unwrap().cards[0].id();
let mut store = alix::store::Store::open(&store_path).unwrap();
store.get_or_insert(card_id, 0);
store.save().unwrap();
let out = alix(&[
"reset",
"--card",
&card_id.to_string(),
"--yes",
"--store",
store_path.to_str().unwrap(),
]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
assert!(
stdout(&out).contains("Reset 1 card(s)."),
"{}",
stdout(&out)
);
}
#[test]
fn reset_by_text_query_within_a_target_resets_only_matching_cards() {
let dir = TempDir::new().unwrap();
let deck_text = "# Capital of Japan?\n\tTokyo\n\n# Largest planet?\n\tJupiter\n";
let deck = write(dir.path(), "geo.txt", deck_text);
let cards = alix::parser::parse_str("geo.txt", deck_text).unwrap();
let store_path = dir.path().join("progress.json");
let mut store = alix::store::Store::open(&store_path).unwrap();
for c in &cards {
store.get_or_insert(c.id(), 0);
}
store.save().unwrap();
let out = alix(&[
"reset",
&deck,
"--card",
"japan",
"--yes",
"--store",
store_path.to_str().unwrap(),
]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
assert!(
stdout(&out).contains("Reset 1 card(s)."),
"{}",
stdout(&out)
);
let reloaded = alix::store::Store::open(&store_path).unwrap();
assert!(
reloaded.get(cards[0].id()).is_none(),
"the matched card should be cleared"
);
assert!(
reloaded.get(cards[1].id()).is_some(),
"the other card should survive"
);
}
#[test]
fn reset_by_text_query_with_no_match_reports_nothing() {
let dir = TempDir::new().unwrap();
let deck = write(dir.path(), "geo.txt", "# Capital of Japan?\n\tTokyo\n");
let store_path = dir.path().join("progress.json");
let out = alix(&[
"reset",
&deck,
"--card",
"nonexistent-query",
"--yes",
"--store",
store_path.to_str().unwrap(),
]);
assert!(out.status.success(), "stderr: {}", stderr(&out));
assert!(
stdout(&out).contains("No stored progress matching"),
"{}",
stdout(&out)
);
}