cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! Symmetric-links rule for issues. Mirrors the DR-side rule per
//! DDR-018QWJVHRH35B: every link mandates a matching inverse pointer on
//! the target (`blocks` ↔ `blocked-by`, `parent-of` ↔ `child-of`).

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
    }

    /// Test scaffolding: bundle a repo and pre-loaded issues with empty
    /// workspace config to produce an [`IssueCheckCtx`]. The rule under
    /// test ignores every field except `issues` / `paths`, but the
    /// trait signature still needs them.
    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());
    }
}