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, percentiles};
use super::Percentiles;

pub(super) fn compute_cycle_time(
    filtered: &IssueView<'_>,
    statuses: &StatusesConfig,
) -> Option<Percentiles> {
    let mut ct_vec: 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();
    ct_vec.sort_by(|a, b| a.total_cmp(b));
    percentiles(&ct_vec)
}

#[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_first_ongoing_transition_as_start() {
        // created 2026-03-01, started (in-progress) 2026-03-05, closed 2026-03-09
        // cycle time = 4d, lead time = 8d
        scenario()
            .given(
                feature("Story")
                    .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("in-progress"),
                    )
                    .with_timestamped_event(
                        "2026-03-09T00:00:00Z",
                        "status_changed",
                        Some("in-progress"),
                        Some("closed"),
                    ),
            )
            .when_cycle_time()
            .then_p50(4.0);
    }

    #[test]
    fn falls_back_to_creation_when_no_ongoing_transition() {
        // No Ongoing transition → cycle time == lead time (8d)
        scenario()
            .with_default_statuses()
            .given(
                feature("Task")
                    .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"),
                    ),
            )
            .when_cycle_time()
            .then_p50(8.0);
    }

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

    struct Scenario {
        issues: Vec<Issue>,
    }

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

    impl Scenario {
        fn with_default_statuses(self) -> Self {
            self
        }

        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_cycle_time(self) -> CycleTimeOutcome {
            let refs = IssueView::from_slice(&self.issues);
            let result = compute_cycle_time(&refs, &StatusesConfig::default_issue());
            CycleTimeOutcome { result }
        }
    }

    struct CycleTimeOutcome {
        result: Option<Percentiles>,
    }

    impl CycleTimeOutcome {
        fn then_p50(self, expected: f64) -> Self {
            let p = self.result.as_ref().expect("expected Some percentiles");
            assert_eq!(p.p50, expected, "p50 mismatch");
            self
        }
    }
}