use crate::check::{Check, Finding, FindingKind, Severity};
use crate::scan::{fuzzy_suggest, list_feature_files, rel, tagged_lines};
use koala_core::invariant::Context;
use std::fs;
use std::path::Path;
const FORECAST_MARKER: &str = "(待创建)";
const ACCEPTANCE_HEADING_PREFIX: &str = "Acceptance criteria";
pub struct FeatureAcceptanceTestRef;
impl Check for FeatureAcceptanceTestRef {
fn id(&self) -> &'static str {
"feature.acceptance-test-ref"
}
fn intent(&self) -> &'static str {
"Acceptance criteria reference paths that exist in the repo \
(unless explicitly marked (待创建) as a forecast)."
}
fn run(&self, ctx: &Context) -> Vec<Finding> {
let mut out = Vec::new();
for feature_path in list_feature_files(ctx.root()) {
let Ok(content) = fs::read_to_string(&feature_path) else {
continue;
};
let display = rel(&feature_path, ctx.root());
for line in tagged_lines(&content) {
if line.in_fence {
continue;
}
if !line
.section
.map(|s| s.starts_with(ACCEPTANCE_HEADING_PREFIX))
.unwrap_or(false)
{
continue;
}
let stripped = line.text.trim_start();
let Some(after) = stripped.strip_prefix("→ ") else {
continue;
};
let raw = after.trim();
if raw.is_empty() {
continue;
}
if raw.contains(FORECAST_MARKER) {
continue;
}
let token = raw
.split(|c: char| c.is_whitespace() || c == '(' || c == '(')
.next()
.unwrap_or("")
.trim();
let path_str = token.split('#').next().unwrap_or("");
if path_str.is_empty() || path_str.contains('<') {
continue;
}
let target = ctx.root().join(path_str);
if target.exists() {
continue;
}
let suggestion = fuzzy_suggest(Path::new(path_str), ctx.root(), 3);
let mut hint = format!(
"create `{path_str}` or mark this acceptance line as \
{FORECAST_MARKER} until the test exists"
);
if let Some(s) = &suggestion {
hint.push_str(&format!("; did you mean `{}` ?", s.display()));
}
out.push(Finding {
check_id: self.id(),
file: display.clone(),
line: line.line_no,
claim: raw.to_string(),
kind: FindingKind::AcceptanceTestRefMissing,
severity: Severity::Hard,
fix_hint: Some(hint),
});
}
}
out
}
}