cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! Enforce that each issue has at most one parent.

use crate::domain::model::check::{CheckViolationKind, Severity};
use crate::domain::model::issue::Issue;
use crate::domain::model::record_ref::IssueRef;
use crate::domain::usecases::check::{CheckViolation, IssueCheckCtx, IssueFinding, IssueRule};

pub struct MultiParentRule;

pub const RULE_ID: &str = "issue/multi-parent";

impl IssueRule for MultiParentRule {
    fn id(&self) -> &'static str {
        RULE_ID
    }

    fn find(&self, ctx: &IssueCheckCtx<'_>) -> anyhow::Result<Vec<IssueFinding>> {
        let issues: Vec<Issue> = ctx.issues.iter().map(|(_, i)| i.clone()).collect();
        let mut out = Vec::new();
        for (child, parents) in find_violations(&issues) {
            let kind = CheckViolationKind::MultipleParents {
                child: child.as_entity_ref().clone(),
                parents: parents.iter().map(|p| p.as_entity_ref().clone()).collect(),
            };
            for parent in &parents {
                if let Some(path) = ctx.path_of(parent) {
                    out.push(CheckViolation {
                        rule_id: RULE_ID,
                        path: path.to_path_buf(),
                        severity: Severity::Error,
                        kind: kind.clone(),
                    });
                }
            }
        }
        Ok(out.into_iter().map(IssueFinding::report).collect())
    }
}

fn find_violations(issues: &[Issue]) -> Vec<(IssueRef, Vec<IssueRef>)> {
    let mut out: Vec<(IssueRef, Vec<IssueRef>)> = issues
        .iter()
        .filter_map(|child| {
            let mut parents: Vec<IssueRef> = child.parents().cloned().collect();
            if parents.len() > 1 {
                parents.sort();
                Some((child.id.clone(), parents))
            } else {
                None
            }
        })
        .collect();
    out.sort_by(|(a, _), (b, _)| a.cmp(b));
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::model::issue::{IssueLink, IssueRelationship};
    use crate::domain::usecases::issue::tests::{feature, IssueFixture};

    fn build(fix: IssueFixture) -> Issue {
        let raw = fix.id.as_deref().expect("id required").to_string();
        let numeric = IssueRef::new(&raw).unwrap();
        fix.build(numeric)
    }

    fn child_of_link(target: &str) -> IssueLink {
        IssueLink {
            target: IssueRef::new(target).unwrap(),
            relationship: IssueRelationship::ChildOf,
        }
    }

    fn issue_with_child_of_links(id: &str, parents: &[&str]) -> Issue {
        let mut issue = build(feature("Issue").with_id(id));
        for p in parents {
            issue.links.push(child_of_link(p));
        }
        issue
    }

    #[test]
    fn find_multi_parent_returns_targets_with_multiple_parents() {
        let issues = vec![
            issue_with_child_of_links("ISSUE-0001", &[]),
            issue_with_child_of_links("ISSUE-0002", &[]),
            issue_with_child_of_links("ISSUE-0003", &["ISSUE-0001", "ISSUE-0002"]),
        ];
        let v = find_violations(&issues);
        let summary: Vec<(&str, Vec<&str>)> = v
            .iter()
            .map(|(c, ps)| (c.as_str(), ps.iter().map(|p| p.as_str()).collect()))
            .collect();
        assert_eq!(
            summary,
            vec![("ISSUE-0003", vec!["ISSUE-0001", "ISSUE-0002"])]
        );
    }

    #[test]
    fn find_multi_parent_returns_empty_when_invariant_holds() {
        let issues = vec![
            issue_with_child_of_links("ISSUE-0003", &["ISSUE-0001"]),
            issue_with_child_of_links("ISSUE-0004", &["ISSUE-0002"]),
        ];
        assert!(find_violations(&issues).is_empty());
    }
}