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::AgeBuckets;

/// Age metrics for non-terminal (open) issues.
pub(super) struct OpenAge {
    pub avg_age_days: Option<f64>,
    pub oldest_open_days: Option<u32>,
    pub newest_open_days: Option<u32>,
    pub created_last_7: u32,
    pub created_last_30: u32,
    pub created_last_90: u32,
    /// Raw per-issue ages in days — reused by `compute_age_buckets`.
    pub open_ages_days: Vec<i64>,
}

pub(super) fn compute_open_age(filtered: &IssueView<'_>, today: &IsoDate) -> OpenAge {
    let open_ages_days: Vec<i64> = filtered
        .iter()
        .filter(|i| !i.status.terminal)
        .map(|i| creation_date(i).duration_until(today).as_whole_days())
        .filter(|&d| d >= 0)
        .collect();

    let avg_age_days = if open_ages_days.is_empty() {
        None
    } else {
        Some(open_ages_days.iter().sum::<i64>() as f64 / open_ages_days.len() as f64)
    };
    let oldest_open_days = open_ages_days.iter().max().map(|&d| d as u32);
    let newest_open_days = open_ages_days.iter().min().map(|&d| d as u32);

    let all_ages_days: Vec<i64> = filtered
        .iter()
        .map(|i| creation_date(i).duration_until(today).as_whole_days())
        .filter(|&d| d >= 0)
        .collect();

    let created_last_7 = all_ages_days.iter().filter(|&&d| d <= 7).count() as u32;
    let created_last_30 = all_ages_days.iter().filter(|&&d| d <= 30).count() as u32;
    let created_last_90 = all_ages_days.iter().filter(|&&d| d <= 90).count() as u32;

    OpenAge {
        avg_age_days,
        oldest_open_days,
        newest_open_days,
        created_last_7,
        created_last_30,
        created_last_90,
        open_ages_days,
    }
}

pub(super) fn compute_age_buckets(open_ages_days: &[i64]) -> AgeBuckets {
    let mut b = AgeBuckets::default();
    for &age in open_ages_days {
        match age {
            0..=7 => b.days_0_7 += 1,
            8..=30 => b.days_8_30 += 1,
            31..=90 => b.days_31_90 += 1,
            _ => b.days_91_plus += 1,
        }
    }
    b
}

#[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 uses_created_event_date_when_present() {
        scenario("2026-03-12")
            .given(feature("Story").status("open").with_timestamped_event(
                "2026-03-02T00:00:00Z",
                "created",
                None,
                None,
            ))
            .when_open_age()
            .then_oldest_open_days(Some(10))
            .then_avg_age_days(10.0);
    }

    #[test]
    fn falls_back_to_date_field_when_no_created_event() {
        scenario("2026-03-12")
            .given(feature("Story").status("open").date("2026-03-07"))
            .when_open_age()
            .then_oldest_open_days(Some(5));
    }

    #[test]
    fn closed_issues_excluded_from_open_age() {
        scenario("2026-03-12")
            .given(feature("Closed").status("closed").date("2026-01-01"))
            .given(feature("Open").status("open").date("2026-03-07"))
            .when_open_age()
            .then_oldest_open_days(Some(5))
            .then_avg_age_days_less_than(10.0);
    }

    #[test]
    fn created_last_n_days_counts_correctly() {
        scenario("2026-03-12")
            .given(feature("T1").date("2026-03-12")) // 0d
            .given(feature("T2").date("2026-03-06")) // 6d
            .given(feature("T3").date("2026-02-10")) // ~30d
            .given(feature("T4").date("2025-12-03")) // >90d
            .when_open_age()
            .then_created_last_n(2, 3, 3);
    }

    #[test]
    fn age_buckets_distribute_correctly() {
        let open_ages = vec![2i64, 15, 60, 120];
        let buckets = compute_age_buckets(&open_ages);
        assert_eq!(buckets.days_0_7, 1);
        assert_eq!(buckets.days_8_30, 1);
        assert_eq!(buckets.days_31_90, 1);
        assert_eq!(buckets.days_91_plus, 1);
    }

    // ── 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_open_age(self) -> OpenAgeOutcome {
            let refs = IssueView::from_slice(&self.issues);
            let result = compute_open_age(&refs, &self.today);
            OpenAgeOutcome { result }
        }
    }

    struct OpenAgeOutcome {
        result: OpenAge,
    }

    impl OpenAgeOutcome {
        fn then_oldest_open_days(self, expected: Option<u32>) -> Self {
            assert_eq!(
                self.result.oldest_open_days, expected,
                "oldest_open_days mismatch"
            );
            self
        }

        fn then_avg_age_days(self, expected: f64) -> Self {
            assert_eq!(
                self.result.avg_age_days,
                Some(expected),
                "avg_age_days mismatch"
            );
            self
        }

        fn then_avg_age_days_less_than(self, bound: f64) -> Self {
            let avg = self
                .result
                .avg_age_days
                .expect("avg_age_days should be Some");
            assert!(avg < bound, "expected avg_age_days < {bound}, got {avg}");
            self
        }

        fn then_created_last_n(self, last7: u32, last30: u32, last90: u32) -> Self {
            assert_eq!(self.result.created_last_7, last7, "created_last_7 mismatch");
            assert_eq!(
                self.result.created_last_30, last30,
                "created_last_30 mismatch"
            );
            assert_eq!(
                self.result.created_last_90, last90,
                "created_last_90 mismatch"
            );
            self
        }
    }
}