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");
}