Skip to main content

koala_drift/checks/
feature_status_impl.rs

1//! `feature.status-done-has-impl` — when a feature's frontmatter
2//! claims `status: done` (or `implemented`), the `owner` directory it
3//! lists must contain at least one Rust source file. Catches the
4//! "checkboxes flipped without code" failure mode.
5
6use crate::check::{Check, Finding, FindingKind, Severity};
7use crate::scan::{list_feature_files, rel};
8use koala_core::invariant::Context;
9use std::fs;
10
11const DONE_STATUSES: &[&str] = &["done", "implemented", "shipped"];
12
13pub struct FeatureStatusHasImpl;
14
15impl Check for FeatureStatusHasImpl {
16    fn id(&self) -> &'static str {
17        "feature.status-done-has-impl"
18    }
19
20    fn intent(&self) -> &'static str {
21        "A feature whose frontmatter claims status=done (or implemented) \
22         must point at an `owner` directory containing real source code."
23    }
24
25    fn run(&self, ctx: &Context) -> Vec<Finding> {
26        let mut out = Vec::new();
27        for feature in list_feature_files(ctx.root()) {
28            let Ok(content) = fs::read_to_string(&feature) else {
29                continue;
30            };
31            let Some((status, owner, status_line)) = parse_status_owner(&content) else {
32                continue;
33            };
34            if !DONE_STATUSES
35                .iter()
36                .any(|s| s.eq_ignore_ascii_case(&status))
37            {
38                continue;
39            }
40            let display = rel(&feature, ctx.root());
41            let owner_dir = ctx.root().join(owner.trim_end_matches('/'));
42            if !owner_dir.is_dir() {
43                out.push(Finding {
44                    check_id: self.id(),
45                    file: display.clone(),
46                    line: status_line,
47                    claim: format!("status: {status}; owner: {owner}"),
48                    kind: FindingKind::AcceptanceTestRefMissing,
49                    severity: Severity::Hard,
50                    fix_hint: Some(format!(
51                        "owner directory `{owner}` doesn't exist; either implement it or \
52                         set status to `in-progress` / `planned`",
53                    )),
54                });
55                continue;
56            }
57            if !contains_rust_source(&owner_dir) {
58                out.push(Finding {
59                    check_id: self.id(),
60                    file: display.clone(),
61                    line: status_line,
62                    claim: format!("status: {status}; owner: {owner}"),
63                    kind: FindingKind::AcceptanceTestRefMissing,
64                    severity: Severity::Hard,
65                    fix_hint: Some(format!(
66                        "owner directory `{owner}` has no .rs files yet; either start \
67                         the implementation or set status back to `in-progress`",
68                    )),
69                });
70            }
71        }
72        out
73    }
74}
75
76fn parse_status_owner(content: &str) -> Option<(String, String, usize)> {
77    let rest = content.strip_prefix("---\n")?;
78    let end = rest.find("\n---\n")?;
79    let front = &rest[..end];
80
81    let mut status: Option<String> = None;
82    let mut owner: Option<String> = None;
83    let mut status_line: usize = 1;
84    for (i, line) in front.lines().enumerate() {
85        let Some((k, v)) = line.split_once(':') else {
86            continue;
87        };
88        match k.trim() {
89            "status" => {
90                status = Some(v.trim().to_string());
91                status_line = i + 2; // +1 for `---` line, +1 for 1-indexing
92            }
93            "owner" => owner = Some(v.trim().to_string()),
94            _ => {}
95        }
96    }
97    Some((status?, owner?, status_line))
98}
99
100fn contains_rust_source(dir: &std::path::Path) -> bool {
101    use walkdir::WalkDir;
102    WalkDir::new(dir)
103        .into_iter()
104        .filter_map(Result::ok)
105        .any(|e| {
106            e.file_type().is_file() && e.path().extension().and_then(|s| s.to_str()) == Some("rs")
107        })
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use std::fs;
114    use tempfile::TempDir;
115
116    fn setup(tmp: &TempDir, feature_body: &str, source_present: bool) {
117        fs::create_dir_all(tmp.path().join("wiki/features")).unwrap();
118        fs::write(tmp.path().join("wiki/features/x.md"), feature_body).unwrap();
119        fs::create_dir_all(tmp.path().join("crates/x/src")).unwrap();
120        if source_present {
121            fs::write(tmp.path().join("crates/x/src/lib.rs"), "fn x() {}\n").unwrap();
122        }
123    }
124
125    #[test]
126    fn done_without_source_blocks() {
127        let tmp = TempDir::new().unwrap();
128        setup(
129            &tmp,
130            "---\nid: x\nstatus: done\nowner: crates/x/\n---\n# x\n",
131            false,
132        );
133        let ctx = Context::new(tmp.path().to_path_buf());
134        let f = FeatureStatusHasImpl.run(&ctx);
135        assert_eq!(f.len(), 1);
136        assert_eq!(f[0].severity, Severity::Hard);
137    }
138
139    #[test]
140    fn implemented_with_source_passes() {
141        let tmp = TempDir::new().unwrap();
142        setup(
143            &tmp,
144            "---\nid: x\nstatus: implemented\nowner: crates/x/\n---\n# x\n",
145            true,
146        );
147        let ctx = Context::new(tmp.path().to_path_buf());
148        assert!(FeatureStatusHasImpl.run(&ctx).is_empty());
149    }
150
151    #[test]
152    fn in_progress_status_skipped() {
153        let tmp = TempDir::new().unwrap();
154        setup(
155            &tmp,
156            "---\nid: x\nstatus: in-progress\nowner: crates/x/\n---\n# x\n",
157            false,
158        );
159        let ctx = Context::new(tmp.path().to_path_buf());
160        assert!(FeatureStatusHasImpl.run(&ctx).is_empty());
161    }
162}