koala-core 1.0.4

Shared types, invariant evaluator, and primitives for the koala framework.
Documentation
//! `governance.adr-not-deleted` — git-aware: every accepted ADR file
//! that exists in `HEAD` must still exist in the working tree. Catches
//! "ADR永不删" violations (rule #9 of the 12 铁律) at the workspace
//! level. ADRs in proposed/rejected status may be removed.
//!
//! Falls back to a `Skip` outcome when `git` is unavailable so the
//! invariant remains green in non-git checkouts.

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"))
        {
            // Skip _index, _template, etc.
            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)
}