use crate::domain::model::issue::IssueView;
use crate::domain::model::status::StatusesConfig;
use super::helpers::{is_ongoing, is_terminal};
use super::{Distributions, HistoBucket};
const HISTO_BUCKETS: &[(&str, i64, i64)] = &[
("1 day", 0, 1),
("2-3 days", 2, 3),
("4-7 days", 4, 7),
("8-14 days", 8, 14),
("15-30 days", 15, 30),
("30+ days", 31, i64::MAX),
];
fn build_histogram(days: &[f64]) -> Vec<HistoBucket> {
HISTO_BUCKETS
.iter()
.map(|&(label, lo, hi)| {
let count = days
.iter()
.filter(|&&d| d as i64 >= lo && d as i64 <= hi)
.count() as u32;
HistoBucket { label, count }
})
.collect()
}
pub(super) fn compute_distributions(
filtered: &IssueView<'_>,
statuses: &StatusesConfig,
) -> Option<Distributions> {
let lead_days: Vec<f64> = filtered
.iter()
.filter(|i| i.status.terminal)
.filter_map(|i| {
i.events
.lead_time(&i.date, is_terminal(statuses))
.map(|d| d.as_days())
})
.collect();
let cycle_days: 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();
Some(Distributions {
lead_time: build_histogram(&lead_days),
cycle_time: build_histogram(&cycle_days),
})
}
#[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};
#[test]
fn places_5_day_lead_time_in_correct_bucket() {
scenario()
.given(
feature("T1")
.status("closed")
.with_timestamped_event("2026-03-01T00:00:00Z", "created", None, None)
.with_timestamped_event(
"2026-03-06T00:00:00Z",
"status_changed",
Some("open"),
Some("closed"),
),
)
.when_distributions()
.then_lead_time_bucket("4-7 days", 1);
}
#[test]
fn returns_zero_counts_when_no_closed_issues() {
scenario()
.given(feature("Open").status("open").date("2026-03-01"))
.when_distributions()
.then_lead_time_total(0);
}
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_distributions(self) -> DistributionsOutcome {
let refs = IssueView::from_slice(&self.issues);
let statuses = StatusesConfig::default_issue();
let result =
compute_distributions(&refs, &statuses).expect("distributions should be computed");
DistributionsOutcome { result }
}
}
struct DistributionsOutcome {
result: Distributions,
}
impl DistributionsOutcome {
fn then_lead_time_bucket(self, label: &str, expected: u32) -> Self {
let count = self
.result
.lead_time
.iter()
.find(|b| b.label == label)
.map(|b| b.count);
assert_eq!(count, Some(expected), "lead_time bucket {label:?} mismatch");
self
}
fn then_lead_time_total(self, expected: u32) -> Self {
let total: u32 = self.result.lead_time.iter().map(|b| b.count).sum();
assert_eq!(total, expected, "lead_time total mismatch");
self
}
}
}