cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! Decorate a [`build_issue_tree`] forest with per-node rollups.
//!
//! `cartu issue tree --with-rollup` wants two things on every node: the
//! status rollup (a histogram + a derived category) and the structured
//! tag rollups. Both already exist as use cases; this module composes
//! them so the CLI walks one typed tree instead of carrying a by-id
//! index and a `RollupCtx` glue struct.

use std::collections::BTreeMap;

use crate::domain::model::issue::{Issue, IssueFilter, IssueRelationship};
use crate::domain::model::status::RollupHistogram;
use crate::domain::model::tag_descriptor::TagDescriptors;

use super::rollup_status::{compute_status_rollup_via_map, index_issues_by_id};
use super::rollup_tags::{compute_tag_rollups, TagRollupValue};
use super::tree::{build_issue_tree, TreeNode};

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TreeRollup {
    pub status: Option<RollupHistogram>,
    pub tags: BTreeMap<String, TagRollupValue>,
}

impl TreeRollup {
    pub fn is_empty(&self) -> bool {
        self.status.is_none() && self.tags.is_empty()
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TreeViewNode {
    pub issue: Issue,
    pub matched: bool,
    pub rollup: Option<TreeRollup>,
    pub children: Vec<TreeViewNode>,
}

/// Build the forest and attach every node's rollup (when non-empty).
/// Nodes with no children or no tag-aggregate signal carry `None`.
pub fn build_issue_tree_view(
    issues: &[Issue],
    filter: &IssueFilter<'_>,
    descriptors: &TagDescriptors,
) -> Vec<TreeViewNode> {
    let forest = build_issue_tree(issues, filter);
    let by_id = index_issues_by_id(issues);
    forest
        .into_iter()
        .map(|n| decorate(n, &by_id, descriptors))
        .collect()
}

fn decorate(
    node: TreeNode,
    by_id: &std::collections::HashMap<crate::domain::model::record_ref::IssueRef, &Issue>,
    descriptors: &TagDescriptors,
) -> TreeViewNode {
    let status = compute_status_rollup_via_map(&node.issue, by_id);
    let direct_children: Vec<&Issue> = node
        .issue
        .links
        .iter()
        .filter(|l| l.relationship == IssueRelationship::ParentOf)
        .filter_map(|l| by_id.get(&l.target).copied())
        .collect();
    let tags = compute_tag_rollups(&direct_children, descriptors);
    let rollup = TreeRollup { status, tags };
    let rollup = if rollup.is_empty() {
        None
    } else {
        Some(rollup)
    };
    TreeViewNode {
        issue: node.issue,
        matched: node.matched,
        rollup,
        children: node
            .children
            .into_iter()
            .map(|c| decorate(c, by_id, descriptors))
            .collect(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::model::issue::test_fixtures::{feature, ir};
    use crate::domain::model::tag_descriptor::TagDescriptors;
    use crate::domain::usecases::issue::tests::enrich_issue;

    fn enriched(id: u64, title: &str) -> Issue {
        let mut i = feature(title).with_id(&ir(id).to_string()).build(ir(id));
        enrich_issue(
            &mut i,
            &crate::domain::model::status::StatusesConfig::default_issue(),
        );
        i
    }

    fn no_filter() -> IssueFilter<'static> {
        IssueFilter {
            status: None,
            active: false,
            tags: &[],
        }
    }

    #[test]
    fn empty_corpus_yields_empty_forest() {
        let view = build_issue_tree_view(&[], &no_filter(), &TagDescriptors::default());
        assert!(view.is_empty());
    }

    #[test]
    fn standalone_issue_has_no_rollup() {
        let view = build_issue_tree_view(
            &[enriched(1, "Solo")],
            &no_filter(),
            &TagDescriptors::default(),
        );
        assert_eq!(view.len(), 1);
        assert!(view[0].rollup.is_none());
        assert!(view[0].children.is_empty());
    }

    #[test]
    fn parent_with_children_carries_a_status_rollup() {
        let parent = feature("Parent")
            .with_id("ISSUE-0001")
            .with_link("ISSUE-0002", "parent-of")
            .build(ir(1));
        let mut parent = parent;
        enrich_issue(
            &mut parent,
            &crate::domain::model::status::StatusesConfig::default_issue(),
        );
        let child = feature("Child")
            .with_id("ISSUE-0002")
            .with_link("ISSUE-0001", "child-of")
            .build(ir(2));
        let mut child = child;
        enrich_issue(
            &mut child,
            &crate::domain::model::status::StatusesConfig::default_issue(),
        );
        let view =
            build_issue_tree_view(&[parent, child], &no_filter(), &TagDescriptors::default());
        let root = view.iter().find(|n| n.issue.id == ir(1)).unwrap();
        let rollup = root.rollup.as_ref().expect("parent should carry rollup");
        assert!(rollup.status.is_some());
        assert_eq!(root.children.len(), 1);
    }
}