use std::fs;
use std::path::Path;
use std::process::{Command, Output};
fn run_in(root: &Path, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_spec-spine"))
.arg("--repo")
.arg(root)
.args(args)
.output()
.expect("spawn spec-spine")
}
fn code(out: &Output) -> i32 {
out.status.code().unwrap_or(-1)
}
fn stderr(out: &Output) -> String {
String::from_utf8_lossy(&out.stderr).into_owned()
}
fn envelope(out: &Output) -> serde_json::Value {
serde_json::from_slice(&out.stdout).unwrap_or_else(|e| {
panic!(
"stdout is one JSON envelope: {e}\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
stderr(out)
)
})
}
fn write_spec(root: &Path, id: &str) {
let dir = root.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-09-24\"\n\
summary: \"s\"\n---\n# {id}\n"
),
)
.unwrap();
}
fn fresh_corpus() -> tempfile::TempDir {
let tmp = tempfile::tempdir().unwrap();
write_spec(tmp.path(), "001-a");
for verb in ["compile", "index"] {
assert_eq!(code(&run_in(tmp.path(), &[verb])), 0, "fixture {verb}");
}
tmp
}
fn assert_header(v: &serde_json::Value, verb: &str, exit: i32, outcome: &str) {
let obj = v.as_object().expect("an object");
let mut keys: Vec<&str> = obj.keys().map(String::as_str).collect();
keys.sort_unstable();
let payload = if obj.contains_key("report") {
"report"
} else {
"error"
};
let mut want = vec![
"exitCode",
payload,
"outcome",
"schemaVersion",
"summary",
"tool",
"verb",
];
want.sort_unstable();
assert_eq!(keys, want, "{v}");
assert_eq!(v["schemaVersion"], "1.0.0", "{v}");
assert_eq!(v["tool"], "spec-spine", "{v}");
assert_eq!(v["verb"], verb, "{v}");
assert_eq!(v["exitCode"], exit, "{v}");
assert_eq!(v["outcome"], outcome, "{v}");
assert!(v["summary"].as_str().is_some_and(|s| !s.is_empty()), "{v}");
}
#[test]
fn stale_is_a_finding() {
let tmp = fresh_corpus();
write_spec(tmp.path(), "002-b");
for args in [
&["check"][..],
&["index", "check"][..],
&["compile", "--check"][..],
] {
let out = run_in(tmp.path(), args);
assert_eq!(code(&out), 1, "{args:?}: {}", stderr(&out));
}
let out = run_in(tmp.path(), &["index", "check", "--json"]);
assert_eq!(code(&out), 1, "{}", stderr(&out));
let v = envelope(&out);
assert_header(&v, "index.check", 1, "finding");
assert_eq!(v["report"]["fresh"], false, "the report says why: {v}");
let out = run_in(tmp.path(), &["index", "coverage"]);
assert_eq!(code(&out), 1, "{}", stderr(&out));
}
#[test]
fn malformed_authored_content_is_a_finding() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("specs/001-a");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("spec.md"), "---\nid: [unclosed\n---\n# x\n").unwrap();
let out = run_in(tmp.path(), &["compile"]);
assert_eq!(code(&out), 1, "{}", stderr(&out));
}
#[test]
fn an_invalid_config_is_refused() {
let tmp = fresh_corpus();
fs::write(
tmp.path().join("spec-spine.toml"),
"[layout]\nderived_dir = \"../x\"\n",
)
.unwrap();
let out = run_in(tmp.path(), &["check"]);
assert_eq!(code(&out), 2, "{}", stderr(&out));
let out = run_in(tmp.path(), &["check", "--json"]);
assert_eq!(code(&out), 2, "{}", stderr(&out));
let v = envelope(&out);
assert_header(&v, "check", 2, "refused");
assert_eq!(v["error"]["kind"], "config", "{v}");
fs::write(tmp.path().join("spec-spine.toml"), "not = [toml").unwrap();
let out = run_in(tmp.path(), &["lint"]);
assert_eq!(code(&out), 2, "unparseable config: {}", stderr(&out));
}
#[test]
fn a_pin_mismatch_is_refused() {
let tmp = fresh_corpus();
fs::write(
tmp.path().join("spec-spine.toml"),
"[meta]\nrequired_version = \"=0.0.1\"\n",
)
.unwrap();
let out = run_in(tmp.path(), &["lint"]);
assert_eq!(code(&out), 2, "{}", stderr(&out));
assert!(
stderr(&out).contains("required_version"),
"{}",
stderr(&out)
);
let out = run_in(tmp.path(), &["lint", "--json"]);
assert_eq!(code(&out), 2, "{}", stderr(&out));
let v = envelope(&out);
assert_header(&v, "lint", 2, "refused");
assert_eq!(v["error"]["kind"], "refused", "{v}");
}
#[test]
fn a_containment_refusal_is_refused_not_io() {
let tmp = tempfile::tempdir().unwrap();
write_spec(tmp.path(), "CON");
let out = run_in(tmp.path(), &["compile"]);
assert_eq!(code(&out), 2, "{}", stderr(&out));
assert!(stderr(&out).contains("refused"), "{}", stderr(&out));
}
#[test]
fn a_rejected_flag_combination_is_usage() {
let tmp = fresh_corpus();
let out = run_in(tmp.path(), &["no-such-verb"]);
assert_eq!(code(&out), 3, "{}", stderr(&out));
let out = run_in(tmp.path(), &["compile", "--json"]);
assert_eq!(code(&out), 3, "{}", stderr(&out));
let v = envelope(&out);
assert_header(&v, "compile.check", 3, "usage");
assert_eq!(v["error"]["kind"], "usage", "{v}");
}
#[test]
fn an_io_failure_is_failed() {
let tmp = fresh_corpus();
fs::create_dir(tmp.path().join("spec-spine.toml")).unwrap();
let out = run_in(tmp.path(), &["lint", "--json"]);
assert_eq!(code(&out), 4, "{}", stderr(&out));
let v = envelope(&out);
assert_header(&v, "lint", 4, "failed");
assert_eq!(v["error"]["kind"], "io", "{v}");
}
#[test]
fn a_corrupt_committed_shard_is_failed() {
let tmp = fresh_corpus();
let shard = tmp.path().join(".derived/spec-registry/by-spec/001-a.json");
assert!(shard.is_file(), "fixture shard at {}", shard.display());
fs::write(&shard, "{ not json").unwrap();
let out = run_in(tmp.path(), &["registry", "list"]);
assert_eq!(code(&out), 4, "{}", stderr(&out));
}
#[cfg(unix)]
#[test]
fn check_folds_by_the_higher_code() {
let tmp = fresh_corpus();
let out = run_in(tmp.path(), &["check", "--json"]);
assert_eq!(code(&out), 0, "{}", stderr(&out));
assert_header(&envelope(&out), "check", 0, "ok");
write_spec(tmp.path(), "002-b");
let by_spec = tmp.path().join(".derived/codebase-index/by-spec");
set_mode(&by_spec, 0o000);
let out = run_in(tmp.path(), &["check"]);
set_mode(&by_spec, 0o755);
assert_eq!(code(&out), 4, "{}", stderr(&out));
}
#[cfg(unix)]
fn set_mode(p: &Path, mode: u32) {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(p, fs::Permissions::from_mode(mode)).unwrap();
}
#[cfg(not(unix))]
fn set_mode(_: &Path, _: u32) {}
#[test]
fn a_report_envelope_carries_the_header() {
let tmp = fresh_corpus();
let out = run_in(tmp.path(), &["lint", "--json"]);
assert_eq!(code(&out), 0, "{}", stderr(&out));
let v = envelope(&out);
assert_header(&v, "lint", 0, "ok");
assert!(v.get("report").is_some());
}