use std::collections::{BTreeMap, HashMap, HashSet};
use crate::domain::model::issue::{Issue, IssueFilter};
use crate::domain::model::record_ref::IssueRef;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TreeNode {
pub issue: Issue,
pub matched: bool,
pub children: Vec<TreeNode>,
}
pub fn build_issue_tree(issues: &[Issue], filter: &IssueFilter<'_>) -> Vec<TreeNode> {
let by_id: HashMap<String, &Issue> = issues.iter().map(|i| (i.id.to_string(), i)).collect();
let parent_of: HashMap<String, String> = issues
.iter()
.filter_map(|child| {
let mut parents: Vec<&IssueRef> = child.parents().collect();
parents.sort();
parents
.first()
.map(|p| (child.id.to_string(), p.as_str().to_string()))
})
.collect();
let mut children_of: BTreeMap<String, Vec<String>> = BTreeMap::new();
for (child_id, parent_id) in &parent_of {
children_of
.entry(parent_id.clone())
.or_default()
.push(child_id.clone());
}
for ids in children_of.values_mut() {
ids.sort();
}
let matched: HashSet<String> = issues
.iter()
.filter(|i| i.matches(filter))
.map(|i| i.id.to_string())
.collect();
let mut keep: HashSet<String> = HashSet::new();
for id in matched.iter() {
keep.insert(id.clone());
let mut cursor = parent_of.get(id);
while let Some(p) = cursor {
if !keep.insert(p.clone()) {
break;
}
cursor = parent_of.get(p);
}
}
let mut roots: Vec<&Issue> = issues
.iter()
.filter(|i| !parent_of.contains_key(&i.id.to_string()))
.filter(|i| keep.contains(&i.id.to_string()))
.collect();
roots.sort_by(|a, b| a.id.cmp(&b.id));
roots
.into_iter()
.map(|root| build_node(root, &by_id, &children_of, &keep, &matched))
.collect()
}
fn build_node(
issue: &Issue,
by_id: &HashMap<String, &Issue>,
children_of: &BTreeMap<String, Vec<String>>,
keep: &HashSet<String>,
matched: &HashSet<String>,
) -> TreeNode {
let id_str = issue.id.to_string();
let children = children_of
.get(&id_str)
.map(|ids| {
ids.iter()
.filter(|cid| keep.contains(*cid))
.filter_map(|cid| by_id.get(cid).copied())
.map(|child| build_node(child, by_id, children_of, keep, matched))
.collect()
})
.unwrap_or_default();
TreeNode {
issue: issue.clone(),
matched: matched.contains(&id_str),
children,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::model::issue::{IssueLinks, IssueRelationship};
use crate::domain::model::status::Status;
use crate::domain::usecases::issue::tests::{defect, feature, ir, IssueFixture};
fn parent_link(child_num: u64) -> crate::domain::model::issue::IssueLink {
let target =
crate::domain::model::record_ref::IssueRef::new(format!("ISSUE-{child_num:04}"))
.unwrap();
crate::domain::model::issue::IssueLink {
target,
relationship: IssueRelationship::ParentOf,
}
}
fn issue_with_children(fix: IssueFixture, id: u64, children: &[u64]) -> Issue {
let mut links = IssueLinks::new();
for c in children {
links.push(parent_link(*c));
}
fix.build(ir(id)).with_links(links)
}
fn link_inverses(mut issues: Vec<Issue>) -> Vec<Issue> {
let pairs: Vec<(IssueRef, IssueRef)> = issues
.iter()
.flat_map(|p| {
let pid = p.id.clone();
p.links
.iter()
.filter(|l| l.relationship == IssueRelationship::ParentOf)
.map(move |l| (pid.clone(), l.target.clone()))
.collect::<Vec<_>>()
})
.collect();
for (parent_id, child_id) in pairs {
if let Some(child) = issues.iter_mut().find(|i| i.id == child_id) {
child.links.push(crate::domain::model::issue::IssueLink {
target: parent_id,
relationship: IssueRelationship::ChildOf,
});
}
}
issues
}
#[test]
fn empty_input_yields_empty_forest() {
let forest = build_issue_tree(&[], &IssueFilter::default());
assert!(forest.is_empty());
}
#[test]
fn standalone_issues_appear_as_roots() {
let issues = vec![feature("A").build(ir(1)), defect("B").build(ir(2))];
let forest = build_issue_tree(&issues, &IssueFilter::default());
assert_eq!(forest.len(), 2);
assert!(forest.iter().all(|n| n.children.is_empty()));
assert!(forest.iter().all(|n| n.matched));
}
#[test]
fn parent_of_link_creates_a_child_node() {
let issues = link_inverses(vec![
issue_with_children(feature("Parent"), 1, &[2]),
feature("Child").build(ir(2)),
]);
let forest = build_issue_tree(&issues, &IssueFilter::default());
assert_eq!(forest.len(), 1);
assert_eq!(forest[0].issue.id.as_str(), "ISSUE-0001");
assert_eq!(forest[0].children.len(), 1);
assert_eq!(forest[0].children[0].issue.id.as_str(), "ISSUE-0002");
}
#[test]
fn children_are_sorted_by_id() {
let issues = link_inverses(vec![
issue_with_children(feature("Parent"), 1, &[3, 2]),
feature("B").build(ir(2)),
feature("C").build(ir(3)),
]);
let forest = build_issue_tree(&issues, &IssueFilter::default());
let ids: Vec<&str> = forest[0]
.children
.iter()
.map(|n| n.issue.id.as_str())
.collect();
assert_eq!(ids, vec!["ISSUE-0002", "ISSUE-0003"]);
}
#[test]
fn filter_keeps_unmatched_ancestor_when_descendant_matches() {
let issues = link_inverses(vec![
issue_with_children(feature("Closed parent").status("closed"), 1, &[2]),
feature("Open child").status("open").build(ir(2)),
]);
let open = Status::new("open").unwrap();
let filter = IssueFilter {
status: Some(&open),
..Default::default()
};
let forest = build_issue_tree(&issues, &filter);
assert_eq!(forest.len(), 1);
let parent_node = &forest[0];
assert_eq!(parent_node.issue.id.as_str(), "ISSUE-0001");
assert!(!parent_node.matched, "parent should be kept as a bridge");
assert_eq!(parent_node.children.len(), 1);
assert!(parent_node.children[0].matched);
}
#[test]
fn active_filter_keeps_inactive_ancestor_when_descendant_is_active() {
let issues = link_inverses(vec![
issue_with_children(feature("Closed parent").status("closed"), 1, &[2]),
feature("Open child").status("open").build(ir(2)),
]);
let cfg = crate::domain::model::status::StatusesConfig::default_issue();
let mut enriched = issues.clone();
for i in &mut enriched {
crate::domain::usecases::issue::tests::enrich_issue(i, &cfg);
}
let filter = IssueFilter {
active: true,
..Default::default()
};
let forest = build_issue_tree(&enriched, &filter);
assert_eq!(forest.len(), 1);
let parent_node = &forest[0];
assert_eq!(parent_node.issue.id.as_str(), "ISSUE-0001");
assert!(!parent_node.matched, "inactive parent kept as a bridge");
assert_eq!(parent_node.children.len(), 1);
assert!(parent_node.children[0].matched);
}
#[test]
fn filter_drops_subtree_when_no_descendant_matches() {
let issues = link_inverses(vec![
issue_with_children(feature("Closed parent").status("closed"), 1, &[2]),
feature("Closed child").status("closed").build(ir(2)),
feature("Open standalone").status("open").build(ir(3)),
]);
let open = Status::new("open").unwrap();
let filter = IssueFilter {
status: Some(&open),
..Default::default()
};
let forest = build_issue_tree(&issues, &filter);
assert_eq!(forest.len(), 1);
assert_eq!(forest[0].issue.id.as_str(), "ISSUE-0003");
}
#[test]
fn deeply_nested_chain_is_preserved() {
let issues = link_inverses(vec![
issue_with_children(feature("L1"), 1, &[2]),
issue_with_children(feature("L2"), 2, &[3]),
issue_with_children(feature("L3"), 3, &[4]),
feature("Leaf").build(ir(4)),
]);
let forest = build_issue_tree(&issues, &IssueFilter::default());
assert_eq!(forest.len(), 1);
let l1 = &forest[0];
let l2 = &l1.children[0];
let l3 = &l2.children[0];
let leaf = &l3.children[0];
assert_eq!(leaf.issue.id.as_str(), "ISSUE-0004");
assert!(leaf.children.is_empty());
}
#[test]
fn multi_parent_violation_picks_smallest_parent_id() {
let issues = link_inverses(vec![
issue_with_children(feature("Parent A"), 1, &[3]),
issue_with_children(feature("Parent B"), 2, &[3]),
feature("Disputed").build(ir(3)),
]);
let forest = build_issue_tree(&issues, &IssueFilter::default());
let parent_a = forest
.iter()
.find(|n| n.issue.id.as_str() == "ISSUE-0001")
.unwrap();
let parent_b = forest
.iter()
.find(|n| n.issue.id.as_str() == "ISSUE-0002")
.unwrap();
assert_eq!(parent_a.children.len(), 1);
assert!(parent_b.children.is_empty());
}
}