use std::collections::HashMap;
use std::path::PathBuf;
use super::error::Error;
use super::stats::Stats;
pub const TREND_SCHEMA_VERSION: u32 = 1;
pub const MIN_TREND_POINTS: usize = 2;
pub const MAX_TREND_POINTS: usize = 120;
pub fn validate_points(points: usize) -> Result<usize, Error> {
if points < MIN_TREND_POINTS {
return Err(Error::InvalidTrend(format!(
"at least {MIN_TREND_POINTS} points are required for a trend (got {points})"
)));
}
if points > MAX_TREND_POINTS {
return Err(Error::InvalidTrend(format!(
"at most {MAX_TREND_POINTS} points are supported (got {points})"
)));
}
Ok(points)
}
#[must_use]
pub fn timestamps(end: i64, span_secs: i64, points: usize) -> Vec<i64> {
if points <= 1 {
return vec![end];
}
let start = end.saturating_sub(span_secs);
let divisor = i64::try_from(points - 1).unwrap_or(i64::MAX);
(0..points)
.map(|i| {
let i = i64::try_from(i).unwrap_or(i64::MAX);
start + span_secs.saturating_mul(i) / divisor
})
.collect()
}
#[derive(Clone, Debug)]
pub struct Trend {
as_of_points: Vec<i64>,
files: HashMap<PathBuf, Vec<Option<Stats>>>,
long_window_days: u32,
recent_window_days: u32,
truncated_shallow_clone: bool,
}
impl Trend {
#[must_use]
pub fn from_snapshots(
as_of_points: Vec<i64>,
per_point: Vec<HashMap<PathBuf, Stats>>,
long_window_days: u32,
recent_window_days: u32,
truncated_shallow_clone: bool,
) -> Self {
let n = as_of_points.len();
debug_assert_eq!(per_point.len(), n, "one snapshot per as-of point");
let mut files: HashMap<PathBuf, Vec<Option<Stats>>> = HashMap::new();
for (i, snapshot) in per_point.into_iter().enumerate().take(n) {
for (path, stats) in snapshot {
if let Some(slot) = files
.entry(path)
.or_insert_with(|| vec![None; n])
.get_mut(i)
{
*slot = Some(stats);
}
}
}
Self {
as_of_points,
files,
long_window_days,
recent_window_days,
truncated_shallow_clone,
}
}
#[must_use]
pub fn as_of_points(&self) -> &[i64] {
&self.as_of_points
}
pub fn iter(&self) -> impl Iterator<Item = (&PathBuf, &[Option<Stats>])> {
self.files.iter().map(|(path, points)| (path, &points[..]))
}
#[must_use]
pub fn len(&self) -> usize {
self.files.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.files.is_empty()
}
#[must_use]
pub fn long_window_days(&self) -> u32 {
self.long_window_days
}
#[must_use]
pub fn recent_window_days(&self) -> u32 {
self.recent_window_days
}
#[must_use]
pub fn truncated_shallow_clone(&self) -> bool {
self.truncated_shallow_clone
}
#[must_use]
pub fn deltas(&self, top: usize) -> TrendDeltas {
let mut improved: Vec<TrendDelta> = Vec::new();
let mut regressed: Vec<TrendDelta> = Vec::new();
for (path, points) in &self.files {
let Some(delta) = self.file_delta(path, points) else {
continue;
};
if delta.delta < 0.0 {
improved.push(delta);
} else if delta.delta > 0.0 {
regressed.push(delta);
}
}
improved.sort_by(|a, b| {
a.delta
.partial_cmp(&b.delta)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.path.cmp(&b.path))
});
regressed.sort_by(|a, b| {
b.delta
.partial_cmp(&a.delta)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.path.cmp(&b.path))
});
if top > 0 {
improved.truncate(top);
regressed.truncate(top);
}
TrendDeltas {
improved,
regressed,
}
}
fn file_delta(&self, path: &std::path::Path, points: &[Option<Stats>]) -> Option<TrendDelta> {
let mut present = points
.iter()
.enumerate()
.filter_map(|(i, stats)| stats.as_ref().map(|s| (i, s)));
let (first_i, first) = present.next()?;
let (last_i, last) = present.next_back()?;
Some(TrendDelta {
path: path.to_path_buf(),
first_as_of: self.as_of_points[first_i],
last_as_of: self.as_of_points[last_i],
first_risk_score: first.risk_score,
last_risk_score: last.risk_score,
delta: last.risk_score - first.risk_score,
})
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct TrendDelta {
pub path: PathBuf,
pub first_as_of: i64,
pub last_as_of: i64,
pub first_risk_score: f64,
pub last_risk_score: f64,
pub delta: f64,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct TrendDeltas {
pub improved: Vec<TrendDelta>,
pub regressed: Vec<TrendDelta>,
}
#[cfg(test)]
#[path = "trend_tests.rs"]
mod tests;