use crate::domain::model::check::{CheckViolation, CheckViolationKind, Severity, Violations};
use crate::domain::model::entry_locator::EntryLocator;
use crate::domain::usecases::issue::check_companions::{
validate_content_references, ContentGraph, ContentViolation,
};
use crate::domain::usecases::issue::content_reader::IssueContentReader;
use crate::domain::usecases::issue::repository::IssueCheckResult;
use crate::domain::usecases::issue::IssueRepository;
const COMPANIONS_RULE_ID: &str = "issue/companions";
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)
}
pub fn check_issues_content_references(
repo: &dyn IssueRepository,
content: &dyn IssueContentReader,
) -> Vec<IssueCheckResult> {
let Ok(issues) = repo.list() else {
return Vec::new();
};
let mut out: Vec<IssueCheckResult> = Vec::new();
for issue in issues {
let Ok(Some(set)) = content.content_of(&issue.id) else {
continue;
};
let graph = ContentGraph {
parts: &set.parts,
references: &set.references,
};
let violations = validate_content_references(&graph);
if violations.is_empty() {
continue;
}
let path = locator_to_path(&issue.location);
let mut bag = Violations::new();
for v in violations {
bag.push(to_check_violation(v, path.clone()));
}
out.push(IssueCheckResult {
path,
violations: bag,
});
}
out
}
fn to_check_violation(v: ContentViolation, path: std::path::PathBuf) -> CheckViolation {
let (severity, kind) = match v {
ContentViolation::BrokenReference { from, to } => (
Severity::Error,
CheckViolationKind::BrokenCompanionLink { from, to },
),
};
CheckViolation {
rule_id: COMPANIONS_RULE_ID,
path,
severity,
kind,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::model::issue::test_fixtures::ir;
use crate::domain::model::issue::Issue;
use crate::domain::model::record_ref::IssueRef;
use crate::domain::usecases::issue::content_reader::IssueContentSet;
use crate::domain::usecases::issue::test_support::FakeIssueRepository;
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Default)]
struct StubContentReader {
sets: HashMap<IssueRef, IssueContentSet>,
}
impl StubContentReader {
fn with(mut self, id: IssueRef, parts: &[&str], references: &[(&str, &[&str])]) -> Self {
self.sets.insert(
id,
IssueContentSet {
parts: parts.iter().map(|s| s.to_string()).collect(),
references: references
.iter()
.map(|(n, ts)| (n.to_string(), ts.iter().map(|t| t.to_string()).collect()))
.collect(),
},
);
self
}
}
impl IssueContentReader for StubContentReader {
fn content_of(&self, id: &IssueRef) -> anyhow::Result<Option<IssueContentSet>> {
Ok(self.sets.get(id).map(|s| IssueContentSet {
parts: s.parts.clone(),
references: s.references.clone(),
}))
}
}
fn issue(n: u64) -> Issue {
use crate::domain::model::issue::test_fixtures::feature;
feature("any title")
.with_id(&ir(n).to_string())
.build(ir(n))
}
fn repo_with(issues: Vec<Issue>) -> FakeIssueRepository {
let mut r = FakeIssueRepository::new();
for i in issues {
r.push_issue(i);
}
r
}
#[test]
fn no_issues_yields_no_results() {
let repo = FakeIssueRepository::new();
let content = StubContentReader::default();
let results = check_issues_content_references(&repo, &content);
assert!(results.is_empty());
}
#[test]
fn issue_with_clean_refs_produces_no_result() {
let repo = repo_with(vec![issue(1)]);
let content = StubContentReader::default().with(
ir(1),
&["index", "plan"],
&[("index", &["plan"]), ("plan", &[])],
);
let results = check_issues_content_references(&repo, &content);
assert!(results.is_empty());
}
#[test]
fn broken_ref_yields_violation_anchored_on_issue_path() {
let repo = repo_with(vec![issue(1)]);
let content = StubContentReader::default().with(ir(1), &["index"], &[("index", &["plan"])]);
let results = check_issues_content_references(&repo, &content);
assert_eq!(results.len(), 1);
let r = &results[0];
assert_eq!(r.path, PathBuf::from("docs/issues/0001-issue/index.md"));
let v = r.violations.iter().next().unwrap();
assert_eq!(v.severity, Severity::Error);
match &v.kind {
CheckViolationKind::BrokenCompanionLink { from, to } => {
assert_eq!(from, "index");
assert_eq!(to, "plan");
}
other => panic!("unexpected violation kind: {other:?}"),
}
}
#[test]
fn issue_with_no_content_set_is_skipped_silently() {
let repo = repo_with(vec![issue(1), issue(2)]);
let content =
StubContentReader::default().with(ir(2), &["index"], &[("index", &["missing"])]);
let results = check_issues_content_references(&repo, &content);
assert_eq!(results.len(), 1);
assert_eq!(
results[0].path,
PathBuf::from("docs/issues/0002-issue/index.md")
);
}
}