koala-drift 1.0.4

Wiki ↔ code drift detector.
Documentation
//! `adr.no-delete-accepted` — ADR-0008 forbids deletion (supersede
//! instead). The check fires on either signal:
//!
//! 1. **Gap in current sequence** — id 3 missing while 1, 2, 4 exist.
//!    Catches deletions that leave a gap.
//! 2. **Git history vs current tip** — `git log --diff-filter=D` lists
//!    every ADR ever deleted; if any deleted id isn't currently
//!    present, that's a deletion (catches the case where the *latest*
//!    ADR was deleted, leaving no gap).
//!
//! When `git` isn't reachable (e.g. running outside a checkout), the
//! check falls back to gap-only mode.

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();

        // Historical ids = current ∪ ever-deleted (per git log).
        let mut historical: HashSet<u32> = present.clone();
        if let Some(deleted) = git_deleted_adr_ids(ctx.root()) {
            historical.extend(deleted);
        }

        // Honest accounting: also flag gaps below max even if git is
        // unreachable. So the historical max defaults to the current
        // max if the git lookup returned nothing extra.
        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
    }
}

/// Parse `git log --diff-filter=D --name-only -- wiki/decisions/`
/// for ever-deleted ADR file paths and pull the leading id.
/// Returns `None` if git is unreachable / the path isn't a git
/// checkout (the check then degrades to gap-only mode).
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]);
    }

    /// Initialize a real git repo, commit two ADRs, delete the
    /// highest-numbered one. Without git history the gap detector
    /// would see only ADR-1 and pass — with git history we catch
    /// the deletion.
    #[test]
    fn deleted_tip_adr_caught_via_git_history() {
        let tmp = TempDir::new().unwrap();
        let root = tmp.path();

        // Real git init.
        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"]);

        // Delete ADR-2 — leaves no gap (only ADR-1 remains).
        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:?}"
        );
        // The claim should reflect "once committed".
        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}"
        );
    }
}