koala_drift/checks/
feature_status_impl.rs1use 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; }
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}