use std::path::Path;
use std::process::Command;
use tempfile::TempDir;
fn aristo_bin() -> &'static str {
env!("CARGO_BIN_EXE_aristo")
}
fn aristo_in(workspace: &Path) -> Command {
let mut c = Command::new(aristo_bin());
c.env_clear();
if let Ok(path) = std::env::var("PATH") {
c.env("PATH", path);
}
#[cfg(target_os = "macos")]
if let Ok(p) = std::env::var("DYLD_FALLBACK_LIBRARY_PATH") {
c.env("DYLD_FALLBACK_LIBRARY_PATH", p);
}
let home = workspace.join("home");
std::fs::create_dir_all(&home).unwrap();
c.env("HOME", &home);
c.env("XDG_CONFIG_HOME", home.join("xdg"));
c.current_dir(workspace);
c
}
fn setup_workspace(source_body: &str) -> TempDir {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("aristo.toml"), "").unwrap();
std::fs::create_dir_all(tmp.path().join("src")).unwrap();
std::fs::create_dir_all(tmp.path().join(".aristo")).unwrap();
std::fs::write(tmp.path().join("src").join("lib.rs"), source_body).unwrap();
std::fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname = \"sandbox\"\nversion = \"0.0.1\"\nedition = \"2021\"\n",
)
.unwrap();
tmp
}
fn write_fixture(fixture_dir: &Path) {
std::fs::create_dir_all(fixture_dir).unwrap();
let body = r#"
effective_scopes = [":vanilla"]
canon_version = "v0.2.0"
matched_at = "2026-06-15T09:14:22Z"
results = [
[
{ canon_id = "some_unrelated_entry", version = "v0.1.2", canonical_text = "output sequence is monotonically non-decreasing", confidence = 0.66, scope = ":vanilla", prefix_tier = "aristos:", backed_by = "specialized neural checker", linked = "arta_x1y2z3a4b5c6", verification = { coverage_level = "loose", test_binaries = [] } }
]
]
"#;
std::fs::write(fixture_dir.join("match.toml"), body).unwrap();
}
const SOURCE: &str = r#"
#[aristo::intent(
"result must be strictly increasing",
id = "my_local_invariant"
)]
pub fn check_sequence() {}
"#;
fn stamp(ws: &Path, fixture: &Path) {
let out = aristo_in(ws)
.env("ARISTO_CANON_FIXTURE", fixture)
.args(["stamp"])
.output()
.unwrap();
assert!(
out.status.success(),
"stamp failed: stdout={} stderr={}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn reject_moves_pending_to_rejected_with_text_hash_pin() {
let ws = setup_workspace(SOURCE);
let fixture = ws.path().join("fixtures/canon");
write_fixture(&fixture);
stamp(ws.path(), &fixture);
let out = aristo_in(ws.path())
.args([
"canon",
"reject",
"my_local_invariant",
"some_unrelated_entry",
"--reason",
"intentionally narrower than canon entry",
])
.output()
.unwrap();
assert!(
out.status.success(),
"reject failed: stdout={} stderr={}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let cache = std::fs::read_to_string(ws.path().join(".aristo/canon-matches.toml")).unwrap();
assert!(
!cache.contains("[[my_local_invariant.pending_matches]]"),
"pending should be empty after reject; got:\n{cache}"
);
assert!(
cache.contains("[[my_local_invariant.rejected_matches]]"),
"expected rejected_matches entry; got:\n{cache}"
);
assert!(
cache.contains(r#"canon_id = "some_unrelated_entry""#),
"got: {cache}"
);
assert!(
cache.contains(r#"reason = "intentionally narrower than canon entry""#),
"got: {cache}"
);
assert!(cache.contains("text_hash ="), "got: {cache}");
}
#[test]
fn reject_without_reason_still_succeeds_and_omits_reason() {
let ws = setup_workspace(SOURCE);
let fixture = ws.path().join("fixtures/canon");
write_fixture(&fixture);
stamp(ws.path(), &fixture);
let out = aristo_in(ws.path())
.args([
"canon",
"reject",
"my_local_invariant",
"some_unrelated_entry",
])
.output()
.unwrap();
assert!(out.status.success(), "reject (no reason) failed");
let cache = std::fs::read_to_string(ws.path().join(".aristo/canon-matches.toml")).unwrap();
assert!(
cache.contains("[[my_local_invariant.rejected_matches]]"),
"got: {cache}"
);
let rejected_section_idx = cache
.find("[[my_local_invariant.rejected_matches]]")
.unwrap();
let after = &cache[rejected_section_idx..];
let next_section = after[1..].find("[[").map(|i| i + 1).unwrap_or(after.len());
let entry = &after[..next_section];
assert!(
!entry.contains("reason"),
"expected no reason field for no-reason reject; got entry:\n{entry}"
);
}
#[test]
fn reject_pin_uses_index_text_hash() {
let ws = setup_workspace(SOURCE);
let fixture = ws.path().join("fixtures/canon");
write_fixture(&fixture);
stamp(ws.path(), &fixture);
let index_raw = std::fs::read_to_string(ws.path().join(".aristo/index.toml")).unwrap();
let needle = "[my_local_invariant]";
let section_start = index_raw.find(needle).expect("annotation in index");
let section = &index_raw[section_start..];
let th_idx = section
.find("text_hash = \"")
.expect("text_hash in section");
let after = §ion[th_idx + "text_hash = \"".len()..];
let end = after.find('"').unwrap();
let expected_text_hash = &after[..end];
aristo_in(ws.path())
.args([
"canon",
"reject",
"my_local_invariant",
"some_unrelated_entry",
])
.status()
.unwrap();
let cache = std::fs::read_to_string(ws.path().join(".aristo/canon-matches.toml")).unwrap();
let pin_line = format!(r#"text_hash = "{expected_text_hash}""#);
assert!(
cache.contains(&pin_line),
"expected rejection to pin against the index's text_hash {expected_text_hash}; cache:\n{cache}"
);
}
#[test]
fn reject_then_restamp_suppresses_the_same_match() {
let ws = setup_workspace(SOURCE);
let fixture = ws.path().join("fixtures/canon");
write_fixture(&fixture);
stamp(ws.path(), &fixture);
aristo_in(ws.path())
.args([
"canon",
"reject",
"my_local_invariant",
"some_unrelated_entry",
"--reason",
"too broad",
])
.status()
.unwrap();
let out = aristo_in(ws.path())
.env("ARISTO_CANON_FIXTURE", &fixture)
.args(["stamp", "--refresh-canon"])
.output()
.unwrap();
assert!(
out.status.success(),
"second stamp failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let cache = std::fs::read_to_string(ws.path().join(".aristo/canon-matches.toml")).unwrap();
assert!(
!cache.contains("[[my_local_invariant.pending_matches]]"),
"rejected match should not re-surface after stamp; cache:\n{cache}"
);
assert!(
cache.contains("[[my_local_invariant.rejected_matches]]"),
"rejected_matches should survive a restamp; cache:\n{cache}"
);
}
#[test]
fn reject_with_unknown_canon_id_errors() {
let ws = setup_workspace(SOURCE);
let fixture = ws.path().join("fixtures/canon");
write_fixture(&fixture);
stamp(ws.path(), &fixture);
let out = aristo_in(ws.path())
.args(["canon", "reject", "my_local_invariant", "wrong_canon_id"])
.output()
.unwrap();
assert!(!out.status.success());
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("no pending canon match"),
"expected diagnostic; got: {stderr}"
);
}
#[test]
fn reject_with_unknown_annotation_id_errors() {
let ws = setup_workspace(SOURCE);
let fixture = ws.path().join("fixtures/canon");
write_fixture(&fixture);
stamp(ws.path(), &fixture);
let out = aristo_in(ws.path())
.args([
"canon",
"reject",
"no_such_annotation",
"some_unrelated_entry",
])
.output()
.unwrap();
assert!(!out.status.success());
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("no pending canon matches")
|| stderr.contains("not found")
|| stderr.contains("not in"),
"expected diagnostic; got: {stderr}"
);
}