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

use super::helpers::creation_date;
use super::MonthCount;

pub(super) fn compute_by_month(filtered: &IssueView<'_>, today: &IsoDate) -> Vec<MonthCount> {
    let mut month_labels: Vec<String> = (0..6)
        .rev()
        .map(|offset| today.minus_months(offset).year_month_label())
        .collect();
    month_labels.sort();

    let mut counts: std::collections::HashMap<String, u32> =
        month_labels.iter().map(|l| (l.clone(), 0)).collect();
    for issue in filtered {
        let created = creation_date(issue);
        let ym: String = created.as_str().chars().take(7).collect();
        if counts.contains_key(&ym) {
            *counts.entry(ym).or_insert(0) += 1;
        }
    }
    month_labels
        .into_iter()
        .map(|m| MonthCount {
            count: counts[&m],
            month: m,
        })
        .collect()
}

#[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::tests::{feature, ir, IssueFixture};

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

    #[test]
    fn returns_six_months_oldest_first() {
        scenario("2026-03-12")
            .when_by_month()
            .then_len(6)
            .then_month_at(0, "2025-10")
            .then_month_at(5, "2026-03");
    }

    #[test]
    fn counts_issues_in_correct_month() {
        scenario("2026-03-12")
            .given(feature("Story").date("2026-02-15"))
            .when_by_month()
            .then_month_count("2026-02", 1)
            .then_month_count("2026-03", 0);
    }

    #[test]
    fn issues_outside_window_are_not_counted() {
        scenario("2026-03-12")
            .given(feature("Old").date("2024-01-01"))
            .when_by_month()
            .then_total(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;
            self.issues.push(fixture.build(ir(id)));
            self
        }

        fn when_by_month(self) -> ByMonthOutcome {
            let refs = IssueView::from_slice(&self.issues);
            let result = compute_by_month(&refs, &self.today);
            ByMonthOutcome { result }
        }
    }

    struct ByMonthOutcome {
        result: Vec<MonthCount>,
    }

    impl ByMonthOutcome {
        fn then_len(self, expected: usize) -> Self {
            assert_eq!(self.result.len(), expected, "month count mismatch");
            self
        }

        fn then_month_at(self, idx: usize, expected: &str) -> Self {
            assert_eq!(
                self.result[idx].month, expected,
                "month at index {idx} mismatch"
            );
            self
        }

        fn then_month_count(self, month: &str, expected: u32) -> Self {
            let count = self
                .result
                .iter()
                .find(|m| m.month == month)
                .map(|m| m.count);
            assert_eq!(count, Some(expected), "count for month {month:?} mismatch");
            self
        }

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