use crate::invariant::{Category, Context, Invariant, Outcome};
use std::path::Path;
use std::process::Command;
pub struct AdrNotDeleted;
impl Invariant for AdrNotDeleted {
fn id(&self) -> &'static str {
"governance.adr-not-deleted"
}
fn category(&self) -> Category {
Category::Governance
}
fn intent(&self) -> &'static str {
"Accepted/superseded ADRs in HEAD must still exist in the working \
tree (rule #9 of the 12 铁律: ADRs are append-only)."
}
fn adr(&self) -> Option<&'static str> {
Some("ADR-0008")
}
fn evaluate(&self, ctx: &Context) -> Outcome {
let head = match ls_tree_head(ctx.root()) {
Ok(s) => s,
Err(reason) => return Outcome::skip(reason),
};
let mut missing: Vec<String> = Vec::new();
for path in head
.lines()
.filter(|l| l.starts_with("wiki/decisions/") && l.ends_with(".md"))
{
let Some(name) = std::path::Path::new(path)
.file_name()
.and_then(|s| s.to_str())
else {
continue;
};
if name.starts_with('_') || !name_starts_with_four_digits(name) {
continue;
}
if !ctx.root().join(path).is_file() {
missing.push(path.to_string());
}
}
if missing.is_empty() {
Outcome::pass()
} else {
Outcome::fail_repro(
format!(
"{} ADR file(s) in HEAD missing from working tree:\n {}",
missing.len(),
missing.join("\n "),
),
"git diff --name-status HEAD -- wiki/decisions/",
)
}
}
}
fn ls_tree_head(root: &Path) -> Result<String, String> {
let out = Command::new("git")
.args(["ls-tree", "-r", "--name-only", "HEAD"])
.current_dir(root)
.output()
.map_err(|e| format!("git not available: {e}"))?;
if !out.status.success() {
return Err(format!(
"git ls-tree failed: {}",
String::from_utf8_lossy(&out.stderr)
));
}
String::from_utf8(out.stdout).map_err(|e| format!("non-utf8 output: {e}"))
}
fn name_starts_with_four_digits(name: &str) -> bool {
let bytes = name.as_bytes();
bytes.len() >= 4 && bytes[..4].iter().all(u8::is_ascii_digit)
}