cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! `cartu issue tree` — build a parent/child tree view of the workspace.
//!
//! Pure: takes the issue corpus and a filter, returns a forest of
//! [`TreeNode`]s. The shell (CLI / site) is responsible for rendering.
//!
//! Filtering is *structural*: an ancestor whose own attributes do not
//! match the filter is still kept when at least one descendant matches,
//! so the rendered tree never has dangling children. The `matched` flag
//! on each node distinguishes "matches the filter" from "kept as a
//! bridge to a matched descendant", letting the renderer dim bridges.

use std::collections::{BTreeMap, HashMap, HashSet};

use crate::domain::model::issue::{Issue, IssueFilter};
use crate::domain::model::record_ref::IssueRef;

/// One node of the rendered tree. Children come pre-sorted by numeric id.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TreeNode {
    pub issue: Issue,
    /// `true` if this issue itself satisfies the filter; `false` if it
    /// only appears because a descendant does.
    pub matched: bool,
    pub children: Vec<TreeNode>,
}

/// Build the parent/child forest for `issues` filtered by `filter`.
///
/// Roots are issues that are nobody's child. Standalones (no parent,
/// no children) appear in the forest at root level when they match the
/// filter, alongside hierarchical roots.
pub fn build_issue_tree(issues: &[Issue], filter: &IssueFilter<'_>) -> Vec<TreeNode> {
    // Indexes the corpus by id.
    let by_id: HashMap<String, &Issue> = issues.iter().map(|i| (i.id.to_string(), i)).collect();

    // child id → parent id. Each issue names its parent locally via
    // `child-of`. On multi-parent invariant violation (caught by
    // `cartu check`), the smallest parent id wins so the rendered
    // tree stays deterministic.
    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();

    // parent id → sorted children ids, derived from parent_of so a
    // child appears under exactly one parent.
    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();
    }

    // First pass: mark issues that match the filter.
    let matched: HashSet<String> = issues
        .iter()
        .filter(|i| i.matches(filter))
        .map(|i| i.id.to_string())
        .collect();

    // Second pass: mark issues that should appear (matched themselves or
    // have a matched descendant).
    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)
    }

    /// Reflect every `parent-of` edge as a matching `child-of` on the
    /// target, mirroring the mutual-inverse encoding the cascade applies
    /// in production. Tests express edges from the parent side; this
    /// helper adds the child side.
    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() {
        // Reproduces the bug where `--active` pre-filtered the corpus
        // and orphaned active children of inactive parents.
        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());
    }
}