cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! Shared helpers used by multiple stats sub-computations.

use crate::domain::model::issue::Issue;
use crate::domain::model::status::{StatusCategory, StatusesConfig};
use crate::domain::model::temporal::iso_date::IsoDate;

// ── State classifiers ─────────────────────────────────────────────────────────

/// A predicate `Fn(&str) -> bool` that returns `true` if the named state is
/// terminal in the given `StatusesConfig`. Built once and cloned into the
/// `EventLog` helpers that need it.
pub(crate) fn is_terminal(statuses: &StatusesConfig) -> impl Fn(&str) -> bool + '_ {
    move |s| statuses.resolve(s).map(|st| st.terminal).unwrap_or(false)
}

/// A predicate `Fn(&str) -> bool` that returns `true` if the named state has
/// category `Active` in the given `StatusesConfig`.
pub(crate) fn is_ongoing(statuses: &StatusesConfig) -> impl Fn(&str) -> bool + '_ {
    move |s| {
        statuses
            .resolve(s)
            .map(|st| st.category == StatusCategory::Active)
            .unwrap_or(false)
    }
}

/// A predicate `Fn(&str) -> bool` that returns `true` if the named state has
/// category `Stalled` in the given `StatusesConfig`.
///
/// Used as the flow-efficiency denominator's second term:
/// `Active / (Active + Stalled)`.
pub(crate) fn is_stalled(statuses: &StatusesConfig) -> impl Fn(&str) -> bool + '_ {
    move |s| {
        statuses
            .resolve(s)
            .map(|st| st.category == StatusCategory::Stalled)
            .unwrap_or(false)
    }
}

// ── Date extraction ───────────────────────────────────────────────────────────

/// Extract the creation date of an issue.
pub(super) fn creation_date(issue: &Issue) -> IsoDate {
    issue.events.creation_date(&issue.date)
}

/// Extract the close date of an issue — date of the last `StatusChanged` event
/// whose `to` state is terminal per `statuses`.
pub(crate) fn close_date(issue: &Issue, statuses: &StatusesConfig) -> Option<IsoDate> {
    issue.events.close_date(is_terminal(statuses))
}

// ── Math ──────────────────────────────────────────────────────────────────────

/// Compute p50, p85, p95 percentiles from a sorted slice of f64 values.
/// Returns `None` if the slice is empty.
pub(super) fn percentiles(sorted: &[f64]) -> Option<super::Percentiles> {
    if sorted.is_empty() {
        return None;
    }
    let p = |pct: f64| -> f64 {
        let idx = (pct / 100.0 * (sorted.len() - 1) as f64).round() as usize;
        sorted[idx.min(sorted.len() - 1)]
    };
    Some(super::Percentiles {
        p50: p(50.0),
        p85: p(85.0),
        p95: p(95.0),
    })
}

/// Coefficient of variation (std_dev / mean * 100) — `None` if fewer than 2 values.
pub(crate) fn coeff_of_variation(values: &[f64]) -> Option<f64> {
    if values.len() < 2 {
        return None;
    }
    let mean = values.iter().sum::<f64>() / values.len() as f64;
    if mean == 0.0 {
        return None;
    }
    let variance = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / values.len() as f64;
    Some(variance.sqrt() / mean * 100.0)
}