use std::fs;
use std::path::Path;
use std::process::{Command, Output, Stdio};
use serde_json::Value;
const RULE_ANCHOR: &str = "3-1-the-rule";
fn run(root: &Path, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_spec-spine"))
.arg("--repo")
.arg(root)
.args(args)
.stdin(Stdio::null())
.output()
.expect("spawn spec-spine")
}
fn text(out: &Output) -> String {
format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
)
}
fn json(root: &Path, args: &[&str], want: i32) -> Value {
let out = run(root, args);
assert_eq!(out.status.code(), Some(want), "{args:?}: {}", text(&out));
serde_json::from_slice(&out.stdout).unwrap()
}
fn compile(root: &Path) {
let out = run(root, &["compile"]);
assert_eq!(out.status.code(), Some(0), "compile: {}", text(&out));
}
struct Exporter {
tmp: tempfile::TempDir,
status: &'static str,
rule: &'static str,
other: &'static str,
}
impl Exporter {
fn new() -> Self {
let e = Exporter {
tmp: tempfile::tempdir().unwrap(),
status: "draft",
rule: "The rule text.",
other: "Other text.",
};
let dir = e.root().join("specs/002-b");
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("spec.md"),
"---\nid: \"002-b\"\ntitle: \"B\"\nstatus: draft\ncreated: \"2026-09-22\"\nsummary: \"s\"\n\
impacts:\n - obligation: \"001#R-1\"\n nature: refines\n - obligation: \"001#R-2\"\n nature: informs\n\
---\n# 002\n\n## 1. Purpose\n\nP.\n",
)
.unwrap();
e.write();
e
}
fn root(&self) -> &Path {
self.tmp.path()
}
fn write(&self) {
let dir = self.root().join("specs/001-a");
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("spec.md"),
format!(
"---\nid: \"001-a\"\ntitle: \"A\"\nstatus: {}\ncreated: \"2026-09-22\"\nsummary: \"s\"\n\
obligations:\n - id: \"R-1\"\n kind: requirement\n text: \"The rule holds.\"\n anchor: \"{RULE_ANCHOR}\"\n\
\x20 - id: \"R-2\"\n kind: requirement\n text: \"Old.\"\n anchor: \"3-2-the-other-rule\"\n withdrawn: true\n\
---\n# 001\n\n## 3. Behavior\n\n### 3.1 The rule\n\n{}\n\n### 3.2 The other rule\n\n{}\n",
self.status, self.rule, self.other
),
)
.unwrap();
compile(self.root());
}
fn show(&self) -> Value {
json(self.root(), &["registry", "show", "001", "--json"], 0)
}
fn closure_digest(&self, request: &str) -> String {
let path = self.root().join("request.json");
fs::write(&path, request).unwrap();
let doc = json(
self.root(),
&[
"registry",
"closure",
"--request",
path.to_str().unwrap(),
"--json",
],
0,
);
doc["digest"].as_str().unwrap().to_string()
}
}
fn importer(exp: &Exporter) -> tempfile::TempDir {
let show = exp.show();
let digest = format!("sha256:{}", show["contentHash"].as_str().unwrap());
let section = format!(
"sha256:{}",
show["sectionDigests"][RULE_ANCHOR].as_str().unwrap()
);
let t = tempfile::tempdir().unwrap();
for (id, sections) in [
("001-whole", String::new()),
(
"002-sectioned",
format!(
" sections:\n - anchor: \"{RULE_ANCHOR}\"\n digest: \"{section}\"\n"
),
),
] {
let dir = t.path().join("specs").join(id);
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("spec.md"),
format!(
"---\nid: \"{id}\"\ntitle: \"T\"\nstatus: draft\ncreated: \"2026-09-22\"\nsummary: \"s\"\n\
interface_references:\n - corpus: \"upstream\"\n spec: \"001-a\"\n digest: \"{digest}\"\n obtained: \"2026-09-22\"\n{sections}\
---\n# {id}\n"
),
)
.unwrap();
}
compile(t.path());
t
}
fn outcomes(imp: &Path, exp: &Path) -> (Vec<(String, String)>, i32) {
let e = format!("upstream={}", exp.display());
let out = run(imp, &["interface", "verify", "--export", &e, "--json"]);
let code = out.status.code().unwrap_or(-1);
assert!(code == 0 || code == 1, "{}", text(&out));
let doc: Value = serde_json::from_slice(&out.stdout).unwrap();
let v = doc["references"]
.as_array()
.unwrap()
.iter()
.map(|r| {
(
r["declaredBy"].as_str().unwrap().to_string(),
r["outcome"].as_str().unwrap().to_string(),
)
})
.collect();
(v, code)
}
fn pair(a: &str, b: &str) -> (String, String) {
(a.to_string(), b.to_string())
}
#[test]
fn one_section_identity_across_obligation_show_and_pin() {
let exp = Exporter::new();
let ob = json(
exp.root(),
&["registry", "obligation", "001#R-1", "--json"],
0,
);
let show = exp.show();
assert_eq!(ob["sectionDigest"], show["sectionDigests"][RULE_ANCHOR]);
assert_eq!(ob["contentHash"], show["contentHash"]);
let imp = importer(&exp);
let (o, code) = outcomes(imp.path(), exp.root());
assert_eq!(code, 0);
assert_eq!(
o,
vec![
pair("001-whole", "current"),
pair("002-sectioned", "current")
]
);
}
#[test]
fn a_changed_obligation_section_moves_the_closure_and_stales_both_pins() {
let mut exp = Exporter::new();
let imp = importer(&exp);
let by_ob = r#"{"obligations":["001#R-1"]}"#;
let closure_before = exp.closure_digest(by_ob);
let impacts_before = run(exp.root(), &["registry", "impacts", "--json"]).stdout;
exp.rule = "The rule text, amended.";
exp.write();
assert_ne!(closure_before, exp.closure_digest(by_ob));
assert_eq!(
impacts_before,
run(exp.root(), &["registry", "impacts", "--json"]).stdout
);
let (o, code) = outcomes(imp.path(), exp.root());
assert_eq!(code, 1);
assert_eq!(
o,
vec![pair("001-whole", "stale"), pair("002-sectioned", "stale")]
);
}
#[test]
fn a_status_flip_stales_only_whole_file_identities() {
let mut exp = Exporter::new();
let imp = importer(&exp);
let by_ob = r#"{"obligations":["001#R-1"]}"#;
let closure_before = exp.closure_digest(by_ob);
exp.status = "approved";
exp.write();
assert_eq!(closure_before, exp.closure_digest(by_ob));
let (o, code) = outcomes(imp.path(), exp.root());
assert_eq!(code, 1, "the whole-spec pin refuses");
assert_eq!(
o,
vec![
pair("001-whole", "stale"),
pair("002-sectioned", "sections-current")
]
);
exp.status = "draft";
exp.other = "Other text, rewritten.";
exp.write();
assert_eq!(closure_before, exp.closure_digest(by_ob));
let (o, _) = outcomes(imp.path(), exp.root());
assert_eq!(o[1], pair("002-sectioned", "sections-current"));
}
#[test]
fn an_impact_on_a_withdrawn_obligation_names_it_withdrawn() {
let exp = Exporter::new();
let doc = json(exp.root(), &["registry", "impacts", "--json"], 0);
let by_target: Vec<(String, bool)> = doc["impacts"]
.as_array()
.unwrap()
.iter()
.map(|i| {
(
i["target"].as_str().unwrap().to_string(),
i["targetWithdrawn"].as_bool().unwrap(),
)
})
.collect();
assert_eq!(
by_target,
vec![
("001-a#R-1".to_string(), false),
("001-a#R-2".to_string(), true)
]
);
}
#[test]
fn a_stale_exporter_ledger_cannot_hide_a_change_and_a_stale_importer_ledger_refuses() {
let exp = Exporter::new();
let imp = importer(&exp);
let path = exp.root().join("specs/001-a/spec.md");
let s = fs::read_to_string(&path).unwrap();
fs::write(&path, s.replace("The rule text.", "Changed, uncompiled.")).unwrap();
let (o, code) = outcomes(imp.path(), exp.root());
assert_eq!(code, 1);
assert_eq!(
o,
vec![pair("001-whole", "stale"), pair("002-sectioned", "stale")]
);
let ip = imp.path().join("specs/001-whole/spec.md");
let s = fs::read_to_string(&ip).unwrap();
fs::write(&ip, format!("{s}\nEdited.\n")).unwrap();
let e = format!("upstream={}", exp.root().display());
let out = run(imp.path(), &["interface", "verify", "--export", &e]);
assert_eq!(out.status.code(), Some(1), "{}", text(&out));
}