use crate::check::{Check, Finding, FindingKind, Severity};
use koala_adr::Registry as AdrRegistry;
use koala_core::invariant::Context;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
pub struct AdrNoDelete;
impl Check for AdrNoDelete {
fn id(&self) -> &'static str {
"adr.no-delete-accepted"
}
fn intent(&self) -> &'static str {
"Every ADR id ever committed must still be present at HEAD. \
Gaps and deleted-tip ADRs both fail (ADR-0008: ADRs are \
immutable, supersede instead)."
}
fn run(&self, ctx: &Context) -> Vec<Finding> {
let Ok(reg) = AdrRegistry::load(ctx.root()) else {
return Vec::new();
};
let mut current: Vec<u32> = reg.entries().iter().map(|e| e.frontmatter.id).collect();
current.sort_unstable();
let present: HashSet<u32> = current.iter().copied().collect();
let mut historical: HashSet<u32> = present.clone();
if let Some(deleted) = git_deleted_adr_ids(ctx.root()) {
historical.extend(deleted);
}
let max = match historical.iter().max().copied() {
Some(m) => m,
None => return Vec::new(),
};
let display = PathBuf::from("wiki/decisions/");
let mut out = Vec::new();
for missing in 1..=max {
if present.contains(&missing) {
continue;
}
let was_deleted = historical.contains(&missing);
let claim = if was_deleted {
format!("ADR-{missing:04} once committed, now deleted")
} else {
format!("ADR-{missing:04} missing")
};
out.push(Finding {
check_id: self.id(),
file: display.clone(),
line: 0,
claim,
kind: FindingKind::AdrIdGap { missing },
severity: Severity::Hard,
fix_hint: Some(format!(
"Restore ADR-{missing:04} (ADR-0008: ADRs are immutable). \
If it was wrongly created, mark it status=deprecated rather than deleting."
)),
});
}
out
}
}
fn git_deleted_adr_ids(root: &Path) -> Option<HashSet<u32>> {
let out = std::process::Command::new("git")
.arg("-C")
.arg(root)
.args([
"log",
"--all",
"--diff-filter=D",
"--name-only",
"--pretty=format:",
"--",
"wiki/decisions/",
])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let text = String::from_utf8_lossy(&out.stdout);
let mut ids: HashSet<u32> = HashSet::new();
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let leaf = trimmed.split('/').next_back().unwrap_or(trimmed);
if leaf.starts_with('_') {
continue;
}
if let Some(prefix) = leaf.split('-').next() {
if let Ok(n) = prefix.parse::<u32>() {
ids.insert(n);
}
}
}
Some(ids)
}
#[cfg(test)]
mod tests {
use super::*;
use koala_core::invariant::Context;
use std::fs;
use tempfile::TempDir;
fn write_adr(root: &std::path::Path, id: u32) {
let dir = root.join("wiki/decisions");
fs::create_dir_all(&dir).unwrap();
let body = format!(
"---\n\
id: {id:04}\n\
title: ADR {id}\n\
status: accepted\n\
date: 2026-01-01\n\
---\n\n# ADR-{id:04}: ADR {id}\n"
);
fs::write(dir.join(format!("{id:04}-adr.md")), body).unwrap();
}
#[test]
fn contiguous_ids_pass() {
let tmp = TempDir::new().unwrap();
for id in 1..=3 {
write_adr(tmp.path(), id);
}
let ctx = Context::new(tmp.path());
let findings = AdrNoDelete.run(&ctx);
assert!(findings.is_empty(), "{findings:?}");
}
#[test]
fn empty_registry_passes() {
let tmp = TempDir::new().unwrap();
let ctx = Context::new(tmp.path());
let findings = AdrNoDelete.run(&ctx);
assert!(findings.is_empty());
}
#[test]
fn accepted_adr_deletion_blocks() {
let tmp = TempDir::new().unwrap();
write_adr(tmp.path(), 1);
write_adr(tmp.path(), 2);
write_adr(tmp.path(), 4);
let ctx = Context::new(tmp.path());
let findings = AdrNoDelete.run(&ctx);
assert_eq!(findings.len(), 1);
let f = &findings[0];
assert_eq!(f.check_id, "adr.no-delete-accepted");
assert_eq!(f.severity, Severity::Hard);
assert!(matches!(f.kind, FindingKind::AdrIdGap { missing: 3 }));
}
#[test]
fn multiple_gaps_each_reported() {
let tmp = TempDir::new().unwrap();
write_adr(tmp.path(), 1);
write_adr(tmp.path(), 5);
let ctx = Context::new(tmp.path());
let findings = AdrNoDelete.run(&ctx);
let missing: Vec<u32> = findings
.iter()
.filter_map(|f| match f.kind {
FindingKind::AdrIdGap { missing } => Some(missing),
_ => None,
})
.collect();
assert_eq!(missing, vec![2, 3, 4]);
}
#[test]
fn deleted_tip_adr_caught_via_git_history() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
let run = |args: &[&str]| {
std::process::Command::new("git")
.args(args)
.current_dir(root)
.output()
.expect("git invocation")
};
run(&["init", "--initial-branch=main"]);
run(&["config", "user.email", "test@example.com"]);
run(&["config", "user.name", "Test"]);
run(&["config", "commit.gpgsign", "false"]);
write_adr(root, 1);
write_adr(root, 2);
run(&["add", "-A"]);
run(&["commit", "-m", "two adrs"]);
std::fs::remove_file(root.join("wiki/decisions/0002-adr.md")).unwrap();
run(&["add", "-A"]);
run(&["commit", "-m", "delete ADR-2"]);
let ctx = Context::new(root);
let findings = AdrNoDelete.run(&ctx);
assert!(
findings
.iter()
.any(|f| matches!(f.kind, FindingKind::AdrIdGap { missing: 2 })),
"expected ADR-2 deletion to be caught, got {findings:?}"
);
let claim = findings
.iter()
.find(|f| matches!(f.kind, FindingKind::AdrIdGap { missing: 2 }))
.map(|f| f.claim.clone())
.unwrap_or_default();
assert!(
claim.contains("once committed"),
"claim should distinguish deletion from gap: {claim}"
);
}
}