cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
use crate::domain::model::issue::{Issue, IssueView};
use crate::domain::model::status::StatusesConfig;
use crate::domain::model::temporal::iso_date::IsoDate;
use crate::domain::usecases::issue::filter::IssueFilter;

use super::by_month::compute_by_month;
use super::cadence::compute_cadence;
use super::cycle_time::compute_cycle_time;
use super::distributions::compute_distributions;
use super::flow_efficiency::compute_flow_efficiency;
use super::flow_load::compute_flow_load;
use super::lead_time::compute_lead_time;
use super::open_age::{compute_age_buckets, compute_open_age};
use super::overview::compute_overview;
use super::queue_time::compute_queue_time;
use super::throughput::compute_throughput;
use super::types::{AgeBuckets, IssueStats};

pub fn compute_issue_stats(
    issues: &[Issue],
    statuses: &StatusesConfig,
    filter: &IssueFilter<'_>,
    today: &IsoDate,
    detailed: bool,
    weeks: u32,
    stale_after: u32,
) -> IssueStats {
    let view = IssueView::from_slice(issues).matching(filter);

    let ov = compute_overview(&view);
    let age = compute_open_age(&view, today);
    let lead_time = compute_lead_time(&view, statuses);
    let flow_load = compute_flow_load(
        &view,
        statuses,
        today,
        lead_time.as_ref().map(|lt| lt.p85),
        stale_after,
        detailed,
    );
    let (throughput, throughput_stability_pct) = compute_throughput(&view, statuses, today, weeks);

    let (
        cycle_time,
        queue_time,
        flow_efficiency_pct,
        age_buckets,
        by_month,
        cadence,
        distributions,
    ) = if detailed {
        (
            compute_cycle_time(&view, statuses),
            compute_queue_time(&view, statuses),
            compute_flow_efficiency(&view, statuses),
            compute_age_buckets(&age.open_ages_days),
            compute_by_month(&view, today),
            compute_cadence(&view, statuses, today, weeks),
            compute_distributions(&view, statuses),
        )
    } else {
        (None, None, None, AgeBuckets::default(), vec![], None, None)
    };

    IssueStats {
        weeks,
        total: ov.total,
        by_status: ov.by_status,
        avg_age_days: age.avg_age_days,
        oldest_open_days: age.oldest_open_days,
        newest_open_days: age.newest_open_days,
        created_last_7: age.created_last_7,
        created_last_30: age.created_last_30,
        created_last_90: age.created_last_90,
        flow_load,
        lead_time,
        throughput_stability_pct,
        cycle_time,
        queue_time,
        flow_efficiency_pct,
        throughput,
        age_buckets,
        by_month,
        cadence,
        distributions,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::model::issue::Issue;
    use crate::domain::model::temporal::iso_date::IsoDate;

    use crate::domain::usecases::issue::stats::types::AgeBuckets;
    use crate::domain::usecases::issue::tests::{defect, feature, ir, IssueFixture};

    #[test]
    fn filters_by_status_before_computing() {
        scenario()
            .given(feature("Open").status("open"))
            .given(defect("Closed").status("closed"))
            .when_stats_with_status_filter("open")
            .then_total(1);
    }

    #[test]
    fn detailed_false_leaves_detail_fields_empty() {
        scenario()
            .given(
                feature("T1")
                    .status("closed")
                    .with_timestamped_event("2026-03-01T00:00:00Z", "created", None, None)
                    .with_timestamped_event(
                        "2026-03-05T00:00:00Z",
                        "status_changed",
                        Some("open"),
                        Some("closed"),
                    ),
            )
            .when_stats(false)
            .then_detail_fields_empty();
    }

    #[test]
    fn detailed_true_populates_detail_fields() {
        scenario()
            .given(
                feature("T1")
                    .status("closed")
                    .with_timestamped_event("2026-03-01T00:00:00Z", "created", None, None)
                    .with_timestamped_event(
                        "2026-03-05T00:00:00Z",
                        "status_changed",
                        Some("open"),
                        Some("closed"),
                    ),
            )
            .when_stats(true)
            .then_detail_fields_populated();
    }

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

    struct Scenario {
        issues: Vec<Issue>,
    }

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

    fn today() -> IsoDate {
        IsoDate::new("2026-03-12").unwrap()
    }

    impl Scenario {
        fn given(mut self, fixture: IssueFixture) -> Self {
            let id = self.issues.len() as u64 + 1;
            self.issues.push(fixture.build(ir(id)));
            self
        }

        fn when_stats(self, detailed: bool) -> StatsOutcome {
            let result = compute_issue_stats(
                &self.issues,
                &StatusesConfig::default_issue(),
                &IssueFilter::default(),
                &today(),
                detailed,
                8,
                3,
            );
            StatsOutcome { result }
        }

        fn when_stats_with_status_filter(self, status: &str) -> StatsOutcome {
            use crate::domain::model::status::Status;
            let st = Status::new(status).unwrap();
            let result = compute_issue_stats(
                &self.issues,
                &StatusesConfig::default_issue(),
                &IssueFilter {
                    status: Some(&st),
                    ..Default::default()
                },
                &today(),
                false,
                8,
                3,
            );
            StatsOutcome { result }
        }
    }

    struct StatsOutcome {
        result: super::IssueStats,
    }

    impl StatsOutcome {
        fn then_total(self, expected: u32) -> Self {
            assert_eq!(self.result.total, expected, "total mismatch");
            self
        }

        fn then_detail_fields_empty(self) -> Self {
            assert!(self.result.cycle_time.is_none());
            assert!(self.result.flow_efficiency_pct.is_none());
            assert_eq!(self.result.age_buckets, AgeBuckets::default());
            assert!(self.result.by_month.is_empty());
            assert!(self.result.cadence.is_none());
            assert!(self.result.distributions.is_none());
            self
        }

        fn then_detail_fields_populated(self) -> Self {
            assert!(self.result.cadence.is_some());
            assert!(self.result.distributions.is_some());
            self
        }
    }
}