use std::collections::HashMap;
use crate::graph::GraphStore;
use crate::models::Severity;
#[derive(Debug, Clone)]
pub struct MetricTrend {
pub metric_name: String,
pub values: Vec<f64>,
pub timestamps: Vec<i64>,
pub trend_direction: String,
pub change_percentage: f64,
pub velocity: f64,
pub is_degrading: bool,
}
#[derive(Debug, Clone)]
pub struct CodeHotspot {
pub file_path: String,
pub churn_count: i64,
pub complexity_velocity: f64,
pub coupling_velocity: f64,
pub risk_score: f64,
pub last_modified: i64,
pub top_authors: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct CommitComparison {
pub before_commit: String,
pub after_commit: String,
pub before_date: Option<i64>,
pub after_date: Option<i64>,
pub improvements: Vec<String>,
pub regressions: Vec<String>,
pub changes: HashMap<String, MetricChange>,
}
#[derive(Debug, Clone)]
pub struct MetricChange {
pub before: f64,
pub after: f64,
pub change: f64,
pub change_percentage: f64,
}
pub struct TemporalMetrics<'a> {
graph: &'a GraphStore,
}
impl<'a> TemporalMetrics<'a> {
pub fn new(graph: &'a GraphStore) -> Self {
Self { graph }
}
pub fn get_metric_trend(
&self,
metric_name: &str,
window_days: i64,
) -> anyhow::Result<Option<MetricTrend>> {
if !Self::is_valid_metric_name(metric_name) {
anyhow::bail!("Invalid metric name: {}", metric_name);
}
let cutoff_timestamp = chrono::Utc::now().timestamp() - (window_days * 24 * 60 * 60);
let query = format!(
r#"
MATCH (s:Session)
WHERE s.committedAt >= $cutoff_timestamp
AND s.metricsSnapshot IS NOT NULL
AND s.metricsSnapshot.{} IS NOT NULL
RETURN
s.committedAt as timestamp,
s.metricsSnapshot.{} as value
ORDER BY s.committedAt ASC
"#,
metric_name, metric_name
);
let results = self.graph.execute_with_params(
&query,
vec![("cutoff_timestamp", cutoff_timestamp.into())],
)?;
if results.len() < 2 {
return Ok(None);
}
let timestamps: Vec<i64> = results
.iter()
.filter_map(|r| r.get_i64("timestamp"))
.collect();
let values: Vec<f64> = results
.iter()
.filter_map(|r| r.get_f64("value"))
.collect();
if values.len() < 2 {
return Ok(None);
}
let trend_direction = Self::calculate_trend_direction(&values);
let change_pct = if values[0] != 0.0 {
((values.last().unwrap() - values[0]) / values[0]) * 100.0
} else {
0.0
};
let velocity = Self::calculate_velocity(&values, ×tamps);
let degrading_metrics = ["coupling", "circular_dependencies", "dead_code_percentage"];
let improving_metrics = ["modularity", "abstraction_ratio"];
let is_degrading = if degrading_metrics.contains(&metric_name) {
trend_direction == "increasing"
} else if improving_metrics.contains(&metric_name) {
trend_direction == "decreasing"
} else {
false
};
Ok(Some(MetricTrend {
metric_name: metric_name.to_string(),
values,
timestamps,
trend_direction,
change_percentage: change_pct,
velocity,
is_degrading,
}))
}
pub fn find_code_hotspots(
&self,
window_days: i64,
min_churn: i64,
) -> anyhow::Result<Vec<CodeHotspot>> {
let cutoff_timestamp = chrono::Utc::now().timestamp() - (window_days * 24 * 60 * 60);
let query = r#"
MATCH (s:Session)-[:MODIFIED]->(f:File)
WHERE s.committedAt >= $cutoff_timestamp
WITH f.filePath as path, count(s) as churn_count, max(s.committedAt) as last_modified
WHERE churn_count >= $min_churn
RETURN path, churn_count, last_modified
ORDER BY churn_count DESC
LIMIT 50
"#;
let results = self.graph.execute_with_params(
query,
vec![
("cutoff_timestamp", cutoff_timestamp.into()),
("min_churn", min_churn.into()),
],
)?;
let mut hotspots = Vec::new();
for row in results {
let file_path = row.get_string("path").unwrap_or_default();
let churn_count = row.get_i64("churn_count").unwrap_or(0);
let last_modified = row.get_i64("last_modified").unwrap_or(0);
let complexity_velocity = 0.0;
let coupling_velocity = 0.0;
let risk_score = churn_count as f64 * f64::max(complexity_velocity, 0.1);
let top_authors = self.get_file_top_authors(&file_path, window_days)?;
hotspots.push(CodeHotspot {
file_path,
churn_count,
complexity_velocity,
coupling_velocity,
risk_score,
last_modified,
top_authors,
});
}
hotspots.sort_by(|a, b| b.risk_score.partial_cmp(&a.risk_score).unwrap_or(std::cmp::Ordering::Equal));
Ok(hotspots)
}
pub fn compare_commits(
&self,
before_hash: &str,
after_hash: &str,
) -> anyhow::Result<CommitComparison> {
let query = r#"
MATCH (before:Session {commitHash: $before_hash})
MATCH (after:Session {commitHash: $after_hash})
RETURN
before.metricsSnapshot as before_metrics,
after.metricsSnapshot as after_metrics,
before.committedAt as before_date,
after.committedAt as after_date
"#;
let results = self.graph.execute_with_params(
query,
vec![
("before_hash", before_hash.into()),
("after_hash", after_hash.into()),
],
)?;
if results.is_empty() {
return Ok(CommitComparison {
before_commit: before_hash[..7.min(before_hash.len())].to_string(),
after_commit: after_hash[..7.min(after_hash.len())].to_string(),
..Default::default()
});
}
let row = &results[0];
let before_metrics: HashMap<String, f64> = row
.get_string("before_metrics")
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
let after_metrics: HashMap<String, f64> = row
.get_string("after_metrics")
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
let mut comparison = CommitComparison {
before_commit: before_hash[..7.min(before_hash.len())].to_string(),
after_commit: after_hash[..7.min(after_hash.len())].to_string(),
before_date: row.get_i64("before_date"),
after_date: row.get_i64("after_date"),
..Default::default()
};
let metrics_to_compare = [
("modularity", true), ("coupling", false), ("circular_dependencies", false),
("dead_code_percentage", false),
];
for (metric_name, higher_is_better) in metrics_to_compare {
if let (Some(&before_val), Some(&after_val)) =
(before_metrics.get(metric_name), after_metrics.get(metric_name))
{
let change = after_val - before_val;
let change_pct = if before_val != 0.0 {
(change / before_val) * 100.0
} else {
0.0
};
comparison.changes.insert(
metric_name.to_string(),
MetricChange {
before: before_val,
after: after_val,
change,
change_percentage: change_pct,
},
);
let is_improvement = if higher_is_better {
change > 0.0
} else {
change < 0.0
};
if change != 0.0 {
if is_improvement {
comparison.improvements.push(metric_name.to_string());
} else {
comparison.regressions.push(metric_name.to_string());
}
}
}
}
Ok(comparison)
}
fn is_valid_metric_name(name: &str) -> bool {
name.chars().all(|c| c.is_alphanumeric() || c == '_')
}
fn calculate_trend_direction(values: &[f64]) -> String {
if values.len() < 2 {
return "stable".to_string();
}
let n = values.len() as f64;
let x_mean = (n - 1.0) / 2.0;
let y_mean: f64 = values.iter().sum::<f64>() / n;
let mut numerator = 0.0;
let mut denominator = 0.0;
for (i, &y) in values.iter().enumerate() {
let x = i as f64;
numerator += (x - x_mean) * (y - y_mean);
denominator += (x - x_mean).powi(2);
}
if denominator == 0.0 {
return "stable".to_string();
}
let slope = numerator / denominator;
if slope.abs() < 0.01 {
"stable".to_string()
} else if slope > 0.0 {
"increasing".to_string()
} else {
"decreasing".to_string()
}
}
fn calculate_velocity(values: &[f64], timestamps: &[i64]) -> f64 {
if values.len() < 2 || timestamps.len() < 2 {
return 0.0;
}
let total_change = values.last().unwrap() - values[0];
let time_span_days = (*timestamps.last().unwrap() - timestamps[0]) as f64 / 86400.0;
if time_span_days == 0.0 {
0.0
} else {
total_change / time_span_days
}
}
fn get_file_top_authors(
&self,
file_path: &str,
window_days: i64,
) -> anyhow::Result<Vec<String>> {
let cutoff_timestamp = chrono::Utc::now().timestamp() - (window_days * 24 * 60 * 60);
let query = r#"
MATCH (s:Session)-[:MODIFIED]->(f:File {filePath: $file_path})
WHERE s.committedAt >= $cutoff_timestamp
WITH s.author as author, count(*) as mod_count
RETURN author
ORDER BY mod_count DESC
LIMIT 5
"#;
let results = self.graph.execute_with_params(
query,
vec![
("file_path", file_path.into()),
("cutoff_timestamp", cutoff_timestamp.into()),
],
)?;
Ok(results
.iter()
.filter_map(|r| r.get_string("author"))
.collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_trend_direction() {
let increasing = vec![1.0, 2.0, 3.0, 4.0, 5.0];
assert_eq!(TemporalMetrics::calculate_trend_direction(&increasing), "increasing");
let decreasing = vec![5.0, 4.0, 3.0, 2.0, 1.0];
assert_eq!(TemporalMetrics::calculate_trend_direction(&decreasing), "decreasing");
let stable = vec![3.0, 3.0, 3.0, 3.0];
assert_eq!(TemporalMetrics::calculate_trend_direction(&stable), "stable");
}
#[test]
fn test_valid_metric_name() {
assert!(TemporalMetrics::is_valid_metric_name("modularity"));
assert!(TemporalMetrics::is_valid_metric_name("code_coverage"));
assert!(!TemporalMetrics::is_valid_metric_name("metric; DROP TABLE"));
assert!(!TemporalMetrics::is_valid_metric_name("metric.nested"));
}
#[test]
fn test_velocity_calculation() {
let values = vec![10.0, 20.0];
let timestamps = vec![0, 86400]; assert_eq!(TemporalMetrics::calculate_velocity(&values, ×tamps), 10.0);
}
}