use std::collections::HashMap;
use serde::Serialize;
use crate::scorer::HistoryEntry;
const VELOCITY_WINDOW: usize = 8;
const DIRECTION_THRESHOLD: f64 = 0.5;
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum VelocityDirection {
Improving,
Declining,
Stable,
}
#[derive(Debug, Clone, Serialize)]
pub struct SparklinePoint {
pub score: u32,
pub head_short: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct TrendVelocity {
pub direction: VelocityDirection,
pub points_per_run: f64,
pub window_size: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct TrendDelta {
pub overall: i32,
pub delta_vs_oldest: i32,
pub categories: HashMap<String, i32>,
pub is_first: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct TrendSummary {
pub delta: TrendDelta,
pub sparkline: Vec<SparklinePoint>,
pub velocity: Option<TrendVelocity>,
pub branch_mismatch_warning: bool,
pub history: Vec<HistoryEntry>,
}
pub fn compute_trend(
history: &[HistoryEntry],
current_branch: &str,
current_entry: &HistoryEntry,
) -> TrendSummary {
let same_branch: Vec<&HistoryEntry> = history
.iter()
.filter(|e| e.branch == current_branch)
.collect();
if same_branch.is_empty() {
let branch_mismatch_warning = !history.is_empty();
return TrendSummary {
delta: TrendDelta {
overall: 0,
delta_vs_oldest: 0,
categories: HashMap::new(),
is_first: true,
},
sparkline: build_sparkline(&same_branch, current_entry),
velocity: None,
branch_mismatch_warning,
history: history.to_vec(),
};
}
let last = *same_branch.last().unwrap();
let oldest = *same_branch.first().unwrap();
let branch_mismatch_warning = history
.last()
.map(|e| e.branch != current_branch)
.unwrap_or(false);
let delta_overall = current_entry.overall_score as i32 - last.overall_score as i32;
let delta_vs_oldest = current_entry.overall_score as i32 - oldest.overall_score as i32;
let delta_categories = compute_category_deltas(&last.categories, ¤t_entry.categories);
let sparkline = build_sparkline(&same_branch, current_entry);
let velocity = compute_velocity(&same_branch, current_entry);
TrendSummary {
delta: TrendDelta {
overall: delta_overall,
delta_vs_oldest,
categories: delta_categories,
is_first: false,
},
sparkline,
velocity: Some(velocity),
branch_mismatch_warning,
history: history.to_vec(),
}
}
fn compute_category_deltas(
previous: &HashMap<String, u32>,
current: &HashMap<String, u32>,
) -> HashMap<String, i32> {
let mut deltas = HashMap::new();
for (key, ¤t_score) in current {
let prev_score = previous.get(key).copied().unwrap_or(current_score);
deltas.insert(key.clone(), current_score as i32 - prev_score as i32);
}
deltas
}
fn take_velocity_window<'a>(same_branch: &[&'a HistoryEntry]) -> Vec<&'a HistoryEntry> {
if same_branch.len() > VELOCITY_WINDOW {
same_branch[same_branch.len() - VELOCITY_WINDOW..].to_vec()
} else {
same_branch.to_vec()
}
}
fn build_sparkline(
same_branch: &[&HistoryEntry],
current_entry: &HistoryEntry,
) -> Vec<SparklinePoint> {
let window_entries = take_velocity_window(same_branch);
let mut points: Vec<SparklinePoint> = window_entries
.iter()
.map(|e| SparklinePoint {
score: e.overall_score,
head_short: e.head[..e.head.len().min(7)].to_string(),
})
.collect();
points.push(SparklinePoint {
score: current_entry.overall_score,
head_short: current_entry.head[..current_entry.head.len().min(7)].to_string(),
});
points
}
fn compute_velocity(same_branch: &[&HistoryEntry], current_entry: &HistoryEntry) -> TrendVelocity {
let window = take_velocity_window(same_branch);
let window_size = window.len() + 1;
let first_score = window
.first()
.map(|e| e.overall_score)
.unwrap_or(current_entry.overall_score);
let last_score = current_entry.overall_score;
let total_change = last_score as i32 - first_score as i32;
let runs = (window_size - 1).max(1) as f64;
let points_per_run = total_change as f64 / runs;
let direction = if points_per_run > DIRECTION_THRESHOLD {
VelocityDirection::Improving
} else if points_per_run < -DIRECTION_THRESHOLD {
VelocityDirection::Declining
} else {
VelocityDirection::Stable
};
TrendVelocity {
direction,
points_per_run,
window_size,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scorer::HistoryCounts;
use chrono::Utc;
fn make_entry(branch: &str, overall_score: u32, head: &str) -> HistoryEntry {
let mut categories = HashMap::new();
categories.insert("Health".to_string(), overall_score);
categories.insert("Team".to_string(), overall_score);
HistoryEntry {
timestamp: Utc::now(),
head: head.to_string(),
overall_score,
categories,
metrics: HashMap::new(),
counts: HistoryCounts {
commits: 1,
files: 1,
authors: 1,
},
branch: branch.to_string(),
schema_version: 1,
source: None,
}
}
#[test]
fn compute_trend_with_3_entries_has_non_null_velocity_and_correct_delta_vs_oldest() {
let entry1 = make_entry("main", 60, "aaa0001");
let entry2 = make_entry("main", 65, "aaa0002");
let entry3 = make_entry("main", 68, "aaa0003");
let current = make_entry("main", 72, "bbb1111");
let history = vec![entry1, entry2, entry3];
let summary = compute_trend(&history, "main", ¤t);
assert!(
summary.velocity.is_some(),
"velocity should be Some when 3+ same-branch prior entries exist"
);
assert_eq!(
summary.delta.delta_vs_oldest, 12,
"delta_vs_oldest should be current_score - oldest_score = 72 - 60 = 12"
);
assert_eq!(
summary.delta.overall, 4,
"delta.overall (delta_vs_last) should be current_score - last_score = 72 - 68 = 4"
);
}
#[test]
fn compute_trend_with_no_prior_entries_returns_is_first_true() {
let current = make_entry("main", 75, "abc1234");
let summary = compute_trend(&[], "main", ¤t);
assert!(
summary.delta.is_first,
"is_first should be true when history is empty"
);
assert_eq!(summary.delta.overall, 0);
}
#[test]
fn compute_trend_with_one_prior_entry_computes_positive_delta() {
let prior = make_entry("main", 70, "aaa0000");
let current = make_entry("main", 75, "bbb1111");
let summary = compute_trend(&[prior], "main", ¤t);
assert!(
!summary.delta.is_first,
"is_first should be false when prior history exists"
);
assert_eq!(summary.delta.overall, 5, "delta should be 75 - 70 = +5");
}
#[test]
fn compute_trend_branch_mismatch_sets_warning() {
let prior = make_entry("feature/refactor", 70, "aaa0000");
let current = make_entry("main", 75, "bbb1111");
let summary = compute_trend(&[prior], "main", ¤t);
assert!(
summary.branch_mismatch_warning,
"branch_mismatch_warning should be true when history is non-empty but no same-branch entries exist"
);
assert!(
summary.delta.is_first,
"is_first should be true when no same-branch prior entries exist"
);
}
#[test]
fn compute_trend_direction_improving_when_score_increases() {
let entry1 = make_entry("main", 60, "aaa0001");
let entry2 = make_entry("main", 65, "aaa0002");
let entry3 = make_entry("main", 68, "aaa0003");
let entry4 = make_entry("main", 72, "aaa0004");
let current = make_entry("main", 80, "bbb1111");
let history = vec![entry1, entry2, entry3, entry4];
let summary = compute_trend(&history, "main", ¤t);
let velocity = summary
.velocity
.expect("velocity should be Some with prior entries");
assert_eq!(
velocity.direction,
VelocityDirection::Improving,
"direction should be Improving when current score exceeds prior scores"
);
assert!(
summary.delta.overall > 0,
"delta_vs_last should be positive when current score exceeds last score"
);
}
#[test]
fn compute_trend_direction_declining_when_score_drops() {
let prior = make_entry("main", 99, "aaa0001");
let current = make_entry("main", 40, "bbb1111");
let history = vec![prior];
let summary = compute_trend(&history, "main", ¤t);
let velocity = summary
.velocity
.expect("velocity should be Some with prior entries");
assert_eq!(
velocity.direction,
VelocityDirection::Declining,
"direction should be Declining when current score is below last score"
);
assert!(
summary.delta.overall < 0,
"delta_vs_last should be negative when current score drops, got: {}",
summary.delta.overall
);
}
#[test]
fn compute_trend_filters_to_current_branch_only() {
let on_other_branch = make_entry("feature", 90, "fff0000");
let on_main = make_entry("main", 60, "aaa0000");
let current = make_entry("main", 65, "bbb1111");
let history = vec![on_other_branch, on_main];
let summary = compute_trend(&history, "main", ¤t);
assert!(
!summary.delta.is_first,
"should find prior entry on main branch"
);
assert_eq!(
summary.delta.overall, 5,
"delta should compare against main branch entry (65 - 60 = +5), not feature branch"
);
}
}