koala_drift/checks/
feature_acceptance.rs1use crate::check::{Check, Finding, FindingKind, Severity};
6use crate::scan::{fuzzy_suggest, list_feature_files, rel, tagged_lines};
7use koala_core::invariant::Context;
8use std::fs;
9use std::path::Path;
10
11const FORECAST_MARKER: &str = "(待创建)";
12const ACCEPTANCE_HEADING_PREFIX: &str = "Acceptance criteria";
13
14pub struct FeatureAcceptanceTestRef;
15
16impl Check for FeatureAcceptanceTestRef {
17 fn id(&self) -> &'static str {
18 "feature.acceptance-test-ref"
19 }
20
21 fn intent(&self) -> &'static str {
22 "Acceptance criteria reference paths that exist in the repo \
23 (unless explicitly marked (待创建) as a forecast)."
24 }
25
26 fn run(&self, ctx: &Context) -> Vec<Finding> {
27 let mut out = Vec::new();
28 for feature_path in list_feature_files(ctx.root()) {
29 let Ok(content) = fs::read_to_string(&feature_path) else {
30 continue;
31 };
32 let display = rel(&feature_path, ctx.root());
33 for line in tagged_lines(&content) {
34 if line.in_fence {
35 continue;
36 }
37 if !line
38 .section
39 .map(|s| s.starts_with(ACCEPTANCE_HEADING_PREFIX))
40 .unwrap_or(false)
41 {
42 continue;
43 }
44 let stripped = line.text.trim_start();
48 let Some(after) = stripped.strip_prefix("→ ") else {
49 continue;
50 };
51 let raw = after.trim();
52 if raw.is_empty() {
53 continue;
54 }
55 if raw.contains(FORECAST_MARKER) {
56 continue;
59 }
60
61 let token = raw
65 .split(|c: char| c.is_whitespace() || c == '(' || c == '(')
66 .next()
67 .unwrap_or("")
68 .trim();
69 let path_str = token.split('#').next().unwrap_or("");
70 if path_str.is_empty() || path_str.contains('<') {
71 continue;
75 }
76
77 let target = ctx.root().join(path_str);
78 if target.exists() {
79 continue;
80 }
81 let suggestion = fuzzy_suggest(Path::new(path_str), ctx.root(), 3);
82 let mut hint = format!(
83 "create `{path_str}` or mark this acceptance line as \
84 {FORECAST_MARKER} until the test exists"
85 );
86 if let Some(s) = &suggestion {
87 hint.push_str(&format!("; did you mean `{}` ?", s.display()));
88 }
89 out.push(Finding {
90 check_id: self.id(),
91 file: display.clone(),
92 line: line.line_no,
93 claim: raw.to_string(),
94 kind: FindingKind::AcceptanceTestRefMissing,
95 severity: Severity::Hard,
96 fix_hint: Some(hint),
97 });
98 }
99 }
100 out
101 }
102}