use std::fs;
use std::path::Path;
use std::process::Command;
fn bin() -> Command {
Command::new(env!("CARGO_BIN_EXE_spec-spine"))
}
fn write_spec(root: &Path, dir: &str, id: &str, status: &str) {
let spec_dir = root.join("specs").join(dir);
fs::create_dir_all(&spec_dir).unwrap();
let body = format!(
"---\nid: \"{id}\"\ntitle: \"T\"\nstatus: {status}\ncreated: \"2026-06-08\"\nsummary: \"s\"\n---\n# {id}\n"
);
fs::write(spec_dir.join("spec.md"), body).unwrap();
}
fn code(out: &std::process::Output) -> i32 {
out.status.code().unwrap_or(-1)
}
#[test]
fn index_slice_hashes_and_check() {
let tmp = tempfile::tempdir().unwrap();
write_spec(tmp.path(), "001-a", "001-a", "approved");
fs::create_dir_all(tmp.path().join("conf")).unwrap();
fs::write(tmp.path().join("conf/a.json"), "{\"a\":1}\n").unwrap();
fs::write(tmp.path().join("conf/b.json"), "{\"b\":2}\n").unwrap();
let run = |args: &[&str]| {
let out = bin().arg("--repo").arg(tmp.path()).args(args).output();
out.unwrap()
};
let slices_file = tmp.path().join(".derived/codebase-index/slices.json");
assert_eq!(code(&run(&["index"])), 0);
assert!(
!slices_file.exists(),
"no slices configured -> no slices.json sidecar"
);
assert_eq!(
code(&run(&["index", "check", "--slice", "agent-config"])),
3,
"unknown slice name -> 3"
);
fs::write(
tmp.path().join("spec-spine.toml"),
"[index.slices]\nzz-last = [\"conf/b.json\"]\nagent-config = [\"conf/a.json\", \"conf/missing.json\"]\n",
)
.unwrap();
assert_eq!(
code(&run(&["index", "check", "--slice", "agent-config"])),
2,
"an index predating the slice config is not vouching for it"
);
assert_eq!(code(&run(&["index"])), 0);
let raw = fs::read_to_string(&slices_file).unwrap();
assert!(
raw.find("agent-config").unwrap() < raw.find("zz-last").unwrap(),
"slice hash keys are sorted"
);
assert_eq!(
code(&run(&["index", "check", "--slice", "agent-config"])),
0
);
assert_eq!(code(&run(&["index", "check", "--slice", "zz-last"])), 0);
assert_eq!(code(&run(&["index", "check"])), 0);
fs::write(tmp.path().join("conf/a.json"), "{\"a\":99}\n").unwrap();
assert_eq!(code(&run(&["index", "check"])), 0, "global gate unaffected");
assert_eq!(
code(&run(&["index", "check", "--slice", "agent-config"])),
2
);
assert_eq!(code(&run(&["index", "check", "--slice", "zz-last"])), 0);
write_spec(tmp.path(), "001-a", "001-a", "draft");
assert_eq!(
code(&run(&["index", "check"])),
2,
"spec.md is global input"
);
assert_eq!(code(&run(&["index", "check", "--slice", "zz-last"])), 0);
assert_eq!(code(&run(&["index"])), 0);
fs::remove_file(tmp.path().join("conf/b.json")).unwrap();
assert_eq!(code(&run(&["index", "check", "--slice", "zz-last"])), 2);
assert_eq!(code(&run(&["index", "check", "--slice", "nope"])), 3);
}
#[test]
fn invalid_slice_config_exits_3() {
let tmp = tempfile::tempdir().unwrap();
write_spec(tmp.path(), "001-a", "001-a", "approved");
fs::write(
tmp.path().join("spec-spine.toml"),
"[index.slices]\n\"Bad_Name\" = [\"conf/*.json\"]\n",
)
.unwrap();
assert_eq!(
code(
&bin()
.arg("--repo")
.arg(tmp.path())
.arg("index")
.output()
.unwrap()
),
3
);
fs::write(
tmp.path().join("spec-spine.toml"),
"[index.slices]\nok = []\n",
)
.unwrap();
assert_eq!(
code(
&bin()
.arg("--repo")
.arg(tmp.path())
.arg("index")
.output()
.unwrap()
),
3
);
}
#[test]
fn compile_ok_then_queries() {
let tmp = tempfile::tempdir().unwrap();
write_spec(tmp.path(), "001-a", "001-a", "approved");
write_spec(tmp.path(), "002-b", "002-b", "approved");
let compile = bin()
.arg("--repo")
.arg(tmp.path())
.arg("compile")
.output()
.unwrap();
assert_eq!(code(&compile), 0, "clean compile exits 0");
assert!(
tmp.path()
.join(".derived/spec-registry/by-spec/001-a.json")
.is_file()
);
assert!(
!tmp.path()
.join(".derived/spec-registry/registry.json")
.exists()
);
let list = bin()
.arg("--repo")
.arg(tmp.path())
.args(["registry", "list"])
.output()
.unwrap();
assert_eq!(code(&list), 0);
assert!(String::from_utf8_lossy(&list.stdout).contains("001-a"));
let show_missing = bin()
.arg("--repo")
.arg(tmp.path())
.args(["registry", "show", "999-nope"])
.output()
.unwrap();
assert_eq!(code(&show_missing), 1, "not found exits 1");
}
#[test]
fn registry_list_ids_only_projection() {
let tmp = tempfile::tempdir().unwrap();
write_spec(tmp.path(), "001-a", "001-a", "approved");
write_spec(tmp.path(), "002-b", "002-b", "approved");
write_spec(tmp.path(), "003-c", "003-c", "draft");
let compiled = bin()
.arg("--repo")
.arg(tmp.path())
.arg("compile")
.output()
.unwrap();
assert_eq!(code(&compiled), 0);
let text = bin()
.arg("--repo")
.arg(tmp.path())
.args(["registry", "list", "--ids-only"])
.output()
.unwrap();
assert_eq!(code(&text), 0);
assert_eq!(
String::from_utf8_lossy(&text.stdout),
"001-a\n002-b\n003-c\n"
);
let json = bin()
.arg("--repo")
.arg(tmp.path())
.args(["registry", "list", "--ids-only", "--json"])
.output()
.unwrap();
assert_eq!(code(&json), 0);
let ids: Vec<String> = serde_json::from_slice(&json.stdout).unwrap();
assert_eq!(ids, ["001-a", "002-b", "003-c"]);
let filtered = bin()
.arg("--repo")
.arg(tmp.path())
.args(["registry", "list", "--ids-only", "--status", "approved"])
.output()
.unwrap();
assert_eq!(code(&filtered), 0);
assert_eq!(String::from_utf8_lossy(&filtered.stdout), "001-a\n002-b\n");
let filtered_json = bin()
.arg("--repo")
.arg(tmp.path())
.args([
"registry",
"list",
"--ids-only",
"--status",
"retired",
"--json",
])
.output()
.unwrap();
assert_eq!(code(&filtered_json), 0);
let none: Vec<String> = serde_json::from_slice(&filtered_json.stdout).unwrap();
assert!(none.is_empty());
let empty = bin()
.arg("--repo")
.arg(tmp.path())
.args(["registry", "list", "--ids-only", "--status", "retired"])
.output()
.unwrap();
assert_eq!(code(&empty), 0);
assert!(empty.stdout.is_empty());
}
#[test]
fn registry_status_report_nonzero_only_projection() {
let tmp = tempfile::tempdir().unwrap();
write_spec(tmp.path(), "001-a", "001-a", "approved");
write_spec(tmp.path(), "002-b", "002-b", "approved");
write_spec(tmp.path(), "003-c", "003-c", "draft");
let compiled = bin()
.arg("--repo")
.arg(tmp.path())
.arg("compile")
.output()
.unwrap();
assert_eq!(code(&compiled), 0);
let plain = bin()
.arg("--repo")
.arg(tmp.path())
.args(["registry", "status-report"])
.output()
.unwrap();
assert_eq!(code(&plain), 0);
assert_eq!(
String::from_utf8_lossy(&plain.stdout),
"total: 3\ndraft: 1\napproved: 2\nsuperseded: 0\nretired: 0\n"
);
let human = bin()
.arg("--repo")
.arg(tmp.path())
.args(["registry", "status-report", "--nonzero-only"])
.output()
.unwrap();
assert_eq!(code(&human), 0);
assert_eq!(
String::from_utf8_lossy(&human.stdout),
"total: 3\ndraft: 1\napproved: 2\n"
);
let json = bin()
.arg("--repo")
.arg(tmp.path())
.args(["registry", "status-report", "--nonzero-only", "--json"])
.output()
.unwrap();
assert_eq!(code(&json), 0);
let report: serde_json::Value = serde_json::from_slice(&json.stdout).unwrap();
assert_eq!(report["total"], 3);
assert_eq!(report["draft"], 1);
assert_eq!(report["approved"], 2);
assert!(report.get("superseded").is_none());
assert!(report.get("retired").is_none());
}
#[test]
fn compile_validation_failure_exits_1() {
let tmp = tempfile::tempdir().unwrap();
write_spec(tmp.path(), "001-folder", "001-mismatch", "approved");
let out = bin()
.arg("--repo")
.arg(tmp.path())
.arg("compile")
.output()
.unwrap();
assert_eq!(code(&out), 1, "validation failure exits 1");
}
#[test]
fn missing_specs_dir_exits_3() {
let tmp = tempfile::tempdir().unwrap();
let out = bin()
.arg("--repo")
.arg(tmp.path())
.arg("compile")
.output()
.unwrap();
assert_eq!(code(&out), 3, "I/O error exits 3");
}
#[test]
fn registry_query_before_compile_exits_3() {
let tmp = tempfile::tempdir().unwrap();
write_spec(tmp.path(), "001-a", "001-a", "approved");
let out = bin()
.arg("--repo")
.arg(tmp.path())
.args(["registry", "list"])
.output()
.unwrap();
assert_eq!(code(&out), 3);
}
#[test]
fn index_then_check_fresh_then_stale() {
let tmp = tempfile::tempdir().unwrap();
write_spec(tmp.path(), "001-a", "001-a", "approved");
let built = bin()
.arg("--repo")
.arg(tmp.path())
.arg("index")
.output()
.unwrap();
assert_eq!(code(&built), 0, "index writes -> 0");
assert!(
tmp.path()
.join(".derived/codebase-index/by-spec/001-a.json")
.is_file()
);
let fresh = bin()
.arg("--repo")
.arg(tmp.path())
.args(["index", "check"])
.output()
.unwrap();
assert_eq!(code(&fresh), 0, "fresh -> 0");
write_spec(tmp.path(), "001-a", "001-a", "draft");
let stale = bin()
.arg("--repo")
.arg(tmp.path())
.args(["index", "check"])
.output()
.unwrap();
assert_eq!(code(&stale), 2, "stale -> 2");
}
#[test]
fn index_render_and_orphans_projections() {
let tmp = tempfile::tempdir().unwrap();
let write_claiming_spec = |id: &str, target: &str| {
let dir = tmp.path().join("specs").join(id);
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("spec.md"),
format!(
"---\nid: \"{id}\"\ntitle: \"T\"\nstatus: approved\ncreated: \"2026-06-08\"\nsummary: \"s\"\nestablishes:\n - \"{target}\"\n---\n# {id}\n"
),
)
.unwrap();
};
fs::create_dir_all(tmp.path().join("src")).unwrap();
fs::write(tmp.path().join("src/lib.rs"), "// Spec: 001-a\n").unwrap();
write_claiming_spec("001-a", "src/lib.rs");
write_claiming_spec("002-b", "src/missing.rs");
let early_render = bin()
.arg("--repo")
.arg(tmp.path())
.args(["index", "render"])
.output()
.unwrap();
assert_eq!(code(&early_render), 3, "render without index -> 3");
let early_orphans = bin()
.arg("--repo")
.arg(tmp.path())
.args(["index", "orphans"])
.output()
.unwrap();
assert_eq!(code(&early_orphans), 3, "orphans without index -> 3");
let built = bin()
.arg("--repo")
.arg(tmp.path())
.arg("index")
.output()
.unwrap();
assert_eq!(code(&built), 0);
let orphans_text = bin()
.arg("--repo")
.arg(tmp.path())
.args(["index", "orphans"])
.output()
.unwrap();
assert_eq!(code(&orphans_text), 0, "orphans is a query, not a gate");
assert_eq!(String::from_utf8_lossy(&orphans_text.stdout), "002-b\n");
let orphans_json = bin()
.arg("--repo")
.arg(tmp.path())
.args(["index", "orphans", "--json"])
.output()
.unwrap();
assert_eq!(code(&orphans_json), 0);
let ids: Vec<String> = serde_json::from_slice(&orphans_json.stdout).unwrap();
assert_eq!(ids, ["002-b"]);
let render = bin()
.arg("--repo")
.arg(tmp.path())
.args(["index", "render"])
.output()
.unwrap();
assert_eq!(code(&render), 0, "diagnostics do not fail a render");
let md = String::from_utf8_lossy(&render.stdout);
let positions: Vec<usize> = [
"# spec-spine codebase index",
"## Packages",
"## Traceability",
]
.iter()
.map(|s| md.find(s).unwrap_or_else(|| panic!("missing section {s}")))
.collect();
assert!(positions.windows(2).all(|w| w[0] < w[1]), "section order");
assert!(md.contains("### Orphaned specs"));
assert!(md.contains("- 002-b"));
assert!(md.ends_with('\n'));
fs::remove_dir_all(tmp.path().join("specs/002-b")).unwrap();
let rebuilt = bin()
.arg("--repo")
.arg(tmp.path())
.arg("index")
.output()
.unwrap();
assert_eq!(code(&rebuilt), 0);
let none = bin()
.arg("--repo")
.arg(tmp.path())
.args(["index", "orphans"])
.output()
.unwrap();
assert_eq!(code(&none), 0);
assert!(none.stdout.is_empty());
}
#[test]
fn lint_fail_on_warn_gating() {
let tmp = tempfile::tempdir().unwrap();
write_spec(tmp.path(), "001-a", "001-a", "approved");
let lenient = bin()
.arg("--repo")
.arg(tmp.path())
.arg("lint")
.output()
.unwrap();
assert_eq!(code(&lenient), 0, "warnings alone do not fail");
let strict = bin()
.arg("--repo")
.arg(tmp.path())
.args(["lint", "--fail-on-warn"])
.output()
.unwrap();
assert_eq!(code(&strict), 1, "--fail-on-warn fails on a warning");
}
#[test]
fn compile_check_exit_contract() {
let tmp = tempfile::tempdir().unwrap();
write_spec(tmp.path(), "001-a", "001-a", "approved");
let run = |args: &[&str]| {
bin()
.arg("--repo")
.arg(tmp.path())
.args(args)
.output()
.unwrap()
};
assert_eq!(
code(&run(&["compile", "--check"])),
2,
"unbuilt -> stale (2)"
);
assert_eq!(code(&run(&["compile"])), 0);
assert_eq!(
code(&run(&["compile", "--check"])),
0,
"just compiled -> fresh (0)"
);
let meta = tmp.path().join(".derived/spec-registry/build-meta.json");
let meta_before = fs::read(&meta).unwrap();
assert_eq!(code(&run(&["compile", "--check"])), 0);
assert_eq!(
fs::read(&meta).unwrap(),
meta_before,
"--check must not restamp build-meta.json"
);
let spec_md = tmp.path().join("specs/001-a/spec.md");
let edited = fs::read_to_string(&spec_md).unwrap() + "\nmore body\n";
fs::write(&spec_md, edited).unwrap();
let stale = run(&["compile", "--check"]);
assert_eq!(code(&stale), 2, "edited spec, stale shard -> 2");
assert!(
String::from_utf8_lossy(&stale.stderr).contains("modified 001-a.json"),
"stale detail belongs on stderr: {}",
String::from_utf8_lossy(&stale.stderr)
);
fs::write(
&spec_md,
"---\nid: \"mismatched\"\ntitle: \"T\"\nstatus: approved\ncreated: \"2026-06-08\"\nsummary: \"s\"\n---\n",
)
.unwrap();
assert_eq!(
code(&run(&["compile", "--check"])),
1,
"validation outranks staleness"
);
}
#[test]
fn index_coverage_reports_and_gates() {
let tmp = tempfile::tempdir().unwrap();
let r = tmp.path();
let write = |rel: &str, content: &str| {
let p = r.join(rel);
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(p, content).unwrap();
};
write(
"Cargo.toml",
"[package]\nname = \"root\"\nversion = \"0.1.0\"\n",
);
write("src/lib.rs", "pub fn a() {}\n");
write("src/other.rs", "pub fn b() {}\n");
write(
"specs/001-a/spec.md",
"---\nid: \"001-a\"\ntitle: \"T\"\nstatus: approved\ncreated: \"2026-06-08\"\nsummary: \"s\"\nestablishes:\n - \"src/lib.rs\"\n---\n# 001-a\n",
);
let run = |args: &[&str]| bin().arg("--repo").arg(r).args(args).output().unwrap();
assert_eq!(
code(&run(&["index", "coverage"])),
3,
"no committed index -> artifact missing (3)"
);
assert_eq!(code(&run(&["index"])), 0);
let text = run(&["index", "coverage"]);
assert_eq!(code(&text), 0, "a report, not a gate");
let out = String::from_utf8_lossy(&text.stdout);
assert!(
out.contains(
"coverage: 1/2 source files specifically claimed (50.0%); 0 floor-only, 1 unclaimed"
),
"{out}"
);
assert!(
out.contains("unclaimed (no owning spec):\n src/other.rs"),
"{out}"
);
let json = run(&["index", "coverage", "--json"]);
assert_eq!(code(&json), 0);
let report: serde_json::Value = serde_json::from_slice(&json.stdout).unwrap();
assert_eq!(report["sourceFiles"], 2);
assert_eq!(report["claimedFiles"], 1);
assert_eq!(
report["unclaimedFiles"],
serde_json::json!(["src/other.rs"])
);
assert_eq!(
code(&run(&["index", "coverage", "--fail-on-untraced"])),
1,
"an untraced file fails the assertion"
);
write(
"specs/001-a/spec.md",
"---\nid: \"001-a\"\ntitle: \"T\"\nstatus: approved\ncreated: \"2026-06-08\"\nsummary: \"s\"\nestablishes:\n - \"src/\"\n---\n# 001-a\n",
);
assert_eq!(
code(&run(&["index", "coverage"])),
2,
"stale index -> 2, never a report over the wrong ledger"
);
assert_eq!(code(&run(&["index"])), 0);
let full = run(&["index", "coverage", "--fail-on-untraced"]);
assert_eq!(code(&full), 0, "{}", String::from_utf8_lossy(&full.stderr));
assert!(
String::from_utf8_lossy(&full.stdout)
.contains("coverage: 2/2 source files specifically claimed (100.0%)")
);
}
#[test]
fn closed_reader_exits_cleanly_rather_than_panicking() {
use std::io::Read;
use std::process::Stdio;
let tmp = tempfile::tempdir().unwrap();
let filler = "x".repeat(8192);
for i in 0..30 {
let id = format!("{i:03}-spec");
let dir = tmp.path().join("specs").join(&id);
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("spec.md"),
format!(
"---\nid: \"{id}\"\ntitle: \"T\"\nstatus: approved\ncreated: \"2026-06-08\"\nsummary: \"{filler}\"\n---\n# {id}\n"
),
)
.unwrap();
}
let compiled = bin()
.arg("--repo")
.arg(tmp.path())
.arg("compile")
.output()
.unwrap();
assert_eq!(code(&compiled), 0, "fixture must compile");
let mut child = bin()
.arg("--repo")
.arg(tmp.path())
.args(["registry", "list", "--json"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let mut stdout = child.stdout.take().unwrap();
let mut buf = [0u8; 32];
let _ = stdout.read(&mut buf);
drop(stdout);
let out = child.wait_with_output().unwrap();
let stderr = String::from_utf8_lossy(&out.stderr);
assert_ne!(
out.status.code(),
Some(101),
"a closed reader must not panic the process; stderr: {stderr}"
);
assert!(
!stderr.contains("panicked"),
"no panic should reach stderr; stderr: {stderr}"
);
assert_eq!(
out.status.code(),
Some(0),
"a reader that stops early is a normal end; stderr: {stderr}"
);
}
fn panicking_stdout_macros(line: &str) -> Vec<usize> {
let mut hits = Vec::new();
if line.trim_start().starts_with("//") {
return hits;
}
for mac in ["print!(", "println!("] {
let mut from = 0;
while let Some(rel) = line[from..].find(mac) {
let at = from + rel;
let is_stderr = at > 0 && line.as_bytes()[at - 1] == b'e';
if !is_stderr {
hits.push(at);
}
from = at + mac.len();
}
}
hits
}
#[test]
fn scanner_does_not_let_a_stderr_call_mask_a_stdout_one() {
assert!(panicking_stdout_macros(r#"eprintln!("x");"#).is_empty());
assert!(panicking_stdout_macros(r#"eprint!("x");"#).is_empty());
assert!(panicking_stdout_macros("// println!(\"a comment\");").is_empty());
assert!(!panicking_stdout_macros(r#"println!("x");"#).is_empty());
assert!(!panicking_stdout_macros(r#"print!("x");"#).is_empty());
assert!(!panicking_stdout_macros(r#"eprintln!("{}", x); println!("{}", y);"#).is_empty());
assert!(!panicking_stdout_macros(r#"eprint!("{}", x); print!("{}", y);"#).is_empty());
}
#[test]
fn no_panicking_stdout_macro_remains_in_the_cli() {
let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut offenders: Vec<String> = Vec::new();
let mut dirs = vec![src.clone()];
let mut files: Vec<std::path::PathBuf> = Vec::new();
while let Some(dir) = dirs.pop() {
for entry in fs::read_dir(&dir).unwrap() {
let p = entry.unwrap().path();
if p.is_dir() {
dirs.push(p);
} else {
files.push(p);
}
}
}
for path in files {
if path.extension().is_none_or(|e| e != "rs") {
continue;
}
let text = fs::read_to_string(&path).unwrap();
for (n, raw) in text.lines().enumerate() {
let line = raw.trim_start();
if !panicking_stdout_macros(line).is_empty() {
offenders.push(format!(
"{}:{}: {line}",
path.strip_prefix(&src).unwrap_or(&path).display(),
n + 1
));
}
}
}
assert!(
offenders.is_empty(),
"CLI stdout must go through out.rs, not a panicking macro (spec 035):\n{}",
offenders.join("\n")
);
}