use crate::domain::model::issue::Issue;
use crate::domain::model::record_ref::IssueRef;
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 ¤t == 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(¤t) 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
}
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
}
#[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());
}
#[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());
}
}