cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! Compute the derived status rollup of a composite issue from its
//! direct children. On-demand, `O(direct_children)`, not persisted.

use std::collections::HashMap;

use crate::domain::model::issue::{Issue, IssueRelationship};
use crate::domain::model::record_ref::IssueRef;
use crate::domain::model::status::RollupHistogram;

/// Compute the status rollup from a slice of direct-child issues.
/// Returns `None` for an empty slice; otherwise the five-bucket
/// histogram whose [`category`](RollupHistogram::category) is the
/// rollup `StatusCategory`.
pub fn compute_status_rollup(direct_children: &[&Issue]) -> Option<RollupHistogram> {
    if direct_children.is_empty() {
        return None;
    }
    Some(RollupHistogram::from_categories(
        direct_children.iter().map(|c| c.status.category),
    ))
}

/// Build a lookup map of issues by id, for batch child resolution.
pub fn index_issues_by_id(issues: &[Issue]) -> HashMap<IssueRef, &Issue> {
    issues.iter().map(|i| (i.id.clone(), i)).collect()
}

/// Compute the rollup of `issue` by resolving its `parent-of` links
/// through `by_id`. Dangling refs are dropped silently.
pub fn compute_status_rollup_via_map(
    issue: &Issue,
    by_id: &HashMap<IssueRef, &Issue>,
) -> Option<RollupHistogram> {
    let child_refs: Vec<&Issue> = issue
        .links
        .iter()
        .filter(|l| l.relationship == IssueRelationship::ParentOf)
        .filter_map(|l| by_id.get(&l.target).copied())
        .collect();
    compute_status_rollup(&child_refs)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::model::status::{StatusCategory, StatusesConfig};
    use crate::domain::usecases::issue::tests::{enrich_issue, feature, ir, IssueFixture};

    #[test]
    fn no_children_returns_none() {
        scenario().when_rollup().then_none();
    }

    #[test]
    fn single_child_carries_its_category() {
        scenario()
            .with_child(feature("Child").status("open"))
            .when_rollup()
            .then_histogram_total(1)
            .then_category(StatusCategory::Queued);
    }

    #[test]
    fn histogram_counts_each_category_bucket() {
        // Default issue preset: open=Queued, in-progress=Active, closed=Resolved.
        scenario()
            .with_child(feature("A").status("open"))
            .with_child(feature("B").status("open"))
            .with_child(feature("C").status("in-progress"))
            .with_child(feature("D").status("closed"))
            .with_child(feature("E").status("closed"))
            .when_rollup()
            .then_histogram_total(5)
            .then_queued(2)
            .then_active(1)
            .then_resolved(2)
            .then_category(StatusCategory::Active);
    }

    #[test]
    fn fully_resolved_with_one_unstarted_keeps_queued_signal() {
        // R R R Q → Q on the rollup chain (terminals are transparent
        // to non-terminals).
        scenario()
            .with_child(feature("A").status("closed"))
            .with_child(feature("B").status("closed"))
            .with_child(feature("C").status("closed"))
            .with_child(feature("D").status("open"))
            .when_rollup()
            .then_category(StatusCategory::Queued);
    }

    // ── Scenario DSL ──────────────────────────────────────────────────────

    struct Scenario {
        children: Vec<Issue>,
    }

    fn scenario() -> Scenario {
        Scenario { children: vec![] }
    }

    impl Scenario {
        fn with_child(mut self, fixture: IssueFixture) -> Self {
            let id = self.children.len() as u64 + 1;
            let mut child = fixture.build(ir(id));
            enrich_issue(&mut child, &StatusesConfig::default_issue());
            self.children.push(child);
            self
        }

        fn when_rollup(self) -> RollupOutcome {
            let refs: Vec<&Issue> = self.children.iter().collect();
            let result = compute_status_rollup(&refs);
            RollupOutcome { result }
        }
    }

    struct RollupOutcome {
        result: Option<RollupHistogram>,
    }

    impl RollupOutcome {
        fn then_none(self) -> Self {
            assert!(self.result.is_none(), "expected None");
            self
        }

        fn unwrap_some(&self) -> &RollupHistogram {
            self.result.as_ref().expect("expected Some(histogram)")
        }

        fn then_histogram_total(self, expected: u32) -> Self {
            assert_eq!(self.unwrap_some().total(), expected, "total mismatch");
            self
        }

        fn then_queued(self, expected: u32) -> Self {
            assert_eq!(self.unwrap_some().queued, expected, "queued mismatch");
            self
        }

        fn then_active(self, expected: u32) -> Self {
            assert_eq!(self.unwrap_some().active, expected, "active mismatch");
            self
        }

        fn then_resolved(self, expected: u32) -> Self {
            assert_eq!(self.unwrap_some().resolved, expected, "resolved mismatch");
            self
        }

        fn then_category(self, expected: StatusCategory) -> Self {
            assert_eq!(self.unwrap_some().category(), expected, "category mismatch");
            self
        }
    }
}