use crate::domain::model::check::{CheckViolation, CheckViolationKind, Severity, Violations};
use crate::domain::model::entry_locator::EntryLocator;
use crate::domain::model::issue::Issue;
use crate::domain::model::load_defect::LoadDefect;
use crate::domain::model::malformed_entry::MalformedEntry;
use crate::domain::model::status::StatusesConfig;
use crate::domain::model::tag_descriptor::TagDescriptors;
use crate::domain::usecases::check::{issue_rules, IssueCheckCtx};
use crate::domain::usecases::entry_defect_scanner::EntryDefectScanner;
use crate::domain::usecases::issue::{IssueCheckResult, IssueRepository};
const SCAN_RULE_ID: &str = "issue/scan";
fn locator_to_path(loc: &EntryLocator) -> std::path::PathBuf {
let s = loc.as_str();
let bare = s.strip_prefix("file://").unwrap_or(s);
std::path::PathBuf::from(bare)
}
fn render_defect(defect: &LoadDefect) -> String {
match defect {
LoadDefect::SourceUnreadable { reason } => reason.clone(),
LoadDefect::InvalidFrontmatter { reason } => reason.clone(),
LoadDefect::MissingId => "missing 'id'".to_string(),
LoadDefect::IdPrefixMismatch { expected, found } => {
format!("id '{found}' does not start with prefix '{expected}'")
}
LoadDefect::InvalidStatus { value } => format!("invalid status '{value}'"),
LoadDefect::MissingEventsLog => "missing 'events.jsonl' sibling".to_string(),
LoadDefect::StatusJournalMismatch {
frontmatter,
terminal,
} => format!(
"frontmatter status '{frontmatter}' diverges from event-log terminal '{terminal}'"
),
}
}
pub fn check_issues(
repo: &dyn IssueRepository,
defect_scanner: &dyn EntryDefectScanner,
known_refs: &crate::domain::model::entity_ref::KnownRefs,
statuses: &StatusesConfig,
tag_descriptors: &TagDescriptors,
) -> anyhow::Result<Vec<IssueCheckResult>> {
let mut per_file: Vec<(std::path::PathBuf, Violations)> = Vec::new();
let mut pairs: Vec<(std::path::PathBuf, Issue)> = Vec::new();
for MalformedEntry {
location, defect, ..
} in defect_scanner.scan()?
{
let path = locator_to_path(&location);
let mut bag = Violations::new();
bag.push(scan_violation(
path.clone(),
Severity::Error,
render_defect(&defect),
));
per_file.push((path, bag));
}
for issue in repo.list()? {
let path = locator_to_path(&issue.location);
pairs.push((path, issue));
}
let issue_ctx = IssueCheckCtx {
repo,
issues: &pairs,
known_refs,
statuses,
tag_descriptors,
};
for rule in issue_rules() {
for finding in rule.find(&issue_ctx)? {
let v = finding.violation;
if let Some(entry) = per_file.iter_mut().find(|(p, _)| p == &v.path) {
entry.1.push(v);
} else {
let mut bag = Violations::new();
let path = v.path.clone();
bag.push(v);
per_file.push((path, bag));
}
}
}
Ok(per_file
.into_iter()
.map(|(path, violations)| IssueCheckResult { path, violations })
.collect())
}
fn scan_violation(path: std::path::PathBuf, severity: Severity, detail: String) -> CheckViolation {
CheckViolation {
rule_id: SCAN_RULE_ID,
path,
severity,
kind: CheckViolationKind::ScanIssue { detail },
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::model::check::render;
use crate::domain::model::entity_ref::KnownRefs;
use crate::domain::model::issue::Issue;
use crate::domain::model::tag_descriptor::{Cardinality, TagDescriptor, TagDescriptors};
use crate::domain::usecases::issue::tests::{
defect, enrich_issue, feature, ir, issue, FakeIssueRepository, IssueFixture,
};
use std::path::PathBuf;
fn p(s: &str) -> PathBuf {
PathBuf::from(s)
}
fn enriched(fix: IssueFixture, id: u64) -> Issue {
let mut issue = fix.build(ir(id));
enrich_issue(&mut issue, &StatusesConfig::default_issue());
issue
}
fn run(entries: Vec<(PathBuf, Issue)>) -> String {
run_with_descriptors(entries, &TagDescriptors::default())
}
fn run_with_descriptors(
entries: Vec<(PathBuf, Issue)>,
descriptors: &TagDescriptors,
) -> String {
let repo = FakeIssueRepository::with_pairs(entries);
let results = check_issues(
&repo,
&repo,
&KnownRefs::new(),
&StatusesConfig::default_issue(),
descriptors,
)
.expect("check_issues failed unexpectedly");
results
.iter()
.flat_map(|r| {
r.violations.iter().map(|v| {
let severity = if v.severity == Severity::Error {
"error"
} else {
"warning"
};
format!("{}: {severity}: {}", r.path.display(), render(&v.kind))
})
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn all_valid_issues_produce_no_output() {
let v = run(vec![
(
p("docs/issues/0001-story-a/index.md"),
enriched(feature("Story A").with_id("ISSUE-0001").status("open"), 1),
),
(
p("docs/issues/0002-bug-b/index.md"),
enriched(defect("Bug B").with_id("ISSUE-0002").status("closed"), 2),
),
]);
assert!(v.is_empty(), "expected no violations, got:\n{v}");
}
#[test]
fn no_files_produce_no_output() {
let v = run(vec![]);
assert!(v.is_empty(), "expected no violations, got:\n{v}");
}
#[test]
fn a_parent_of_cycle_across_issues_is_reported_as_an_error() {
let v = run(vec![
(
p("docs/issues/0001-a/index.md"),
enriched(
feature("A")
.with_id("ISSUE-0001")
.status("open")
.with_link("ISSUE-0002", "parent-of")
.with_link("ISSUE-0002", "child-of"),
1,
),
),
(
p("docs/issues/0002-b/index.md"),
enriched(
feature("B")
.with_id("ISSUE-0002")
.status("open")
.with_link("ISSUE-0001", "parent-of")
.with_link("ISSUE-0001", "child-of"),
2,
),
),
]);
assert!(v.contains("ParentOfCycle"), "got:\n{v}");
assert!(v.contains("error"), "got:\n{v}");
}
#[test]
fn a_child_with_two_parents_is_flagged_on_both_parents() {
let v = run(vec![
(
p("docs/issues/0001-a/index.md"),
enriched(
feature("A")
.with_id("ISSUE-0001")
.status("open")
.with_link("ISSUE-0003", "parent-of"),
1,
),
),
(
p("docs/issues/0002-b/index.md"),
enriched(
feature("B")
.with_id("ISSUE-0002")
.status("open")
.with_link("ISSUE-0003", "parent-of"),
2,
),
),
(
p("docs/issues/0003-c/index.md"),
enriched(
feature("C")
.with_id("ISSUE-0003")
.status("open")
.with_link("ISSUE-0001", "child-of")
.with_link("ISSUE-0002", "child-of"),
3,
),
),
]);
assert!(v.contains("MultipleParents"), "got:\n{v}");
assert_eq!(
v.lines().filter(|l| l.contains("MultipleParents")).count(),
2,
"expected violation reported on both parents, got:\n{v}"
);
}
#[test]
fn closed_parent_with_open_child_is_warned() {
let v = run(vec![
(
p("docs/issues/0001-parent/index.md"),
enriched(
feature("Parent")
.with_id("ISSUE-0001")
.status("closed")
.with_link("ISSUE-0002", "parent-of"),
1,
),
),
(
p("docs/issues/0002-child/index.md"),
enriched(
feature("Child")
.with_id("ISSUE-0002")
.status("open")
.with_link("ISSUE-0001", "child-of"),
2,
),
),
]);
assert!(
v.contains("warning")
&& v.contains("ISSUE-0001")
&& v.contains("closed")
&& v.contains("ISSUE-0002"),
"got:\n{v}"
);
assert!(
!v.contains("error"),
"drift should warn, not error — got:\n{v}"
);
}
#[test]
fn closed_parent_with_only_closed_children_is_silent() {
let v = run(vec![
(
p("docs/issues/0001-parent/index.md"),
enriched(
feature("Parent")
.with_id("ISSUE-0001")
.status("closed")
.with_link("ISSUE-0002", "parent-of"),
1,
),
),
(
p("docs/issues/0002-child/index.md"),
enriched(
feature("Child")
.with_id("ISSUE-0002")
.status("closed")
.with_link("ISSUE-0001", "child-of"),
2,
),
),
]);
assert!(v.is_empty(), "expected no violations, got:\n{v}");
}
#[test]
fn open_parent_with_open_child_is_silent() {
let v = run(vec![
(
p("docs/issues/0001-parent/index.md"),
enriched(
feature("Parent")
.with_id("ISSUE-0001")
.status("open")
.with_link("ISSUE-0002", "parent-of"),
1,
),
),
(
p("docs/issues/0002-child/index.md"),
enriched(
feature("Child")
.with_id("ISSUE-0002")
.status("open")
.with_link("ISSUE-0001", "child-of"),
2,
),
),
]);
assert!(v.is_empty(), "expected no violations, got:\n{v}");
}
#[test]
fn missing_inverse_pointer_is_an_error() {
let v = run(vec![
(
p("docs/issues/0001-a/index.md"),
enriched(
feature("A")
.with_id("ISSUE-0001")
.status("open")
.with_link("ISSUE-0002", "blocked-by"),
1,
),
),
(
p("docs/issues/0002-b/index.md"),
enriched(feature("B").with_id("ISSUE-0002").status("open"), 2),
),
]);
assert!(
v.contains("0002-b")
&& v.contains("error")
&& v.contains("MissingBackPointer")
&& v.contains("back=blocks"),
"got:\n{v}"
);
}
#[test]
fn symmetric_depends_on_pair_is_silent() {
let v = run(vec![
(
p("docs/issues/0001-a/index.md"),
enriched(
feature("A")
.with_id("ISSUE-0001")
.status("open")
.with_link("ISSUE-0002", "blocked-by"),
1,
),
),
(
p("docs/issues/0002-b/index.md"),
enriched(
feature("B")
.with_id("ISSUE-0002")
.status("open")
.with_link("ISSUE-0001", "blocks"),
2,
),
),
]);
assert!(v.is_empty(), "expected no violations, got:\n{v}");
}
#[test]
fn duplicate_issue_ids_are_flagged_on_both_files() {
let v = run(vec![
(
p("docs/issues/0001-add-feature/index.md"),
enriched(
feature("Add feature").with_id("ISSUE-0001").status("open"),
1,
),
),
(
p("docs/issues/0001-add-feature-copy/index.md"),
enriched(
feature("Add feature copy")
.with_id("ISSUE-0001")
.status("open"),
1,
),
),
]);
assert!(v.contains("DuplicateId"), "got:\n{v}");
assert!(
v.lines().filter(|l| l.contains("DuplicateId")).count() == 2,
"expected both files flagged, got:\n{v}"
);
}
#[test]
fn unknown_tag_value_under_closed_descriptor_is_warned() {
let descriptors = TagDescriptors::new(vec![TagDescriptor {
key: "area".into(),
levels: vec!["backend".into(), "frontend".into()],
cardinality: Cardinality::AtMostOne,
ordered: false,
weights: None,
aggregate: None,
applies_to: vec!["issues".into()],
}]);
let v = run_with_descriptors(
vec![(
p("docs/issues/0001-with-bad-area/index.md"),
enriched(
feature("With bad area")
.with_id("ISSUE-0001")
.status("open")
.tags("area:bogus"),
1,
),
)],
&descriptors,
);
assert!(
v.contains("warning") && v.contains("UnknownLevel") && v.contains("bogus"),
"got:\n{v}"
);
}
#[test]
fn missing_required_descriptor_key_is_warned_via_cardinality_unmet() {
let descriptors = TagDescriptors::new(vec![TagDescriptor {
key: "flow".into(),
levels: vec!["feature".into(), "defect".into()],
cardinality: Cardinality::ExactlyOne,
ordered: false,
weights: None,
aggregate: None,
applies_to: vec!["issues".into()],
}]);
let issue = enriched(issue("No flow tag").with_id("ISSUE-0001").status("open"), 1);
let v = run_with_descriptors(
vec![(p("docs/issues/0001-no-flow/index.md"), issue)],
&descriptors,
);
assert!(
v.contains("warning")
&& v.contains("CardinalityUnmet")
&& v.contains("flow")
&& v.contains("ExactlyOne"),
"got:\n{v}"
);
}
}