koala-drift 1.0.4

Wiki ↔ code drift detector.
Documentation
//! `feature.status-done-has-impl` — when a feature's frontmatter
//! claims `status: done` (or `implemented`), the `owner` directory it
//! lists must contain at least one Rust source file. Catches the
//! "checkboxes flipped without code" failure mode.

use crate::check::{Check, Finding, FindingKind, Severity};
use crate::scan::{list_feature_files, rel};
use koala_core::invariant::Context;
use std::fs;

const DONE_STATUSES: &[&str] = &["done", "implemented", "shipped"];

pub struct FeatureStatusHasImpl;

impl Check for FeatureStatusHasImpl {
    fn id(&self) -> &'static str {
        "feature.status-done-has-impl"
    }

    fn intent(&self) -> &'static str {
        "A feature whose frontmatter claims status=done (or implemented) \
         must point at an `owner` directory containing real source code."
    }

    fn run(&self, ctx: &Context) -> Vec<Finding> {
        let mut out = Vec::new();
        for feature in list_feature_files(ctx.root()) {
            let Ok(content) = fs::read_to_string(&feature) else {
                continue;
            };
            let Some((status, owner, status_line)) = parse_status_owner(&content) else {
                continue;
            };
            if !DONE_STATUSES
                .iter()
                .any(|s| s.eq_ignore_ascii_case(&status))
            {
                continue;
            }
            let display = rel(&feature, ctx.root());
            let owner_dir = ctx.root().join(owner.trim_end_matches('/'));
            if !owner_dir.is_dir() {
                out.push(Finding {
                    check_id: self.id(),
                    file: display.clone(),
                    line: status_line,
                    claim: format!("status: {status}; owner: {owner}"),
                    kind: FindingKind::AcceptanceTestRefMissing,
                    severity: Severity::Hard,
                    fix_hint: Some(format!(
                        "owner directory `{owner}` doesn't exist; either implement it or \
                         set status to `in-progress` / `planned`",
                    )),
                });
                continue;
            }
            if !contains_rust_source(&owner_dir) {
                out.push(Finding {
                    check_id: self.id(),
                    file: display.clone(),
                    line: status_line,
                    claim: format!("status: {status}; owner: {owner}"),
                    kind: FindingKind::AcceptanceTestRefMissing,
                    severity: Severity::Hard,
                    fix_hint: Some(format!(
                        "owner directory `{owner}` has no .rs files yet; either start \
                         the implementation or set status back to `in-progress`",
                    )),
                });
            }
        }
        out
    }
}

fn parse_status_owner(content: &str) -> Option<(String, String, usize)> {
    let rest = content.strip_prefix("---\n")?;
    let end = rest.find("\n---\n")?;
    let front = &rest[..end];

    let mut status: Option<String> = None;
    let mut owner: Option<String> = None;
    let mut status_line: usize = 1;
    for (i, line) in front.lines().enumerate() {
        let Some((k, v)) = line.split_once(':') else {
            continue;
        };
        match k.trim() {
            "status" => {
                status = Some(v.trim().to_string());
                status_line = i + 2; // +1 for `---` line, +1 for 1-indexing
            }
            "owner" => owner = Some(v.trim().to_string()),
            _ => {}
        }
    }
    Some((status?, owner?, status_line))
}

fn contains_rust_source(dir: &std::path::Path) -> bool {
    use walkdir::WalkDir;
    WalkDir::new(dir)
        .into_iter()
        .filter_map(Result::ok)
        .any(|e| {
            e.file_type().is_file() && e.path().extension().and_then(|s| s.to_str()) == Some("rs")
        })
}

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

    fn setup(tmp: &TempDir, feature_body: &str, source_present: bool) {
        fs::create_dir_all(tmp.path().join("wiki/features")).unwrap();
        fs::write(tmp.path().join("wiki/features/x.md"), feature_body).unwrap();
        fs::create_dir_all(tmp.path().join("crates/x/src")).unwrap();
        if source_present {
            fs::write(tmp.path().join("crates/x/src/lib.rs"), "fn x() {}\n").unwrap();
        }
    }

    #[test]
    fn done_without_source_blocks() {
        let tmp = TempDir::new().unwrap();
        setup(
            &tmp,
            "---\nid: x\nstatus: done\nowner: crates/x/\n---\n# x\n",
            false,
        );
        let ctx = Context::new(tmp.path().to_path_buf());
        let f = FeatureStatusHasImpl.run(&ctx);
        assert_eq!(f.len(), 1);
        assert_eq!(f[0].severity, Severity::Hard);
    }

    #[test]
    fn implemented_with_source_passes() {
        let tmp = TempDir::new().unwrap();
        setup(
            &tmp,
            "---\nid: x\nstatus: implemented\nowner: crates/x/\n---\n# x\n",
            true,
        );
        let ctx = Context::new(tmp.path().to_path_buf());
        assert!(FeatureStatusHasImpl.run(&ctx).is_empty());
    }

    #[test]
    fn in_progress_status_skipped() {
        let tmp = TempDir::new().unwrap();
        setup(
            &tmp,
            "---\nid: x\nstatus: in-progress\nowner: crates/x/\n---\n# x\n",
            false,
        );
        let ctx = Context::new(tmp.path().to_path_buf());
        assert!(FeatureStatusHasImpl.run(&ctx).is_empty());
    }
}