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_stalled};

pub(super) fn compute_flow_efficiency(
    filtered: &IssueView<'_>,
    statuses: &StatusesConfig,
) -> Option<f64> {
    let efficiencies: Vec<f64> = filtered
        .iter()
        .filter(|i| i.status.terminal)
        .filter_map(|i| {
            i.events
                .flow_efficiency_pct(is_ongoing(statuses), is_stalled(statuses))
        })
        .collect();
    if efficiencies.is_empty() {
        None
    } else {
        Some(efficiencies.iter().sum::<f64>() / efficiencies.len() as f64)
    }
}

#[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 computes_active_over_active_plus_stalled() {
        // Kanban preset: in-progress is Active, blocked is Stalled.
        // 2d queue (excluded), 4d active + 4d blocked + 4d active → 8/(8+4)=66.7%.
        // Queue time stays out of the ratio.
        scenario_kanban()
            .given(
                feature("Story")
                    .status("done")
                    .with_timestamped_event("2026-03-01T00:00:00Z", "created", None, None)
                    .with_timestamped_event(
                        "2026-03-03T00:00:00Z",
                        "status_changed",
                        Some("backlog"),
                        Some("in-progress"),
                    )
                    .with_timestamped_event(
                        "2026-03-07T00:00:00Z",
                        "status_changed",
                        Some("in-progress"),
                        Some("blocked"),
                    )
                    .with_timestamped_event(
                        "2026-03-11T00:00:00Z",
                        "status_changed",
                        Some("blocked"),
                        Some("in-progress"),
                    )
                    .with_timestamped_event(
                        "2026-03-15T00:00:00Z",
                        "status_changed",
                        Some("in-progress"),
                        Some("done"),
                    ),
            )
            .when_flow_efficiency()
            .then_approximately(66.7, 1.0);
    }

    #[test]
    fn returns_none_when_no_ongoing_transition() {
        // No Ongoing transition → active_duration returns None → flow efficiency None
        scenario()
            .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_flow_efficiency()
            .then_none();
    }

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

    struct Scenario {
        issues: Vec<Issue>,
        config: StatusesConfig,
    }

    fn scenario() -> Scenario {
        Scenario {
            issues: vec![],
            config: StatusesConfig::default_issue(),
        }
    }

    fn scenario_kanban() -> Scenario {
        Scenario {
            issues: vec![],
            config: StatusesConfig::preset_issue_kanban(),
        }
    }

    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, &self.config);
            self.issues.push(issue);
            self
        }

        fn when_flow_efficiency(self) -> FlowEfficiencyOutcome {
            let refs = IssueView::from_slice(&self.issues);
            let result = compute_flow_efficiency(&refs, &self.config);
            FlowEfficiencyOutcome { result }
        }
    }

    struct FlowEfficiencyOutcome {
        result: Option<f64>,
    }

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

        fn then_approximately(self, expected: f64, tolerance: f64) -> Self {
            let v = self.result.expect("expected Some");
            assert!(
                (v - expected).abs() < tolerance,
                "expected ~{expected}%, got {v:.1}%"
            );
            self
        }
    }
}