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::IssueView;
use crate::domain::model::status::StatusesConfig;

use super::helpers::{is_ongoing, is_terminal};
use super::{Distributions, HistoBucket};

const HISTO_BUCKETS: &[(&str, i64, i64)] = &[
    ("1 day", 0, 1),
    ("2-3 days", 2, 3),
    ("4-7 days", 4, 7),
    ("8-14 days", 8, 14),
    ("15-30 days", 15, 30),
    ("30+ days", 31, i64::MAX),
];

fn build_histogram(days: &[f64]) -> Vec<HistoBucket> {
    HISTO_BUCKETS
        .iter()
        .map(|&(label, lo, hi)| {
            let count = days
                .iter()
                .filter(|&&d| d as i64 >= lo && d as i64 <= hi)
                .count() as u32;
            HistoBucket { label, count }
        })
        .collect()
}

pub(super) fn compute_distributions(
    filtered: &IssueView<'_>,
    statuses: &StatusesConfig,
) -> Option<Distributions> {
    let lead_days: Vec<f64> = filtered
        .iter()
        .filter(|i| i.status.terminal)
        .filter_map(|i| {
            i.events
                .lead_time(&i.date, is_terminal(statuses))
                .map(|d| d.as_days())
        })
        .collect();

    let cycle_days: Vec<f64> = filtered
        .iter()
        .filter(|i| i.status.terminal)
        .filter_map(|i| {
            i.events
                .cycle_time(&i.date, is_terminal(statuses), is_ongoing(statuses))
                .map(|d| d.as_days())
        })
        .collect();

    Some(Distributions {
        lead_time: build_histogram(&lead_days),
        cycle_time: build_histogram(&cycle_days),
    })
}

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

    // ── Tests ─────────────────────────────────────────────────────────────────

    #[test]
    fn places_5_day_lead_time_in_correct_bucket() {
        // created 2026-03-01, closed 2026-03-06 → 5 days → "4-7 days" bucket
        scenario()
            .given(
                feature("T1")
                    .status("closed")
                    .with_timestamped_event("2026-03-01T00:00:00Z", "created", None, None)
                    .with_timestamped_event(
                        "2026-03-06T00:00:00Z",
                        "status_changed",
                        Some("open"),
                        Some("closed"),
                    ),
            )
            .when_distributions()
            .then_lead_time_bucket("4-7 days", 1);
    }

    #[test]
    fn returns_zero_counts_when_no_closed_issues() {
        scenario()
            .given(feature("Open").status("open").date("2026-03-01"))
            .when_distributions()
            .then_lead_time_total(0);
    }

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

    struct Scenario {
        issues: Vec<Issue>,
    }

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

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

        fn when_distributions(self) -> DistributionsOutcome {
            let refs = IssueView::from_slice(&self.issues);
            let statuses = StatusesConfig::default_issue();
            let result =
                compute_distributions(&refs, &statuses).expect("distributions should be computed");
            DistributionsOutcome { result }
        }
    }

    struct DistributionsOutcome {
        result: Distributions,
    }

    impl DistributionsOutcome {
        fn then_lead_time_bucket(self, label: &str, expected: u32) -> Self {
            let count = self
                .result
                .lead_time
                .iter()
                .find(|b| b.label == label)
                .map(|b| b.count);
            assert_eq!(count, Some(expected), "lead_time bucket {label:?} mismatch");
            self
        }

        fn then_lead_time_total(self, expected: u32) -> Self {
            let total: u32 = self.result.lead_time.iter().map(|b| b.count).sum();
            assert_eq!(total, expected, "lead_time total mismatch");
            self
        }
    }
}