Skip to main content

koala_drift/checks/
adr_no_delete.rs

1//! `adr.no-delete-accepted` — ADR-0008 forbids deletion (supersede
2//! instead). The check fires on either signal:
3//!
4//! 1. **Gap in current sequence** — id 3 missing while 1, 2, 4 exist.
5//!    Catches deletions that leave a gap.
6//! 2. **Git history vs current tip** — `git log --diff-filter=D` lists
7//!    every ADR ever deleted; if any deleted id isn't currently
8//!    present, that's a deletion (catches the case where the *latest*
9//!    ADR was deleted, leaving no gap).
10//!
11//! When `git` isn't reachable (e.g. running outside a checkout), the
12//! check falls back to gap-only mode.
13
14use crate::check::{Check, Finding, FindingKind, Severity};
15use koala_adr::Registry as AdrRegistry;
16use koala_core::invariant::Context;
17use std::collections::HashSet;
18use std::path::{Path, PathBuf};
19
20pub struct AdrNoDelete;
21
22impl Check for AdrNoDelete {
23    fn id(&self) -> &'static str {
24        "adr.no-delete-accepted"
25    }
26
27    fn intent(&self) -> &'static str {
28        "Every ADR id ever committed must still be present at HEAD. \
29         Gaps and deleted-tip ADRs both fail (ADR-0008: ADRs are \
30         immutable, supersede instead)."
31    }
32
33    fn run(&self, ctx: &Context) -> Vec<Finding> {
34        let Ok(reg) = AdrRegistry::load(ctx.root()) else {
35            return Vec::new();
36        };
37        let mut current: Vec<u32> = reg.entries().iter().map(|e| e.frontmatter.id).collect();
38        current.sort_unstable();
39        let present: HashSet<u32> = current.iter().copied().collect();
40
41        // Historical ids = current ∪ ever-deleted (per git log).
42        let mut historical: HashSet<u32> = present.clone();
43        if let Some(deleted) = git_deleted_adr_ids(ctx.root()) {
44            historical.extend(deleted);
45        }
46
47        // Honest accounting: also flag gaps below max even if git is
48        // unreachable. So the historical max defaults to the current
49        // max if the git lookup returned nothing extra.
50        let max = match historical.iter().max().copied() {
51            Some(m) => m,
52            None => return Vec::new(),
53        };
54
55        let display = PathBuf::from("wiki/decisions/");
56        let mut out = Vec::new();
57        for missing in 1..=max {
58            if present.contains(&missing) {
59                continue;
60            }
61            let was_deleted = historical.contains(&missing);
62            let claim = if was_deleted {
63                format!("ADR-{missing:04} once committed, now deleted")
64            } else {
65                format!("ADR-{missing:04} missing")
66            };
67            out.push(Finding {
68                check_id: self.id(),
69                file: display.clone(),
70                line: 0,
71                claim,
72                kind: FindingKind::AdrIdGap { missing },
73                severity: Severity::Hard,
74                fix_hint: Some(format!(
75                    "Restore ADR-{missing:04} (ADR-0008: ADRs are immutable). \
76                     If it was wrongly created, mark it status=deprecated rather than deleting."
77                )),
78            });
79        }
80        out
81    }
82}
83
84/// Parse `git log --diff-filter=D --name-only -- wiki/decisions/`
85/// for ever-deleted ADR file paths and pull the leading id.
86/// Returns `None` if git is unreachable / the path isn't a git
87/// checkout (the check then degrades to gap-only mode).
88fn git_deleted_adr_ids(root: &Path) -> Option<HashSet<u32>> {
89    let out = std::process::Command::new("git")
90        .arg("-C")
91        .arg(root)
92        .args([
93            "log",
94            "--all",
95            "--diff-filter=D",
96            "--name-only",
97            "--pretty=format:",
98            "--",
99            "wiki/decisions/",
100        ])
101        .output()
102        .ok()?;
103    if !out.status.success() {
104        return None;
105    }
106    let text = String::from_utf8_lossy(&out.stdout);
107    let mut ids: HashSet<u32> = HashSet::new();
108    for line in text.lines() {
109        let trimmed = line.trim();
110        if trimmed.is_empty() {
111            continue;
112        }
113        let leaf = trimmed.split('/').next_back().unwrap_or(trimmed);
114        if leaf.starts_with('_') {
115            continue;
116        }
117        if let Some(prefix) = leaf.split('-').next() {
118            if let Ok(n) = prefix.parse::<u32>() {
119                ids.insert(n);
120            }
121        }
122    }
123    Some(ids)
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use koala_core::invariant::Context;
130    use std::fs;
131    use tempfile::TempDir;
132
133    fn write_adr(root: &std::path::Path, id: u32) {
134        let dir = root.join("wiki/decisions");
135        fs::create_dir_all(&dir).unwrap();
136        let body = format!(
137            "---\n\
138id: {id:04}\n\
139title: ADR {id}\n\
140status: accepted\n\
141date: 2026-01-01\n\
142---\n\n# ADR-{id:04}: ADR {id}\n"
143        );
144        fs::write(dir.join(format!("{id:04}-adr.md")), body).unwrap();
145    }
146
147    #[test]
148    fn contiguous_ids_pass() {
149        let tmp = TempDir::new().unwrap();
150        for id in 1..=3 {
151            write_adr(tmp.path(), id);
152        }
153        let ctx = Context::new(tmp.path());
154        let findings = AdrNoDelete.run(&ctx);
155        assert!(findings.is_empty(), "{findings:?}");
156    }
157
158    #[test]
159    fn empty_registry_passes() {
160        let tmp = TempDir::new().unwrap();
161        let ctx = Context::new(tmp.path());
162        let findings = AdrNoDelete.run(&ctx);
163        assert!(findings.is_empty());
164    }
165
166    #[test]
167    fn accepted_adr_deletion_blocks() {
168        let tmp = TempDir::new().unwrap();
169        write_adr(tmp.path(), 1);
170        write_adr(tmp.path(), 2);
171        write_adr(tmp.path(), 4);
172        let ctx = Context::new(tmp.path());
173        let findings = AdrNoDelete.run(&ctx);
174        assert_eq!(findings.len(), 1);
175        let f = &findings[0];
176        assert_eq!(f.check_id, "adr.no-delete-accepted");
177        assert_eq!(f.severity, Severity::Hard);
178        assert!(matches!(f.kind, FindingKind::AdrIdGap { missing: 3 }));
179    }
180
181    #[test]
182    fn multiple_gaps_each_reported() {
183        let tmp = TempDir::new().unwrap();
184        write_adr(tmp.path(), 1);
185        write_adr(tmp.path(), 5);
186        let ctx = Context::new(tmp.path());
187        let findings = AdrNoDelete.run(&ctx);
188        let missing: Vec<u32> = findings
189            .iter()
190            .filter_map(|f| match f.kind {
191                FindingKind::AdrIdGap { missing } => Some(missing),
192                _ => None,
193            })
194            .collect();
195        assert_eq!(missing, vec![2, 3, 4]);
196    }
197
198    /// Initialize a real git repo, commit two ADRs, delete the
199    /// highest-numbered one. Without git history the gap detector
200    /// would see only ADR-1 and pass — with git history we catch
201    /// the deletion.
202    #[test]
203    fn deleted_tip_adr_caught_via_git_history() {
204        let tmp = TempDir::new().unwrap();
205        let root = tmp.path();
206
207        // Real git init.
208        let run = |args: &[&str]| {
209            std::process::Command::new("git")
210                .args(args)
211                .current_dir(root)
212                .output()
213                .expect("git invocation")
214        };
215        run(&["init", "--initial-branch=main"]);
216        run(&["config", "user.email", "test@example.com"]);
217        run(&["config", "user.name", "Test"]);
218        run(&["config", "commit.gpgsign", "false"]);
219
220        write_adr(root, 1);
221        write_adr(root, 2);
222        run(&["add", "-A"]);
223        run(&["commit", "-m", "two adrs"]);
224
225        // Delete ADR-2 — leaves no gap (only ADR-1 remains).
226        std::fs::remove_file(root.join("wiki/decisions/0002-adr.md")).unwrap();
227        run(&["add", "-A"]);
228        run(&["commit", "-m", "delete ADR-2"]);
229
230        let ctx = Context::new(root);
231        let findings = AdrNoDelete.run(&ctx);
232        assert!(
233            findings
234                .iter()
235                .any(|f| matches!(f.kind, FindingKind::AdrIdGap { missing: 2 })),
236            "expected ADR-2 deletion to be caught, got {findings:?}"
237        );
238        // The claim should reflect "once committed".
239        let claim = findings
240            .iter()
241            .find(|f| matches!(f.kind, FindingKind::AdrIdGap { missing: 2 }))
242            .map(|f| f.claim.clone())
243            .unwrap_or_default();
244        assert!(
245            claim.contains("once committed"),
246            "claim should distinguish deletion from gap: {claim}"
247        );
248    }
249}