koala-core 1.0.4

Shared types, invariant evaluator, and primitives for the koala framework.
Documentation
use crate::invariant::rules::util::rel;
use crate::invariant::{Category, Context, Invariant, Outcome};
use crate::wiki::{extract_frontmatter, parse_yaml_frontmatter};
use std::fs;

const ALLOWED_STATUS: &[&str] = &["accepted", "superseded", "deprecated", "proposed"];
const REQUIRED_KEYS: &[&str] = &["id", "title", "status", "date"];

pub struct AdrFrontmatterValid;

impl Invariant for AdrFrontmatterValid {
    fn id(&self) -> &'static str {
        "governance.adr-frontmatter-valid"
    }
    fn category(&self) -> Category {
        Category::Governance
    }
    fn intent(&self) -> &'static str {
        "Every ADR file has YAML frontmatter with id / title / status / date and a known status value."
    }
    fn adr(&self) -> Option<&'static str> {
        Some("ADR-0013")
    }

    fn evaluate(&self, ctx: &Context) -> Outcome {
        let dir = ctx.root().join("wiki/decisions");
        if !dir.is_dir() {
            return Outcome::skip("wiki/decisions/ not found");
        }
        let entries = match fs::read_dir(&dir) {
            Ok(it) => it,
            Err(e) => return Outcome::skip(format!("read_dir failed: {e}")),
        };

        let mut errors = Vec::new();
        let mut checked = 0usize;
        for entry in entries.flatten() {
            let path = entry.path();
            if path.extension().and_then(|s| s.to_str()) != Some("md") {
                continue;
            }
            let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
                continue;
            };
            if name.starts_with('_') {
                continue;
            }
            checked += 1;

            let display = rel(&path, ctx.root());
            let content = match fs::read_to_string(&path) {
                Ok(c) => c,
                Err(e) => {
                    errors.push(format!("{display}: read failed: {e}"));
                    continue;
                }
            };
            let Some(fm_text) = extract_frontmatter(&content) else {
                errors.push(format!(
                    "{display}: missing or unterminated `---` frontmatter block"
                ));
                continue;
            };
            let fields = parse_yaml_frontmatter(fm_text);

            for key in REQUIRED_KEYS {
                if !fields.contains_key(*key) {
                    errors.push(format!("{display}: missing required key `{key}`"));
                }
            }
            if let Some(status) = fields.get("status") {
                if !ALLOWED_STATUS.contains(&status.as_str()) {
                    errors.push(format!(
                        "{display}: status `{status}` not in {ALLOWED_STATUS:?}"
                    ));
                }
            }
            if let Some(title) = fields.get("title") {
                if title.is_empty() {
                    errors.push(format!("{display}: `title` is empty"));
                }
            }
        }

        if errors.is_empty() {
            Outcome::pass_with(format!("{checked} ADR file(s) valid"))
        } else {
            Outcome::fail_repro(
                format!("{} ADR(s) failed:\n  {}", errors.len(), errors.join("\n  ")),
                "head -10 wiki/decisions/*.md",
            )
        }
    }
}