use crate::repo::Repo;
use crate::{CodeLoreError, Options, Result};
use globset::Glob;
const TREND_SLOPE_FRACTION: f64 = 0.1;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ReleaseCadenceRow {
pub tag: String,
pub date: String,
pub days_since_prev: Option<f64>,
pub trend: String,
}
fn ols_slope(ys: &[f64]) -> Option<f64> {
let n = ys.len();
if n < 2 {
return None;
}
#[allow(clippy::cast_precision_loss)]
let n_f = n as f64;
let x_mean = (n_f - 1.0) / 2.0;
let y_mean = ys.iter().sum::<f64>() / n_f;
let mut num = 0.0_f64;
let mut den = 0.0_f64;
for (i, &y) in ys.iter().enumerate() {
#[allow(clippy::cast_precision_loss)]
let xi = i as f64 - x_mean;
num += xi * (y - y_mean);
den += xi * xi;
}
if den < f64::EPSILON {
return Some(0.0);
}
Some(num / den)
}
fn median_sorted(sorted: &[f64]) -> f64 {
let n = sorted.len();
if n % 2 == 1 {
sorted[n / 2]
} else {
sorted[n / 2 - 1] / 2.0 + sorted[n / 2] / 2.0
}
}
fn percentile_sorted(sorted: &[f64], p: f64) -> f64 {
let n = sorted.len();
if n == 1 {
return sorted[0];
}
#[allow(clippy::cast_precision_loss)]
let idx = p * (n as f64 - 1.0);
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let lo = idx.floor() as usize;
let hi = (lo + 1).min(n - 1);
#[allow(clippy::cast_precision_loss)]
let frac = idx - lo as f64;
sorted[lo] * (1.0 - frac) + sorted[hi] * frac
}
fn classify_trend(slope: f64, median_gap: f64) -> &'static str {
let threshold = TREND_SLOPE_FRACTION * median_gap;
if slope > threshold {
"slowing"
} else if slope < -threshold {
"accelerating"
} else {
"stable"
}
}
pub fn run_release_cadence<R: Repo>(repo: &R, opts: &Options) -> Result<Vec<ReleaseCadenceRow>> {
let matcher = Glob::new(&opts.release_tag_glob)
.map_err(|e| {
CodeLoreError::InvalidOptions(format!(
"--release-tag-glob {:?} is not a valid glob: {e}",
opts.release_tag_glob
))
})?
.compile_matcher();
let all_tags = repo.tags()?;
let filtered: Vec<_> = all_tags
.into_iter()
.filter(|t| matcher.is_match(&t.name))
.collect();
if filtered.is_empty() {
return Ok(Vec::new());
}
let mut rows: Vec<ReleaseCadenceRow> = Vec::with_capacity(filtered.len() + 1);
let mut gaps: Vec<f64> = Vec::with_capacity(filtered.len().saturating_sub(1));
for (i, tag) in filtered.iter().enumerate() {
let days_since_prev = if i == 0 {
None
} else {
let prev = &filtered[i - 1];
#[allow(clippy::cast_precision_loss)]
let secs = (tag.date - prev.date).whole_seconds() as f64;
let d = secs / 86_400.0;
gaps.push(d);
Some(d)
};
rows.push(ReleaseCadenceRow {
tag: tag.name.clone(),
date: format!(
"{:04}-{:02}-{:02}",
tag.date.year(),
tag.date.month() as u8,
tag.date.day()
),
days_since_prev,
trend: String::new(),
});
}
if gaps.is_empty() {
return Ok(rows);
}
let mut sorted_gaps = gaps.clone();
sorted_gaps.sort_by(f64::total_cmp);
let median = median_sorted(&sorted_gaps);
let q1 = percentile_sorted(&sorted_gaps, 0.25);
let q3 = percentile_sorted(&sorted_gaps, 0.75);
let iqr = q3 - q1;
let trend = ols_slope(&gaps).map_or("stable", |slope| classify_trend(slope, median));
rows.push(ReleaseCadenceRow {
tag: "__summary__".to_string(),
date: format!("iqr={iqr:.2}d"),
days_since_prev: Some(median),
trend: trend.to_string(),
});
Ok(rows)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ols_slope_rising() {
let s = ols_slope(&[10.0, 20.0, 30.0]).unwrap();
assert!((s - 10.0).abs() < 1e-9, "slope={s}");
}
#[test]
fn ols_slope_flat() {
let s = ols_slope(&[5.0, 5.0, 5.0]).unwrap();
assert!(s.abs() < 1e-9, "slope={s}");
}
#[test]
fn ols_slope_none_for_single_point() {
assert!(ols_slope(&[42.0]).is_none());
}
#[test]
fn median_odd() {
let got = median_sorted(&[1.0, 3.0, 5.0]);
assert!((got - 3.0).abs() < 1e-12, "got {got}");
}
#[test]
fn median_even() {
let got = median_sorted(&[10.0, 20.0]);
assert!((got - 15.0).abs() < 1e-12, "got {got}");
}
#[test]
fn percentile_single() {
let got = percentile_sorted(&[7.0], 0.25);
assert!((got - 7.0).abs() < 1e-12, "got {got}");
}
#[test]
fn trend_slowing() {
let gaps = vec![10.0, 20.0, 30.0];
let slope = ols_slope(&gaps).unwrap();
assert_eq!(classify_trend(slope, 20.0), "slowing");
}
#[test]
fn trend_accelerating() {
let gaps = vec![30.0, 20.0, 10.0];
let slope = ols_slope(&gaps).unwrap();
assert_eq!(classify_trend(slope, 20.0), "accelerating");
}
#[test]
fn trend_stable_near_zero() {
let gaps = vec![10.0, 10.0];
let slope = ols_slope(&gaps).unwrap();
assert_eq!(classify_trend(slope, 10.0), "stable");
}
#[test]
fn scale_relative_yearly_cadence_small_drift_is_stable() {
let gaps = vec![360.0, 360.5, 361.0];
let slope = ols_slope(&gaps).unwrap();
assert!((slope - 0.5).abs() < 1e-9, "slope={slope}");
let median = median_sorted(&{
let mut s = gaps.clone();
s.sort_by(f64::total_cmp);
s
});
assert!((median - 360.5).abs() < 1e-9, "median={median}");
assert_eq!(classify_trend(slope, median), "stable");
}
#[test]
fn scale_relative_fast_cadence_small_drift_is_accelerating() {
let gaps = vec![0.56, 0.5, 0.44];
let slope = ols_slope(&gaps).unwrap();
assert!((slope - -0.06).abs() < 1e-9, "slope={slope}");
let median = median_sorted(&{
let mut s = gaps.clone();
s.sort_by(f64::total_cmp);
s
});
assert!((median - 0.5).abs() < 1e-9, "median={median}");
assert_eq!(classify_trend(slope, median), "accelerating");
}
#[test]
fn classify_trend_zero_median_any_nonzero_slope_is_a_trend() {
assert_eq!(classify_trend(0.01, 0.0), "slowing");
assert_eq!(classify_trend(-0.01, 0.0), "accelerating");
assert_eq!(classify_trend(0.0, 0.0), "stable");
}
}