use assert_cmd::Command;
use predicates::str::contains;
use std::fs;
use std::path::Path;
fn aristo_in(dir: &Path) -> Command {
let mut cmd = Command::cargo_bin("aristo").unwrap();
cmd.current_dir(dir);
cmd
}
fn read_index(root: &Path) -> aristo_core::index::IndexFile {
let text = fs::read_to_string(root.join(".aristo/index.toml")).unwrap();
toml::from_str(&text).expect("index round-trips")
}
fn write_lib(root: &Path, content: &str) {
fs::create_dir_all(root.join("src")).unwrap();
fs::write(root.join("src/lib.rs"), content).unwrap();
}
fn lookup<'a>(
idx: &'a aristo_core::index::IndexFile,
id: &str,
) -> &'a aristo_core::index::IndexEntry {
let parsed = aristo_core::index::AnnotationId::parse(id).unwrap();
idx.entries
.get(&parsed)
.unwrap_or_else(|| panic!("no entry `{id}`"))
}
fn force_status(root: &Path, id: &str, status: aristo_core::index::Status) {
let mut idx = read_index(root);
let parsed = aristo_core::index::AnnotationId::parse(id).unwrap();
let entry = idx.entries.get_mut(&parsed).unwrap();
match entry {
aristo_core::index::IndexEntry::Intent(e) => e.status = status,
aristo_core::index::IndexEntry::Assume(e) => e.status = status,
}
let text = toml::to_string_pretty(&idx).unwrap();
fs::write(root.join(".aristo/index.toml"), text).unwrap();
}
#[test]
fn stamp_on_fresh_workspace_writes_initial_index() {
let tmp = tempfile::tempdir().unwrap();
aristo_in(tmp.path()).arg("init").assert().success();
write_lib(
tmp.path(),
r#"#[aristo::intent("hello", verify = "test", id = "greeting")] fn x() {}"#,
);
aristo_in(tmp.path())
.arg("stamp")
.assert()
.success()
.stdout(contains("ok: stamped 1 annotation"))
.stdout(contains("new: 1"));
let idx = read_index(tmp.path());
assert_eq!(idx.entries.len(), 1);
}
#[test]
fn stamp_preserves_status_when_body_unchanged() {
let tmp = tempfile::tempdir().unwrap();
aristo_in(tmp.path()).arg("init").assert().success();
write_lib(
tmp.path(),
r#"#[aristo::intent("a", verify = "test", id = "a")] fn x() -> i32 { 42 }"#,
);
aristo_in(tmp.path()).arg("stamp").assert().success();
force_status(tmp.path(), "a", aristo_core::index::Status::Tested);
aristo_in(tmp.path()).arg("stamp").assert().success();
let idx = read_index(tmp.path());
if let aristo_core::index::IndexEntry::Intent(e) = lookup(&idx, "a") {
assert_eq!(
e.status,
aristo_core::index::Status::Tested,
"body unchanged → status preserved"
);
}
}
#[test]
fn stamp_flips_verified_to_stale_on_body_change() {
let tmp = tempfile::tempdir().unwrap();
aristo_in(tmp.path()).arg("init").assert().success();
write_lib(
tmp.path(),
r#"#[aristo::intent("a", verify = "test", id = "a")] fn x() -> i32 { 1 }"#,
);
aristo_in(tmp.path()).arg("stamp").assert().success();
force_status(tmp.path(), "a", aristo_core::index::Status::Tested);
write_lib(
tmp.path(),
r#"#[aristo::intent("a", verify = "test", id = "a")] fn x() -> i32 { 99 }"#,
);
aristo_in(tmp.path())
.arg("stamp")
.assert()
.success()
.stdout(contains("body-drifted: 1"))
.stdout(contains("now Stale"));
let idx = read_index(tmp.path());
if let aristo_core::index::IndexEntry::Intent(e) = lookup(&idx, "a") {
assert_eq!(e.status, aristo_core::index::Status::Stale);
}
}
#[test]
fn stamp_flips_counterexample_to_stale_on_body_change() {
let tmp = tempfile::tempdir().unwrap();
aristo_in(tmp.path()).arg("init").assert().success();
write_lib(
tmp.path(),
r#"#[aristo::intent("a", verify = "test", id = "a")] fn x() -> i32 { 1 }"#,
);
aristo_in(tmp.path()).arg("stamp").assert().success();
force_status(tmp.path(), "a", aristo_core::index::Status::Counterexample);
write_lib(
tmp.path(),
r#"#[aristo::intent("a", verify = "test", id = "a")] fn x() -> i32 { 99 }"#,
);
aristo_in(tmp.path())
.arg("stamp")
.assert()
.success()
.stdout(contains("body-drifted: 1"))
.stdout(contains("now Stale"));
let idx = read_index(tmp.path());
if let aristo_core::index::IndexEntry::Intent(e) = lookup(&idx, "a") {
assert_eq!(e.status, aristo_core::index::Status::Stale);
}
}
#[test]
fn stamp_warns_loudly_on_counterexample_entries() {
let tmp = tempfile::tempdir().unwrap();
aristo_in(tmp.path()).arg("init").assert().success();
write_lib(
tmp.path(),
r#"#[aristo::intent("a refuted claim", verify = "neural", id = "refuted_one")] fn x() {}"#,
);
aristo_in(tmp.path()).arg("stamp").assert().success();
force_status(
tmp.path(),
"refuted_one",
aristo_core::index::Status::Counterexample,
);
aristo_in(tmp.path())
.arg("stamp")
.assert()
.success()
.stderr(contains("refuted by counterexample"))
.stderr(contains("refuted_one"));
}
#[test]
fn stamp_check_mode_also_surfaces_counterexamples() {
let tmp = tempfile::tempdir().unwrap();
aristo_in(tmp.path()).arg("init").assert().success();
write_lib(
tmp.path(),
r#"#[aristo::intent("a refuted claim", verify = "neural", id = "refuted_two")] fn x() {}"#,
);
aristo_in(tmp.path()).arg("stamp").assert().success();
force_status(
tmp.path(),
"refuted_two",
aristo_core::index::Status::Counterexample,
);
aristo_in(tmp.path())
.args(["stamp", "--check"])
.assert()
.success()
.stderr(contains("refuted by counterexample"));
}
#[test]
fn stamp_archives_orphan_proof_when_annotation_removed() {
let tmp = tempfile::tempdir().unwrap();
aristo_in(tmp.path()).arg("init").assert().success();
write_lib(
tmp.path(),
r#"#[aristo::intent("doomed", verify = "neural", id = "doomed")] fn d() {}"#,
);
aristo_in(tmp.path()).arg("stamp").assert().success();
let proofs_dir = tmp.path().join(".aristo/proofs");
fs::create_dir_all(&proofs_dir).unwrap();
fs::write(proofs_dir.join("doomed.proof"), "[verdict]\nfake = true\n").unwrap();
assert!(proofs_dir.join("doomed.proof").exists());
write_lib(tmp.path(), "// no annotations\n");
aristo_in(tmp.path())
.arg("stamp")
.assert()
.success()
.stderr(contains("archived orphan proof"))
.stderr(contains("doomed.proof"));
let archived = tmp.path().join(".aristo/archive/proofs/doomed.proof");
assert!(
!proofs_dir.join("doomed.proof").exists(),
"orphan proof must leave the active proofs/ dir"
);
assert!(
archived.exists(),
"orphan proof must be archived (recoverable), not deleted"
);
assert_eq!(
fs::read_to_string(&archived).unwrap(),
"[verdict]\nfake = true\n",
"archived proof must be the original file, byte-for-byte"
);
}
#[test]
fn stamp_check_does_not_archive_orphan_proofs() {
let tmp = tempfile::tempdir().unwrap();
aristo_in(tmp.path()).arg("init").assert().success();
write_lib(
tmp.path(),
r#"#[aristo::intent("doomed", verify = "neural", id = "doomed")] fn d() {}"#,
);
aristo_in(tmp.path()).arg("stamp").assert().success();
let proofs_dir = tmp.path().join(".aristo/proofs");
fs::create_dir_all(&proofs_dir).unwrap();
fs::write(proofs_dir.join("doomed.proof"), "[verdict]\nfake = true\n").unwrap();
write_lib(tmp.path(), "// no annotations\n");
aristo_in(tmp.path())
.args(["stamp", "--check"])
.assert()
.failure();
assert!(
proofs_dir.join("doomed.proof").exists(),
"--check must NOT touch proof files (CI safety)"
);
assert!(
!tmp.path()
.join(".aristo/archive/proofs/doomed.proof")
.exists(),
"--check must NOT archive either"
);
}
#[test]
fn stamp_gc_purges_archived_orphan_proofs() {
let tmp = tempfile::tempdir().unwrap();
aristo_in(tmp.path()).arg("init").assert().success();
write_lib(
tmp.path(),
r#"#[aristo::intent("doomed", verify = "neural", id = "doomed")] fn d() {}"#,
);
aristo_in(tmp.path()).arg("stamp").assert().success();
let proofs_dir = tmp.path().join(".aristo/proofs");
fs::create_dir_all(&proofs_dir).unwrap();
fs::write(proofs_dir.join("doomed.proof"), "[verdict]\nfake = true\n").unwrap();
write_lib(tmp.path(), "// no annotations\n");
aristo_in(tmp.path()).arg("stamp").assert().success();
let archived = tmp.path().join(".aristo/archive/proofs/doomed.proof");
assert!(archived.exists(), "precondition: proof archived");
aristo_in(tmp.path())
.args(["stamp", "--gc"])
.assert()
.success()
.stdout(contains("gc: removed 1 archived proof"));
assert!(!archived.exists(), "--gc must purge archived proofs");
}
#[test]
fn stamp_reword_archives_old_proof_keeping_it_recoverable() {
let tmp = tempfile::tempdir().unwrap();
aristo_in(tmp.path()).arg("init").assert().success();
write_lib(
tmp.path(),
r#"#[aristo::intent("returns one", verify = "neural")] fn k() -> i32 { 1 }"#,
);
aristo_in(tmp.path()).arg("stamp").assert().success();
let id1 = only_id(&read_index(tmp.path()));
let proofs_dir = tmp.path().join(".aristo/proofs");
fs::create_dir_all(&proofs_dir).unwrap();
fs::write(
proofs_dir.join(format!("{id1}.proof")),
"[verdict]\nok = true\n",
)
.unwrap();
write_lib(
tmp.path(),
r#"#[aristo::intent("returns the value one", verify = "neural")] fn k() -> i32 { 1 }"#,
);
aristo_in(tmp.path()).arg("stamp").assert().success();
let id2 = only_id(&read_index(tmp.path()));
assert_ne!(id2, id1);
assert!(
!proofs_dir.join(format!("{id1}.proof")).exists(),
"old id's proof leaves the active set"
);
assert!(
tmp.path()
.join(format!(".aristo/archive/proofs/{id1}.proof"))
.exists(),
"old id's proof is archived, not destroyed by the reword"
);
}
#[test]
fn stamp_flips_text_drift_to_stale() {
let tmp = tempfile::tempdir().unwrap();
aristo_in(tmp.path()).arg("init").assert().success();
write_lib(
tmp.path(),
r#"#[aristo::intent("v1", verify = "test", id = "a")] fn x() -> i32 { 42 }"#,
);
aristo_in(tmp.path()).arg("stamp").assert().success();
force_status(tmp.path(), "a", aristo_core::index::Status::Tested);
write_lib(
tmp.path(),
r#"#[aristo::intent("v2", verify = "test", id = "a")] fn x() -> i32 { 42 }"#,
);
aristo_in(tmp.path())
.arg("stamp")
.assert()
.success()
.stdout(contains("text-changed: 1"))
.stdout(contains("now Stale"));
let idx = read_index(tmp.path());
if let aristo_core::index::IndexEntry::Intent(e) = lookup(&idx, "a") {
assert_eq!(
e.status,
aristo_core::index::Status::Stale,
"text drift on a verified entry transitions to Stale (GAP-8 strict)"
);
assert_eq!(e.text, "v2");
}
}
#[test]
fn stamp_drops_removed_annotations_from_index() {
let tmp = tempfile::tempdir().unwrap();
aristo_in(tmp.path()).arg("init").assert().success();
write_lib(
tmp.path(),
r#"
#[aristo::intent("keep", verify = "test", id = "kept")] fn k() {}
#[aristo::intent("drop", verify = "test", id = "dropped")] fn d() {}
"#,
);
aristo_in(tmp.path()).arg("stamp").assert().success();
write_lib(
tmp.path(),
r#"#[aristo::intent("keep", verify = "test", id = "kept")] fn k() {}"#,
);
aristo_in(tmp.path())
.arg("stamp")
.assert()
.success()
.stdout(contains("removed: 1"));
let idx = read_index(tmp.path());
assert_eq!(idx.entries.len(), 1);
assert!(idx
.entries
.contains_key(&aristo_core::index::AnnotationId::parse("kept").unwrap()));
}
#[test]
fn check_mode_does_not_write_when_index_matches() {
let tmp = tempfile::tempdir().unwrap();
aristo_in(tmp.path()).arg("init").assert().success();
write_lib(
tmp.path(),
r#"#[aristo::intent("x", verify = "test", id = "a")] fn x() {}"#,
);
aristo_in(tmp.path()).arg("stamp").assert().success();
let mtime_before = fs::metadata(tmp.path().join(".aristo/index.toml"))
.unwrap()
.modified()
.unwrap();
std::thread::sleep(std::time::Duration::from_millis(50));
aristo_in(tmp.path())
.args(["stamp", "--check"])
.assert()
.success()
.stdout(contains("up to date"));
let mtime_after = fs::metadata(tmp.path().join(".aristo/index.toml"))
.unwrap()
.modified()
.unwrap();
assert_eq!(mtime_before, mtime_after, "--check must not write");
}
#[test]
fn check_mode_exits_nonzero_when_index_is_stale() {
let tmp = tempfile::tempdir().unwrap();
aristo_in(tmp.path()).arg("init").assert().success();
write_lib(
tmp.path(),
r#"#[aristo::intent("a", verify = "test", id = "a")] fn x() {}"#,
);
aristo_in(tmp.path()).arg("stamp").assert().success();
write_lib(
tmp.path(),
r#"
#[aristo::intent("a", verify = "test", id = "a")] fn x() {}
#[aristo::intent("b", verify = "test", id = "b")] fn y() {}
"#,
);
aristo_in(tmp.path())
.args(["stamp", "--check"])
.assert()
.failure()
.code(2)
.stderr(contains("out of sync"));
}
#[test]
fn check_mode_does_not_corrupt_existing_index_on_diff() {
let tmp = tempfile::tempdir().unwrap();
aristo_in(tmp.path()).arg("init").assert().success();
write_lib(
tmp.path(),
r#"#[aristo::intent("a", verify = "test", id = "a")] fn x() {}"#,
);
aristo_in(tmp.path()).arg("stamp").assert().success();
let before = fs::read_to_string(tmp.path().join(".aristo/index.toml")).unwrap();
write_lib(
tmp.path(),
r#"
#[aristo::intent("a", verify = "test", id = "a")] fn x() {}
#[aristo::intent("b", verify = "test", id = "b")] fn y() {}
"#,
);
let _ = aristo_in(tmp.path()).args(["stamp", "--check"]).output();
let after = fs::read_to_string(tmp.path().join(".aristo/index.toml")).unwrap();
assert_eq!(
before, after,
"--check must leave the index file byte-identical"
);
}
fn only_id(idx: &aristo_core::index::IndexFile) -> String {
assert_eq!(idx.entries.len(), 1, "expected exactly one entry");
idx.entries.keys().next().unwrap().as_str().to_owned()
}
#[test]
fn stamp_idless_annotation_id_and_status_survive_restamp() {
let tmp = tempfile::tempdir().unwrap();
aristo_in(tmp.path()).arg("init").assert().success();
write_lib(
tmp.path(),
r#"#[aristo::intent("the function returns a constant", verify = "neural")] fn k() -> i32 { 7 }"#,
);
aristo_in(tmp.path()).arg("stamp").assert().success();
let id1 = only_id(&read_index(tmp.path()));
assert!(id1.starts_with("aret_"), "idless → opaque id, got {id1}");
force_status(tmp.path(), &id1, aristo_core::index::Status::Neural);
let proofs_dir = tmp.path().join(".aristo/proofs");
fs::create_dir_all(&proofs_dir).unwrap();
let proof_path = proofs_dir.join(format!("{id1}.proof"));
fs::write(&proof_path, "[verdict]\nfake = true\n").unwrap();
aristo_in(tmp.path())
.arg("stamp")
.assert()
.success()
.stdout(contains("unchanged: 1"))
.stdout(contains("removed: 0"));
let idx2 = read_index(tmp.path());
assert_eq!(
only_id(&idx2),
id1,
"deterministic id must be stable across stamps"
);
if let aristo_core::index::IndexEntry::Intent(e) = lookup(&idx2, &id1) {
assert_eq!(
e.status,
aristo_core::index::Status::Neural,
"status must survive a no-op re-stamp (the bug reset it to Unknown)"
);
} else {
panic!("expected Intent");
}
assert!(
proof_path.exists(),
"proof must survive a no-op re-stamp (the bug cascade-deleted it)"
);
}
#[test]
fn stamp_idless_body_edit_keeps_id_and_marks_stale() {
let tmp = tempfile::tempdir().unwrap();
aristo_in(tmp.path()).arg("init").assert().success();
write_lib(
tmp.path(),
r#"#[aristo::intent("returns a constant", verify = "neural")] fn k() -> i32 { 1 }"#,
);
aristo_in(tmp.path()).arg("stamp").assert().success();
let id1 = only_id(&read_index(tmp.path()));
force_status(tmp.path(), &id1, aristo_core::index::Status::Neural);
let proofs_dir = tmp.path().join(".aristo/proofs");
fs::create_dir_all(&proofs_dir).unwrap();
let proof_path = proofs_dir.join(format!("{id1}.proof"));
fs::write(&proof_path, "[verdict]\nok = true\n").unwrap();
write_lib(
tmp.path(),
r#"#[aristo::intent("returns a constant", verify = "neural")] fn k() -> i32 { 2 }"#,
);
aristo_in(tmp.path())
.arg("stamp")
.assert()
.success()
.stdout(contains("body-drifted: 1"));
let idx2 = read_index(tmp.path());
assert_eq!(only_id(&idx2), id1, "body edit must not change the id");
if let aristo_core::index::IndexEntry::Intent(e) = lookup(&idx2, &id1) {
assert_eq!(e.status, aristo_core::index::Status::Stale);
} else {
panic!("expected Intent");
}
assert!(
proof_path.exists(),
"body edit keeps the proof in the active set (id unchanged → not orphaned)"
);
assert!(
!tmp.path()
.join(format!(".aristo/archive/proofs/{id1}.proof"))
.exists(),
"body edit must NOT archive the proof"
);
}
#[test]
fn stamp_idless_reword_changes_id() {
let tmp = tempfile::tempdir().unwrap();
aristo_in(tmp.path()).arg("init").assert().success();
write_lib(
tmp.path(),
r#"#[aristo::intent("returns one", verify = "neural")] fn k() -> i32 { 1 }"#,
);
aristo_in(tmp.path()).arg("stamp").assert().success();
let id1 = only_id(&read_index(tmp.path()));
write_lib(
tmp.path(),
r#"#[aristo::intent("returns the value one", verify = "neural")] fn k() -> i32 { 1 }"#,
);
aristo_in(tmp.path()).arg("stamp").assert().success();
let id2 = only_id(&read_index(tmp.path()));
assert_ne!(id2, id1, "rewording the claim must change the id");
}
#[test]
fn stamp_idless_duplicates_get_distinct_ordinal_ids() {
let tmp = tempfile::tempdir().unwrap();
aristo_in(tmp.path()).arg("init").assert().success();
write_lib(
tmp.path(),
r#"
fn f() {
aristo::intent_stmt!("loop body is independent");
let _a = 1;
aristo::intent_stmt!("loop body is independent");
let _b = 2;
}
"#,
);
aristo_in(tmp.path()).arg("stamp").assert().success();
let ids1: Vec<String> = read_index(tmp.path())
.entries
.keys()
.map(|k| k.as_str().to_owned())
.collect();
assert_eq!(
ids1.len(),
2,
"duplicate-text intents must both index (ordinal disambiguates)"
);
aristo_in(tmp.path()).arg("stamp").assert().success();
let ids2: Vec<String> = read_index(tmp.path())
.entries
.keys()
.map(|k| k.as_str().to_owned())
.collect();
assert_eq!(
ids1, ids2,
"ordinal-disambiguated ids must be stable across stamps"
);
}
#[test]
fn stamp_explicit_id_untouched_while_idless_reword_changes() {
let tmp = tempfile::tempdir().unwrap();
aristo_in(tmp.path()).arg("init").assert().success();
write_lib(
tmp.path(),
r#"
#[aristo::intent("explicit one", verify = "test", id = "explicit_kept")] fn a() {}
#[aristo::intent("idless one", verify = "test")] fn b() {}
"#,
);
aristo_in(tmp.path()).arg("stamp").assert().success();
let idx1 = read_index(tmp.path());
let explicit = aristo_core::index::AnnotationId::parse("explicit_kept").unwrap();
assert!(
idx1.entries.contains_key(&explicit),
"explicit id used verbatim"
);
let idless1 = idx1
.entries
.keys()
.find(|k| k.as_str().starts_with("aret_"))
.expect("idless sibling got an aret_ id")
.as_str()
.to_owned();
write_lib(
tmp.path(),
r#"
#[aristo::intent("explicit one reworded", verify = "test", id = "explicit_kept")] fn a() {}
#[aristo::intent("idless one reworded", verify = "test")] fn b() {}
"#,
);
aristo_in(tmp.path()).arg("stamp").assert().success();
let idx2 = read_index(tmp.path());
assert!(
idx2.entries.contains_key(&explicit),
"explicit id is untouched by a reword (still aliases the user string)"
);
let idless2 = idx2
.entries
.keys()
.find(|k| k.as_str().starts_with("aret_"))
.expect("idless sibling still has an aret_ id")
.as_str()
.to_owned();
assert_ne!(idless1, idless2, "the idless id re-anchors on a reword");
}
#[test]
fn stamp_double_orphan_does_not_clobber_earlier_archived_proof() {
let tmp = tempfile::tempdir().unwrap();
aristo_in(tmp.path()).arg("init").assert().success();
let src = r#"#[aristo::intent("doomed", verify = "neural", id = "doomed")] fn d() {}"#;
let proofs_dir = tmp.path().join(".aristo/proofs");
let archive_dir = tmp.path().join(".aristo/archive/proofs");
write_lib(tmp.path(), src);
aristo_in(tmp.path()).arg("stamp").assert().success();
fs::create_dir_all(&proofs_dir).unwrap();
fs::write(proofs_dir.join("doomed.proof"), "v1\n").unwrap();
write_lib(tmp.path(), "// nothing\n");
aristo_in(tmp.path()).arg("stamp").assert().success();
write_lib(tmp.path(), src);
aristo_in(tmp.path()).arg("stamp").assert().success();
fs::write(proofs_dir.join("doomed.proof"), "v2\n").unwrap();
write_lib(tmp.path(), "// nothing\n");
aristo_in(tmp.path()).arg("stamp").assert().success();
let archived: Vec<String> = fs::read_dir(&archive_dir)
.unwrap()
.map(|e| fs::read_to_string(e.unwrap().path()).unwrap())
.collect();
assert_eq!(archived.len(), 2, "both archived verdicts must be retained");
assert!(archived.contains(&"v1\n".to_string()), "v1 must survive");
assert!(archived.contains(&"v2\n".to_string()), "v2 must survive");
}
#[test]
fn cycle_in_source_aborts_stamp_with_diagnostic() {
let tmp = tempfile::tempdir().unwrap();
aristo_in(tmp.path()).arg("init").assert().success();
write_lib(
tmp.path(),
r#"
#[aristo::intent("a", verify = "test", id = "a", parent = "b")] fn a() {}
#[aristo::intent("b", verify = "test", id = "b", parent = "a")] fn b() {}
"#,
);
aristo_in(tmp.path())
.arg("stamp")
.assert()
.failure()
.code(2)
.stderr(contains("cycle"))
.stderr(contains("No files modified"));
}