use crate::check::{Check, Finding, FindingKind, Severity};
use crate::scan::{list_feature_files, rel, tagged_lines};
use koala_core::invariant::Context;
use std::fs;
const DONE_STATUSES: &[&str] = &["done", "implemented", "shipped"];
const ACCEPTANCE_HEADING_PREFIX: &str = "Acceptance criteria";
const UNCHECKED_PREFIX: &str = "- [ ]";
pub struct FeatureAcceptanceComplete;
impl Check for FeatureAcceptanceComplete {
fn id(&self) -> &'static str {
"feature.acceptance-complete"
}
fn intent(&self) -> &'static str {
"A feature whose frontmatter claims status=done must have every \
item in its `## Acceptance criteria` section checked off — no \
unchecked `- [ ]` boxes left open."
}
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;
};
if !is_done(&content) {
continue;
}
let display = rel(&feature, 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;
}
if line.text.trim_start().starts_with(UNCHECKED_PREFIX) {
out.push(Finding {
check_id: self.id(),
file: display.clone(),
line: line.line_no,
claim: line.text.trim().to_string(),
kind: FindingKind::AcceptanceItemUnchecked,
severity: Severity::Hard,
fix_hint: Some(
"this feature is `status: done` but the acceptance \
item is still unchecked; either finish it and tick \
`- [x]`, or set the feature's status back to \
`in-progress`"
.to_string(),
),
});
}
}
}
out
}
}
fn is_done(content: &str) -> bool {
let Some(rest) = content.strip_prefix("---\n") else {
return false;
};
let Some(end) = rest.find("\n---\n") else {
return false;
};
for line in rest[..end].lines() {
if let Some((k, v)) = line.split_once(':') {
if k.trim() == "status" {
let status = v.trim();
return DONE_STATUSES.iter().any(|s| s.eq_ignore_ascii_case(status));
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn write_feature(tmp: &TempDir, name: &str, body: &str) {
fs::create_dir_all(tmp.path().join("wiki/features")).unwrap();
fs::write(tmp.path().join("wiki/features").join(name), body).unwrap();
}
const DONE_ALL_CHECKED: &str = "\
---
id: x
status: done
owner: crates/x/
---
# Feature: x
## Acceptance criteria
- [x] first thing
→ crates/x/src/lib.rs#first
- [x] second thing
";
const DONE_ONE_OPEN: &str = "\
---
id: x
status: done
owner: crates/x/
---
# Feature: x
## Acceptance criteria
- [x] first thing
- [ ] second thing not finished
";
#[test]
fn done_feature_with_all_boxes_checked_passes() {
let tmp = TempDir::new().unwrap();
write_feature(&tmp, "x.md", DONE_ALL_CHECKED);
let ctx = Context::new(tmp.path().to_path_buf());
assert!(FeatureAcceptanceComplete.run(&ctx).is_empty());
}
#[test]
fn done_feature_with_open_box_blocks() {
let tmp = TempDir::new().unwrap();
write_feature(&tmp, "x.md", DONE_ONE_OPEN);
let ctx = Context::new(tmp.path().to_path_buf());
let f = FeatureAcceptanceComplete.run(&ctx);
assert_eq!(f.len(), 1, "the single open box should be flagged");
assert_eq!(f[0].severity, Severity::Hard);
assert_eq!(f[0].kind, FindingKind::AcceptanceItemUnchecked);
assert!(f[0].claim.contains("second thing not finished"));
}
#[test]
fn in_progress_feature_with_open_box_is_skipped() {
let tmp = TempDir::new().unwrap();
write_feature(
&tmp,
"x.md",
&DONE_ONE_OPEN.replace("status: done", "status: in-progress"),
);
let ctx = Context::new(tmp.path().to_path_buf());
assert!(
FeatureAcceptanceComplete.run(&ctx).is_empty(),
"non-done features may legitimately have open boxes"
);
}
#[test]
fn unchecked_box_outside_acceptance_section_is_ignored() {
let tmp = TempDir::new().unwrap();
write_feature(
&tmp,
"x.md",
"\
---
id: x
status: done
---
# Feature: x
## Non-goals
- [ ] this is a roadmap idea, not an acceptance item
## Acceptance criteria
- [x] done
",
);
let ctx = Context::new(tmp.path().to_path_buf());
assert!(
FeatureAcceptanceComplete.run(&ctx).is_empty(),
"only the Acceptance criteria section is scanned"
);
}
#[test]
fn unchecked_box_inside_fence_is_ignored() {
let tmp = TempDir::new().unwrap();
write_feature(
&tmp,
"x.md",
"\
---
id: x
status: done
---
# Feature: x
## Acceptance criteria
- [x] real item
```text
- [ ] this is example output, not a real box
```
",
);
let ctx = Context::new(tmp.path().to_path_buf());
assert!(
FeatureAcceptanceComplete.run(&ctx).is_empty(),
"fenced code blocks are not acceptance items"
);
}
}