koala-core 1.0.4

Shared types, invariant evaluator, and primitives for the koala framework.
Documentation
//! `docs.feature-frontmatter-valid` — every `wiki/features/*.md` (apart
//! from underscore-prefixed templates) must carry an `id`, `status`,
//! and `owner` field. Catches the "feature doc with no contract"
//! failure mode that drift / status checks rely on.

use crate::invariant::rules::util::walk_repo;
use crate::invariant::{Category, Context, Invariant, Outcome};
use std::fs;

pub struct FeatureFrontmatterValid;

impl Invariant for FeatureFrontmatterValid {
    fn id(&self) -> &'static str {
        "docs.feature-frontmatter-valid"
    }
    fn category(&self) -> Category {
        Category::Docs
    }
    fn intent(&self) -> &'static str {
        "Every `wiki/features/*.md` carries `id`, `status`, and `owner` \
         in its frontmatter."
    }
    fn adr(&self) -> Option<&'static str> {
        Some("ADR-0007")
    }

    fn evaluate(&self, ctx: &Context) -> Outcome {
        let dir = ctx.root().join("wiki/features");
        if !dir.is_dir() {
            return Outcome::skip("no wiki/features/ dir");
        }
        let mut missing: Vec<String> = Vec::new();
        for entry in walk_repo(&dir) {
            if !entry.file_type().is_file() {
                continue;
            }
            let path = entry.path();
            let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
                continue;
            };
            if name.starts_with('_') || !name.ends_with(".md") {
                continue;
            }
            let Ok(text) = fs::read_to_string(path) else {
                continue;
            };
            let needed = ["id", "status", "owner"];
            let missing_keys: Vec<&str> = needed
                .iter()
                .filter(|k| !has_frontmatter_key(&text, k))
                .copied()
                .collect();
            if !missing_keys.is_empty() {
                let rel = path
                    .strip_prefix(ctx.root())
                    .unwrap_or(path)
                    .display()
                    .to_string();
                missing.push(format!("{rel} → missing: {}", missing_keys.join(", ")));
            }
        }
        if missing.is_empty() {
            Outcome::pass()
        } else {
            Outcome::fail(format!(
                "{} feature(s) with incomplete frontmatter:\n  {}",
                missing.len(),
                missing.join("\n  "),
            ))
        }
    }
}

fn has_frontmatter_key(text: &str, key: &str) -> bool {
    let Some(rest) = text.strip_prefix("---\n") else {
        return false;
    };
    let Some(end) = rest.find("\n---") else {
        return false;
    };
    let front = &rest[..end];
    front
        .lines()
        .any(|l| l.trim_start().starts_with(&format!("{key}:")))
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::TempDir;

    fn write(tmp: &TempDir, name: &str, body: &str) {
        let dir = tmp.path().join("wiki/features");
        fs::create_dir_all(&dir).unwrap();
        fs::write(dir.join(name), body).unwrap();
    }

    #[test]
    fn complete_frontmatter_passes() {
        let tmp = TempDir::new().unwrap();
        write(
            &tmp,
            "x.md",
            "---\nid: x\nstatus: implemented\nowner: crates/x/\n---\n# x\n",
        );
        let ctx = Context::new(tmp.path().to_path_buf());
        assert!(matches!(
            FeatureFrontmatterValid.evaluate(&ctx),
            Outcome::Pass { .. }
        ));
    }

    #[test]
    fn missing_owner_fails() {
        let tmp = TempDir::new().unwrap();
        write(&tmp, "x.md", "---\nid: x\nstatus: implemented\n---\n# x\n");
        let ctx = Context::new(tmp.path().to_path_buf());
        assert!(matches!(
            FeatureFrontmatterValid.evaluate(&ctx),
            Outcome::Fail { .. }
        ));
    }
}