use std::collections::HashMap;
use std::path::PathBuf;
use crate::domain::model::check::{CheckViolationKind, Severity};
use crate::domain::model::issue::{Issue, IssueEdit, IssueLink, IssueRelationship};
use crate::domain::model::record_ref::IssueRef;
use crate::domain::usecases::check::{CheckViolation, IssueCheckCtx, IssueFinding, IssueRule};
pub struct SymmetricLinksRule;
pub const RULE_ID: &str = "issue/symmetric-links";
impl IssueRule for SymmetricLinksRule {
fn id(&self) -> &'static str {
RULE_ID
}
fn find(&self, ctx: &IssueCheckCtx<'_>) -> anyhow::Result<Vec<IssueFinding>> {
Ok(enumerate(ctx.issues)
.into_iter()
.map(MissingInverse::into_finding)
.collect())
}
}
struct MissingInverse {
path: PathBuf,
kind: CheckViolationKind,
repair: Option<Repair>,
}
struct Repair {
target_id: IssueRef,
source_id: IssueRef,
relationship: IssueRelationship,
}
impl MissingInverse {
fn into_finding(self) -> IssueFinding {
let fix = self.repair.map(|r| IssueEdit::AddLink {
issue: r.target_id,
link: IssueLink {
target: r.source_id,
relationship: r.relationship,
},
});
IssueFinding {
violation: CheckViolation {
rule_id: RULE_ID,
path: self.path,
severity: Severity::Error,
kind: self.kind,
},
fix,
}
}
}
fn enumerate(issues: &[(PathBuf, Issue)]) -> Vec<MissingInverse> {
let path_of: HashMap<&IssueRef, &PathBuf> = issues.iter().map(|(p, i)| (&i.id, p)).collect();
let by_id: HashMap<&IssueRef, &Issue> = issues.iter().map(|(_, i)| (&i.id, i)).collect();
let mut out = Vec::new();
for (_, source) in issues {
for link in source.links.iter() {
let expected_inverse = link.relationship.inverse();
let Some(target) = by_id.get(&link.target) else {
continue;
};
let has_back = target
.links
.iter()
.any(|l| l.relationship == expected_inverse && l.target == source.id);
if has_back {
continue;
}
let Some(p) = path_of.get(&link.target) else {
continue;
};
out.push(MissingInverse {
path: (*p).clone(),
kind: CheckViolationKind::MissingBackPointer {
source: source.id.as_entity_ref().clone(),
forward: link.relationship.as_str().to_string(),
back: expected_inverse.as_str().to_string(),
},
repair: Some(Repair {
target_id: target.id.clone(),
source_id: source.id.clone(),
relationship: expected_inverse,
}),
});
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::model::entity_ref::KnownRefs;
use crate::domain::model::status::StatusesConfig;
use crate::domain::model::tag_descriptor::TagDescriptors;
use crate::domain::usecases::issue::tests::{feature, FakeIssueRepository, IssueFixture};
use crate::domain::usecases::issue::IssueRepository;
fn repo(fixtures: Vec<IssueFixture>) -> FakeIssueRepository {
let mut r = FakeIssueRepository::new();
for fx in fixtures {
let raw = fx.id.as_deref().expect("with_id required").to_string();
let numeric = IssueRef::new(&raw).unwrap();
r.push_issue(fx.build(numeric));
}
r
}
struct TestCtx {
statuses: StatusesConfig,
tags: TagDescriptors,
known: KnownRefs,
issues: Vec<(PathBuf, Issue)>,
}
impl TestCtx {
fn from(repo: &FakeIssueRepository) -> Self {
let issues = repo
.list()
.unwrap()
.into_iter()
.map(|i| {
let path = std::path::PathBuf::from(i.location.as_str());
(path, i)
})
.collect();
Self {
statuses: StatusesConfig::default_issue(),
tags: TagDescriptors::default(),
known: KnownRefs::new(),
issues,
}
}
fn ctx<'a>(&'a self, repo: &'a FakeIssueRepository) -> IssueCheckCtx<'a> {
IssueCheckCtx {
repo,
issues: &self.issues,
known_refs: &self.known,
statuses: &self.statuses,
tag_descriptors: &self.tags,
}
}
}
#[test]
fn symmetric_pair_produces_no_finding() {
let repo = repo(vec![
feature("A")
.with_id("ISSUE-0001")
.with_link("ISSUE-0002", "blocked-by"),
feature("B")
.with_id("ISSUE-0002")
.with_link("ISSUE-0001", "blocks"),
]);
let tc = TestCtx::from(&repo);
assert!(SymmetricLinksRule.find(&tc.ctx(&repo)).unwrap().is_empty());
}
#[test]
fn one_sided_link_yields_finding_with_fix() {
let repo = repo(vec![
feature("A")
.with_id("ISSUE-0001")
.with_link("ISSUE-0002", "blocked-by"),
feature("B").with_id("ISSUE-0002"),
]);
let tc = TestCtx::from(&repo);
let findings = SymmetricLinksRule.find(&tc.ctx(&repo)).unwrap();
assert_eq!(findings.len(), 1);
assert!(findings[0].fix.is_some());
}
#[test]
fn finding_carries_the_missing_back_pointer_edit() {
let repo = repo(vec![
feature("A")
.with_id("ISSUE-0001")
.with_link("ISSUE-0002", "parent-of"),
feature("B").with_id("ISSUE-0002"),
]);
let tc = TestCtx::from(&repo);
let findings = SymmetricLinksRule.find(&tc.ctx(&repo)).unwrap();
assert_eq!(findings.len(), 1);
let Some(IssueEdit::AddLink { issue, link }) = &findings[0].fix else {
panic!("expected AddLink fix, got {:?}", findings[0].fix);
};
assert_eq!(issue.as_str(), "ISSUE-0002");
assert_eq!(link.relationship, IssueRelationship::ChildOf);
assert_eq!(link.target.as_str(), "ISSUE-0001");
}
#[test]
fn finding_is_empty_on_symmetric_workspace() {
let repo = repo(vec![
feature("A")
.with_id("ISSUE-0001")
.with_link("ISSUE-0002", "blocked-by"),
feature("B")
.with_id("ISSUE-0002")
.with_link("ISSUE-0001", "blocks"),
]);
let tc = TestCtx::from(&repo);
let findings = SymmetricLinksRule.find(&tc.ctx(&repo)).unwrap();
assert!(findings.is_empty());
}
}