use crate::snapshot::{Index, Snapshot};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
const FLAT_VELOCITY_EPSILON: f64 = 1e-9;
const REFACTOR_IMPROVEMENT_THRESHOLD: f64 = -1.0;
const REFACTOR_REBOUND_THRESHOLD: f64 = 0.5;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum VelocityDirection {
Positive,
Negative,
Flat,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub struct RiskVelocity {
pub function_id: String,
pub velocity: f64,
pub direction: VelocityDirection,
pub first_lrs: f64,
pub last_lrs: f64,
pub commit_count: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum HotspotStability {
Stable,
Emerging,
Volatile,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub struct HotspotAnalysis {
pub function_id: String,
pub stability: HotspotStability,
pub overlap_ratio: f64,
pub appearances_in_top_k: usize,
pub total_snapshots: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RefactorOutcome {
Successful,
Partial,
Cosmetic,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub struct RefactorAnalysis {
pub function_id: String,
pub outcome: RefactorOutcome,
pub improvement_delta: f64,
pub sustained_commits: usize,
pub rebound_detected: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub struct TrendsAnalysis {
pub velocities: Vec<RiskVelocity>,
pub hotspots: Vec<HotspotAnalysis>,
pub refactors: Vec<RefactorAnalysis>,
}
impl TrendsAnalysis {
pub fn to_json(&self) -> Result<String> {
serde_json::to_string_pretty(self).context("failed to serialize trends analysis to JSON")
}
}
pub fn load_snapshot_window(repo_root: &Path, window_size: usize) -> Result<Vec<Snapshot>> {
let index_path = crate::snapshot::index_path(repo_root);
let index = Index::load_or_new(&index_path).context("failed to load index")?;
if index.commits.is_empty() {
return Ok(Vec::new());
}
let commits_to_load = if index.commits.len() <= window_size {
&index.commits[..]
} else {
&index.commits[index.commits.len() - window_size..]
};
let mut snapshots = Vec::new();
for entry in commits_to_load {
if let Some(snapshot) = crate::snapshot::load_snapshot(repo_root, &entry.sha)? {
snapshots.push(snapshot);
}
}
snapshots.sort_by(|a, b| {
a.commit
.timestamp
.cmp(&b.commit.timestamp)
.then_with(|| a.commit.sha.cmp(&b.commit.sha))
});
Ok(snapshots)
}
pub fn compute_risk_velocities(snapshots: &[Snapshot]) -> Vec<RiskVelocity> {
if snapshots.len() < 2 {
return Vec::new();
}
let mut function_lrs: HashMap<String, Vec<(usize, f64)>> = HashMap::new();
for (snapshot_idx, snapshot) in snapshots.iter().enumerate() {
for func in &snapshot.functions {
function_lrs
.entry(func.function_id.clone())
.or_default()
.push((snapshot_idx, func.lrs));
}
}
let mut velocities = Vec::new();
for (function_id, lrs_points) in function_lrs {
if lrs_points.len() < 2 {
continue;
}
if lrs_points.len() == 1 && lrs_points[0].0 == 0 {
continue;
}
let mut sorted_points = lrs_points;
sorted_points.sort_by_key(|(idx, _)| *idx);
let (first_lrs, last_lrs) = match (sorted_points.first(), sorted_points.last()) {
(Some(first), Some(last)) => (first.1, last.1),
_ => continue,
};
let commit_count = sorted_points.len();
let velocity = if commit_count > 1 {
(last_lrs - first_lrs) / (commit_count - 1) as f64
} else {
0.0
};
let direction = if velocity.abs() < FLAT_VELOCITY_EPSILON {
VelocityDirection::Flat
} else if velocity > 0.0 {
VelocityDirection::Positive
} else {
VelocityDirection::Negative
};
velocities.push(RiskVelocity {
function_id,
velocity,
direction,
first_lrs,
last_lrs,
commit_count,
});
}
velocities.sort_by(|a, b| a.function_id.cmp(&b.function_id));
velocities
}
fn top_k_functions(snapshot: &Snapshot, k: usize) -> Vec<String> {
let mut functions: Vec<(&crate::snapshot::FunctionSnapshot, f64)> =
snapshot.functions.iter().map(|f| (f, f.lrs)).collect();
functions.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
functions
.into_iter()
.take(k)
.map(|(f, _)| f.function_id.clone())
.collect()
}
pub fn compute_hotspot_stability(snapshots: &[Snapshot], top_k: usize) -> Vec<HotspotAnalysis> {
if snapshots.len() < 2 {
return Vec::new();
}
let mut top_k_per_snapshot: Vec<Vec<String>> = Vec::new();
for snapshot in snapshots {
top_k_per_snapshot.push(top_k_functions(snapshot, top_k));
}
let mut all_top_k_functions: std::collections::HashSet<String> =
std::collections::HashSet::new();
for top_k_list in &top_k_per_snapshot {
for function_id in top_k_list {
all_top_k_functions.insert(function_id.clone());
}
}
let mut hotspot_analyses = Vec::new();
for function_id in all_top_k_functions {
let appearances = top_k_per_snapshot
.iter()
.filter(|top_k_list| top_k_list.contains(&function_id))
.count();
let total_snapshots = snapshots.len();
let overlap_ratio = appearances as f64 / total_snapshots as f64;
let stability = if overlap_ratio >= 0.8 {
HotspotStability::Stable
} else if overlap_ratio >= 0.5 {
HotspotStability::Emerging
} else {
HotspotStability::Volatile
};
hotspot_analyses.push(HotspotAnalysis {
function_id,
stability,
overlap_ratio,
appearances_in_top_k: appearances,
total_snapshots,
});
}
hotspot_analyses.sort_by(|a, b| a.function_id.cmp(&b.function_id));
hotspot_analyses
}
fn collect_function_deltas(snapshots: &[Snapshot]) -> HashMap<String, Vec<(usize, f64)>> {
let mut function_deltas: HashMap<String, Vec<(usize, f64)>> = HashMap::new();
for i in 1..snapshots.len() {
let prev_funcs: HashMap<&str, &crate::snapshot::FunctionSnapshot> = snapshots[i - 1]
.functions
.iter()
.map(|f| (f.function_id.as_str(), f))
.collect();
let curr_funcs: HashMap<&str, &crate::snapshot::FunctionSnapshot> = snapshots[i]
.functions
.iter()
.map(|f| (f.function_id.as_str(), f))
.collect();
for (function_id, curr_func) in &curr_funcs {
if let Some(prev_func) = prev_funcs.get(function_id) {
let delta = curr_func.lrs - prev_func.lrs;
function_deltas
.entry(function_id.to_string())
.or_default()
.push((i, delta));
}
}
}
function_deltas
}
fn check_sustainment_and_rebound(
deltas: &[(usize, f64)],
first_improvement_idx: usize,
snapshots_len: usize,
) -> (usize, bool) {
let mut sustained_commits = 1;
let mut rebound_detected = false;
for i in first_improvement_idx..snapshots_len.min(first_improvement_idx + 3) {
if i >= snapshots_len {
break;
}
if let Some((_, delta)) = deltas.iter().find(|(idx, _)| *idx == i) {
if *delta <= REFACTOR_IMPROVEMENT_THRESHOLD {
sustained_commits += 1;
} else if *delta >= REFACTOR_REBOUND_THRESHOLD {
rebound_detected = true;
break;
}
}
}
(sustained_commits, rebound_detected)
}
pub fn compute_refactor_effectiveness(snapshots: &[Snapshot]) -> Vec<RefactorAnalysis> {
if snapshots.len() < 2 {
return Vec::new();
}
let function_deltas = collect_function_deltas(snapshots);
let mut refactor_analyses = Vec::new();
for (function_id, deltas) in function_deltas {
let improvements: Vec<(usize, f64)> = deltas
.iter()
.filter(|(_, d)| *d <= REFACTOR_IMPROVEMENT_THRESHOLD)
.copied()
.collect();
if improvements.is_empty() {
continue;
}
let first_idx = improvements[0].0;
let improvement_delta = improvements[0].1;
let (sustained_commits, rebound_detected) =
check_sustainment_and_rebound(&deltas, first_idx, snapshots.len());
let outcome = if sustained_commits >= 2 && !rebound_detected {
RefactorOutcome::Successful
} else if sustained_commits >= 2 && rebound_detected {
RefactorOutcome::Partial
} else {
RefactorOutcome::Cosmetic
};
refactor_analyses.push(RefactorAnalysis {
function_id,
outcome,
improvement_delta,
sustained_commits,
rebound_detected,
});
}
refactor_analyses.sort_by(|a, b| a.function_id.cmp(&b.function_id));
refactor_analyses
}
pub fn analyze_trends(
repo_root: &Path,
window_size: usize,
top_k: usize,
) -> Result<TrendsAnalysis> {
let snapshots = load_snapshot_window(repo_root, window_size)?;
let velocities = compute_risk_velocities(&snapshots);
let hotspots = compute_hotspot_stability(&snapshots, top_k);
let refactors = compute_refactor_effectiveness(&snapshots);
Ok(TrendsAnalysis {
velocities,
hotspots,
refactors,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::GitContext;
use crate::report::{FunctionRiskReport, MetricsReport, RiskReport};
use crate::snapshot::FunctionSnapshot;
fn create_test_snapshot(
sha: &str,
parent_sha: &str,
functions: Vec<FunctionSnapshot>,
) -> Snapshot {
let git_context = GitContext {
head_sha: sha.to_string(),
parent_shas: vec![parent_sha.to_string()],
timestamp: 1705600000,
branch: Some("main".to_string()),
is_detached: false,
message: Some("test commit".to_string()),
author: Some("Test Author".to_string()),
is_fix_commit: Some(false),
is_revert_commit: Some(false),
ticket_ids: vec![],
};
let reports: Vec<FunctionRiskReport> = functions
.iter()
.map(|f| FunctionRiskReport {
file: f.file.clone(),
function: f.function_id.split("::").last().unwrap_or("").to_string(),
line: f.line,
language: f.language,
metrics: f.metrics.clone(),
risk: RiskReport {
r_cc: 0.0,
r_nd: 0.0,
r_fo: 0.0,
r_ns: 0.0,
},
lrs: f.lrs,
band: f.band,
suppression_reason: None,
patterns: vec![],
pattern_details: None,
callees: vec![],
explanation: None,
})
.collect();
Snapshot::new(git_context, reports)
}
#[test]
fn test_risk_velocity_positive() {
let snapshots = vec![
create_test_snapshot(
"sha1",
"sha0",
vec![FunctionSnapshot {
function_id: "src/foo.ts::func".to_string(),
file: "src/foo.ts".to_string(),
line: 1,
language: crate::language::Language::TypeScript,
metrics: MetricsReport {
cc: 1,
nd: 0,
fo: 0,
ns: 0,
loc: 10,
},
lrs: 1.0,
band: crate::risk::RiskBand::Low,
suppression_reason: None,
churn: None,
touch_count_30d: None,
days_since_last_change: None,
callgraph: None,
activity_risk: None,
risk_factors: None,
percentile: None,
driver: None,
driver_detail: None,
quadrant: None,
patterns: vec![],
pattern_details: None,
subsystem: None,
authors_90d: None,
directed_coupling: None,
jaccard_label_stability: None,
convention_bug_fix_count: None,
burst_score: None,
commit_count: None,
author_count: None,
author_entropy: None,
isolation_rate: None,
age_days: None,
last_touch_days: None,
newcomer_rate: None,
explanation: None,
}],
),
create_test_snapshot(
"sha2",
"sha1",
vec![FunctionSnapshot {
function_id: "src/foo.ts::func".to_string(),
file: "src/foo.ts".to_string(),
line: 1,
language: crate::language::Language::TypeScript,
metrics: MetricsReport {
cc: 2,
nd: 1,
fo: 0,
ns: 0,
loc: 10,
},
lrs: 3.0,
band: crate::risk::RiskBand::Moderate,
suppression_reason: None,
churn: None,
touch_count_30d: None,
days_since_last_change: None,
callgraph: None,
activity_risk: None,
risk_factors: None,
percentile: None,
driver: None,
driver_detail: None,
quadrant: None,
patterns: vec![],
pattern_details: None,
subsystem: None,
authors_90d: None,
directed_coupling: None,
jaccard_label_stability: None,
convention_bug_fix_count: None,
burst_score: None,
commit_count: None,
author_count: None,
author_entropy: None,
isolation_rate: None,
age_days: None,
last_touch_days: None,
newcomer_rate: None,
explanation: None,
}],
),
];
let velocities = compute_risk_velocities(&snapshots);
assert_eq!(velocities.len(), 1);
assert_eq!(velocities[0].function_id, "src/foo.ts::func");
assert_eq!(velocities[0].velocity, 2.0); assert_eq!(velocities[0].direction, VelocityDirection::Positive);
}
#[test]
fn test_risk_velocity_flat() {
let snapshots = vec![
create_test_snapshot(
"sha1",
"sha0",
vec![FunctionSnapshot {
function_id: "src/foo.ts::func".to_string(),
file: "src/foo.ts".to_string(),
line: 1,
language: crate::language::Language::TypeScript,
metrics: MetricsReport {
cc: 1,
nd: 0,
fo: 0,
ns: 0,
loc: 10,
},
lrs: 1.0,
band: crate::risk::RiskBand::Low,
suppression_reason: None,
churn: None,
touch_count_30d: None,
days_since_last_change: None,
callgraph: None,
activity_risk: None,
risk_factors: None,
percentile: None,
driver: None,
driver_detail: None,
quadrant: None,
patterns: vec![],
pattern_details: None,
subsystem: None,
authors_90d: None,
directed_coupling: None,
jaccard_label_stability: None,
convention_bug_fix_count: None,
burst_score: None,
commit_count: None,
author_count: None,
author_entropy: None,
isolation_rate: None,
age_days: None,
last_touch_days: None,
newcomer_rate: None,
explanation: None,
}],
),
create_test_snapshot(
"sha2",
"sha1",
vec![FunctionSnapshot {
function_id: "src/foo.ts::func".to_string(),
file: "src/foo.ts".to_string(),
line: 1,
language: crate::language::Language::TypeScript,
metrics: MetricsReport {
cc: 1,
nd: 0,
fo: 0,
ns: 0,
loc: 10,
},
lrs: 1.0,
band: crate::risk::RiskBand::Low,
suppression_reason: None,
churn: None,
touch_count_30d: None,
days_since_last_change: None,
callgraph: None,
activity_risk: None,
risk_factors: None,
percentile: None,
driver: None,
driver_detail: None,
quadrant: None,
patterns: vec![],
pattern_details: None,
subsystem: None,
authors_90d: None,
directed_coupling: None,
jaccard_label_stability: None,
convention_bug_fix_count: None,
burst_score: None,
commit_count: None,
author_count: None,
author_entropy: None,
isolation_rate: None,
age_days: None,
last_touch_days: None,
newcomer_rate: None,
explanation: None,
}],
),
];
let velocities = compute_risk_velocities(&snapshots);
assert_eq!(velocities.len(), 1);
assert_eq!(velocities[0].direction, VelocityDirection::Flat);
}
#[test]
fn test_hotspot_stability() {
let snapshots = vec![
create_test_snapshot(
"sha1",
"sha0",
vec![
FunctionSnapshot {
function_id: "src/foo.ts::func1".to_string(),
file: "src/foo.ts".to_string(),
line: 1,
language: crate::language::Language::TypeScript,
metrics: MetricsReport {
cc: 10,
nd: 5,
fo: 3,
ns: 2,
loc: 20,
},
lrs: 15.0,
band: crate::risk::RiskBand::High,
suppression_reason: None,
churn: None,
touch_count_30d: None,
days_since_last_change: None,
callgraph: None,
activity_risk: None,
risk_factors: None,
percentile: None,
driver: None,
driver_detail: None,
quadrant: None,
patterns: vec![],
pattern_details: None,
subsystem: None,
authors_90d: None,
directed_coupling: None,
jaccard_label_stability: None,
convention_bug_fix_count: None,
burst_score: None,
commit_count: None,
author_count: None,
author_entropy: None,
isolation_rate: None,
age_days: None,
last_touch_days: None,
newcomer_rate: None,
explanation: None,
},
FunctionSnapshot {
function_id: "src/bar.ts::func2".to_string(),
file: "src/bar.ts".to_string(),
line: 1,
language: crate::language::Language::TypeScript,
metrics: MetricsReport {
cc: 5,
nd: 2,
fo: 1,
ns: 0,
loc: 10,
},
lrs: 5.0,
band: crate::risk::RiskBand::Moderate,
suppression_reason: None,
churn: None,
touch_count_30d: None,
days_since_last_change: None,
callgraph: None,
activity_risk: None,
risk_factors: None,
percentile: None,
driver: None,
driver_detail: None,
quadrant: None,
patterns: vec![],
pattern_details: None,
subsystem: None,
authors_90d: None,
directed_coupling: None,
jaccard_label_stability: None,
convention_bug_fix_count: None,
burst_score: None,
commit_count: None,
author_count: None,
author_entropy: None,
isolation_rate: None,
age_days: None,
last_touch_days: None,
newcomer_rate: None,
explanation: None,
},
],
),
create_test_snapshot(
"sha2",
"sha1",
vec![
FunctionSnapshot {
function_id: "src/foo.ts::func1".to_string(),
file: "src/foo.ts".to_string(),
line: 1,
language: crate::language::Language::TypeScript,
metrics: MetricsReport {
cc: 12,
nd: 6,
fo: 4,
ns: 2,
loc: 25,
},
lrs: 18.0,
band: crate::risk::RiskBand::High,
suppression_reason: None,
churn: None,
touch_count_30d: None,
days_since_last_change: None,
callgraph: None,
activity_risk: None,
risk_factors: None,
percentile: None,
driver: None,
driver_detail: None,
quadrant: None,
patterns: vec![],
pattern_details: None,
subsystem: None,
authors_90d: None,
directed_coupling: None,
jaccard_label_stability: None,
convention_bug_fix_count: None,
burst_score: None,
commit_count: None,
author_count: None,
author_entropy: None,
isolation_rate: None,
age_days: None,
last_touch_days: None,
newcomer_rate: None,
explanation: None,
},
FunctionSnapshot {
function_id: "src/bar.ts::func2".to_string(),
file: "src/bar.ts".to_string(),
line: 1,
language: crate::language::Language::TypeScript,
metrics: MetricsReport {
cc: 5,
nd: 2,
fo: 1,
ns: 0,
loc: 10,
},
lrs: 5.0,
band: crate::risk::RiskBand::Moderate,
suppression_reason: None,
churn: None,
touch_count_30d: None,
days_since_last_change: None,
callgraph: None,
activity_risk: None,
risk_factors: None,
percentile: None,
driver: None,
driver_detail: None,
quadrant: None,
patterns: vec![],
pattern_details: None,
subsystem: None,
authors_90d: None,
directed_coupling: None,
jaccard_label_stability: None,
convention_bug_fix_count: None,
burst_score: None,
commit_count: None,
author_count: None,
author_entropy: None,
isolation_rate: None,
age_days: None,
last_touch_days: None,
newcomer_rate: None,
explanation: None,
},
],
),
];
let hotspots = compute_hotspot_stability(&snapshots, 1);
assert_eq!(hotspots.len(), 1);
assert_eq!(hotspots[0].function_id, "src/foo.ts::func1");
assert_eq!(hotspots[0].stability, HotspotStability::Stable);
assert_eq!(hotspots[0].overlap_ratio, 1.0);
}
#[test]
fn test_hotspot_stability_empty_with_single_snapshot() {
let snapshots = vec![create_test_snapshot(
"sha1",
"sha0",
vec![FunctionSnapshot {
function_id: "src/foo.ts::func1".to_string(),
file: "src/foo.ts".to_string(),
line: 1,
language: crate::language::Language::TypeScript,
metrics: MetricsReport {
cc: 12,
nd: 6,
fo: 4,
ns: 2,
loc: 25,
},
lrs: 18.0,
band: crate::risk::RiskBand::High,
suppression_reason: None,
churn: None,
touch_count_30d: None,
days_since_last_change: None,
callgraph: None,
activity_risk: None,
risk_factors: None,
percentile: None,
driver: None,
driver_detail: None,
quadrant: None,
patterns: vec![],
pattern_details: None,
subsystem: None,
authors_90d: None,
directed_coupling: None,
jaccard_label_stability: None,
convention_bug_fix_count: None,
burst_score: None,
commit_count: None,
author_count: None,
author_entropy: None,
isolation_rate: None,
age_days: None,
last_touch_days: None,
newcomer_rate: None,
explanation: None,
}],
)];
let hotspots = compute_hotspot_stability(&snapshots, 1);
assert!(
hotspots.is_empty(),
"a single snapshot must not produce 'stable' labels: {:?}",
hotspots
);
}
}