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 crate::domain::model::temporal::iso_date::IsoDate;

use super::helpers::{close_date, coeff_of_variation};
use super::WeekCount;

pub(super) fn compute_throughput(
    filtered: &IssueView<'_>,
    statuses: &StatusesConfig,
    today: &IsoDate,
    weeks: u32,
) -> (Vec<WeekCount>, Option<f64>) {
    let mut week_labels: Vec<String> = (0..weeks)
        .rev()
        .map(|offset| today.minus_weeks(offset).iso_week_label())
        .collect();
    week_labels.dedup();

    let mut counts: std::collections::HashMap<String, u32> =
        week_labels.iter().map(|l| (l.clone(), 0)).collect();

    for issue in filtered.iter().filter(|i| i.status.terminal) {
        if let Some(closed) = close_date(issue, statuses) {
            let label = closed.iso_week_label();
            if counts.contains_key(&label) {
                *counts.entry(label).or_insert(0) += 1;
            }
        }
    }

    let week_counts: Vec<WeekCount> = week_labels
        .into_iter()
        .map(|w| WeekCount {
            count: counts[&w],
            week: w,
        })
        .collect();

    let stability = {
        let vals: Vec<f64> = week_counts.iter().map(|w| w.count as f64).collect();
        coeff_of_variation(&vals)
    };

    (week_counts, stability)
}

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

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

    #[test]
    fn counts_closed_issues_per_week() {
        scenario("2026-03-12")
            .given(
                feature("T1")
                    .status("closed")
                    .with_timestamped_event("2026-03-01T00:00:00Z", "created", None, None)
                    .with_timestamped_event(
                        "2026-03-09T00:00:00Z",
                        "status_changed",
                        Some("open"),
                        Some("closed"),
                    ),
            )
            .given(
                feature("T2")
                    .status("closed")
                    .with_timestamped_event("2026-03-01T00:00:00Z", "created", None, None)
                    .with_timestamped_event(
                        "2026-03-10T00:00:00Z",
                        "status_changed",
                        Some("open"),
                        Some("closed"),
                    ),
            )
            .when_throughput(8)
            .then_total_closed(2)
            .then_week_count("2026-W11", 2);
    }

    #[test]
    fn stability_is_none_for_single_week() {
        scenario("2026-03-12")
            .when_throughput(1)
            .then_stability_none();
    }

    #[test]
    fn zero_weeks_outside_window_do_not_affect_count() {
        scenario("2026-03-12")
            .given(
                feature("Old")
                    .status("closed")
                    .with_timestamped_event("2024-01-01T00:00:00Z", "created", None, None)
                    .with_timestamped_event(
                        "2024-01-10T00:00:00Z",
                        "status_changed",
                        Some("open"),
                        Some("closed"),
                    ),
            )
            .when_throughput(8)
            .then_total_closed(0);
    }

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

    struct Scenario {
        issues: Vec<Issue>,
        today: IsoDate,
    }

    fn scenario(today: &str) -> Scenario {
        Scenario {
            issues: vec![],
            today: IsoDate::new(today).unwrap(),
        }
    }

    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_throughput(self, weeks: u32) -> ThroughputOutcome {
            let refs = IssueView::from_slice(&self.issues);
            let statuses = StatusesConfig::default_issue();
            let (week_counts, stability) = compute_throughput(&refs, &statuses, &self.today, weeks);
            ThroughputOutcome {
                week_counts,
                stability,
            }
        }
    }

    struct ThroughputOutcome {
        week_counts: Vec<WeekCount>,
        stability: Option<f64>,
    }

    impl ThroughputOutcome {
        fn then_total_closed(self, expected: u32) -> Self {
            let total: u32 = self.week_counts.iter().map(|w| w.count).sum();
            assert_eq!(total, expected, "total closed mismatch");
            self
        }

        fn then_week_count(self, week: &str, expected: u32) -> Self {
            let count = self
                .week_counts
                .iter()
                .find(|w| w.week == week)
                .map(|w| w.count);
            assert_eq!(count, Some(expected), "week {week:?} count mismatch");
            self
        }

        fn then_stability_none(self) -> Self {
            assert!(self.stability.is_none(), "expected stability to be None");
            self
        }
    }
}