cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
use crate::domain::model::issue::Issue;
use crate::domain::model::record_ref::IssueRef;

/// Detect whether adding a `parent-of` link from `source_id` to `target_ref`
/// would introduce a cycle in the parent-of graph.
///
/// Returns `Some(path)` — a human-readable cycle description such as
/// `"1 → 2 → 1"` — if a cycle would be created, or `None` if the graph
/// remains acyclic. Iterative DFS walking descendants of `target` via
/// each issue's `parent-of` links, looking for `source`.
pub fn detect_cycle(
    source_id: &IssueRef,
    target_ref: &IssueRef,
    all_issues: &[Issue],
) -> Option<String> {
    use std::collections::{HashMap, HashSet};

    let by_id: HashMap<&IssueRef, &Issue> = all_issues.iter().map(|i| (&i.id, i)).collect();

    let mut visited: HashSet<IssueRef> = HashSet::new();
    let mut stack: Vec<(IssueRef, Vec<IssueRef>)> =
        vec![(target_ref.clone(), vec![target_ref.clone()])];

    while let Some((current, path)) = stack.pop() {
        if &current == source_id {
            let path_str = path
                .iter()
                .map(|n| n.to_string())
                .collect::<Vec<_>>()
                .join("");
            return Some(path_str);
        }
        if !visited.insert(current.clone()) {
            continue;
        }
        let Some(issue) = by_id.get(&current) else {
            continue;
        };
        for child in issue.children() {
            let mut next_path = path.clone();
            next_path.push(child.clone());
            stack.push((child.clone(), next_path));
        }
    }

    None
}

/// Return the source id of the existing `parent-of` link pointing to
/// `target_ref` (excluding `source_id`), if any. Reads `target`'s own
/// `child-of` links — its parents from the inverse-encoding cascade.
pub fn would_introduce_second_parent(
    source_id: &IssueRef,
    target_ref: &IssueRef,
    all_issues: &[Issue],
) -> Option<IssueRef> {
    all_issues
        .iter()
        .find(|i| &i.id == target_ref)?
        .parents()
        .find(|&p| p != source_id)
        .cloned()
}

#[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 parent_link(target: &str) -> IssueLink {
        IssueLink {
            target: IssueRef::new(target).unwrap(),
            relationship: IssueRelationship::ParentOf,
        }
    }

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

    fn issue_with_parent_links(id: &str, targets: &[&str]) -> Issue {
        let mut issue = build(feature("Issue").with_id(id));
        for t in targets {
            issue.links.push(parent_link(t));
        }
        issue
    }

    fn issue_with_child_of(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
    }

    // ── detect_cycle ─────────────────────────────────────────────────────

    #[test]
    fn detect_cycle_returns_none_when_no_cycle() {
        let issues = vec![
            issue_with_parent_links("ISSUE-0001", &["ISSUE-0002"]),
            issue_with_parent_links("ISSUE-0002", &[]),
        ];
        let src = IssueRef::new("ISSUE-0001").unwrap();
        let tgt = IssueRef::new("ISSUE-0003").unwrap();
        assert!(detect_cycle(&src, &tgt, &issues).is_none());
    }

    #[test]
    fn detect_cycle_returns_path_for_direct_cycle() {
        let issues = vec![
            issue_with_parent_links("ISSUE-0001", &["ISSUE-0002"]),
            issue_with_parent_links("ISSUE-0002", &[]),
        ];
        let src = IssueRef::new("ISSUE-0002").unwrap();
        let tgt = IssueRef::new("ISSUE-0001").unwrap();
        assert!(detect_cycle(&src, &tgt, &issues).is_some());
    }

    #[test]
    fn detect_cycle_returns_path_for_indirect_cycle() {
        let issues = vec![
            issue_with_parent_links("ISSUE-0001", &["ISSUE-0002"]),
            issue_with_parent_links("ISSUE-0002", &["ISSUE-0003"]),
            issue_with_parent_links("ISSUE-0003", &[]),
        ];
        let src = IssueRef::new("ISSUE-0003").unwrap();
        let tgt = IssueRef::new("ISSUE-0001").unwrap();
        assert!(detect_cycle(&src, &tgt, &issues).is_some());
    }

    #[test]
    fn detect_cycle_catches_self_link() {
        let issues = vec![issue_with_parent_links("ISSUE-0001", &[])];
        let src = IssueRef::new("ISSUE-0001").unwrap();
        let tgt = IssueRef::new("ISSUE-0001").unwrap();
        assert!(detect_cycle(&src, &tgt, &issues).is_some());
    }

    #[test]
    fn detect_cycle_ignores_non_parent_of_links() {
        let mut a = build(feature("A").with_id("ISSUE-0001"));
        a.links.push(IssueLink {
            target: IssueRef::new("ISSUE-0002").unwrap(),
            relationship: IssueRelationship::BlockedBy,
        });
        let issues = vec![a, issue_with_parent_links("ISSUE-0002", &[])];
        let src = IssueRef::new("ISSUE-0002").unwrap();
        let tgt = IssueRef::new("ISSUE-0001").unwrap();
        assert!(detect_cycle(&src, &tgt, &issues).is_none());
    }

    // ── would_introduce_second_parent ───────────────────────────────────

    #[test]
    fn second_parent_detected_when_other_issue_already_parents_target() {
        let issues = vec![
            issue_with_parent_links("ISSUE-0001", &["ISSUE-0003"]),
            issue_with_parent_links("ISSUE-0002", &[]),
            issue_with_child_of("ISSUE-0003", &["ISSUE-0001"]),
        ];
        let src = IssueRef::new("ISSUE-0002").unwrap();
        let tgt = IssueRef::new("ISSUE-0003").unwrap();
        assert_eq!(
            would_introduce_second_parent(&src, &tgt, &issues),
            Some(IssueRef::new("ISSUE-0001").unwrap())
        );
    }

    #[test]
    fn no_second_parent_when_target_is_orphan() {
        let issues = vec![
            issue_with_parent_links("ISSUE-0001", &[]),
            issue_with_parent_links("ISSUE-0002", &[]),
        ];
        let src = IssueRef::new("ISSUE-0002").unwrap();
        let tgt = IssueRef::new("ISSUE-0003").unwrap();
        assert!(would_introduce_second_parent(&src, &tgt, &issues).is_none());
    }

    #[test]
    fn second_parent_check_excludes_self() {
        let issues = vec![issue_with_parent_links("ISSUE-0001", &["ISSUE-0002"])];
        let src = IssueRef::new("ISSUE-0001").unwrap();
        let tgt = IssueRef::new("ISSUE-0002").unwrap();
        assert!(would_introduce_second_parent(&src, &tgt, &issues).is_none());
    }
}