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

/// Queue time = creation date → first active transition: the
/// `Queued` window before any work began. Only issues that crossed
/// into `Active` contribute.
pub(super) fn compute_queue_time(
    filtered: &IssueView<'_>,
    statuses: &StatusesConfig,
) -> Option<Percentiles> {
    let mut q_vec: Vec<f64> = filtered
        .iter()
        .filter_map(|i| {
            i.events
                .queue_time(&i.date, is_ongoing(statuses))
                .map(|d| d.as_days())
        })
        .collect();
    q_vec.sort_by(|a, b| a.total_cmp(b));
    percentiles(&q_vec)
}

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

    #[test]
    fn measures_creation_to_first_active() {
        scenario()
            .given(
                feature("Story")
                    .status("in-progress")
                    .with_timestamped_event("2026-03-01T00:00:00Z", "created", None, None)
                    .with_timestamped_event(
                        "2026-03-04T00:00:00Z",
                        "status_changed",
                        Some("open"),
                        Some("in-progress"),
                    ),
            )
            .when_queue_time()
            .then_p50(3.0);
    }

    #[test]
    fn skips_issues_that_never_became_active() {
        scenario()
            .given(
                feature("Task")
                    .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("closed"),
                    ),
            )
            .when_queue_time()
            .then_none();
    }

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

    struct QueueTimeOutcome {
        result: Option<Percentiles>,
    }

    impl QueueTimeOutcome {
        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
        }

        fn then_none(self) -> Self {
            assert!(self.result.is_none(), "expected None");
            self
        }
    }
}