use crate::policy::PolicyResults;
use crate::report::MetricsReport;
use crate::risk::RiskBand;
use crate::snapshot::{FunctionSnapshot, Snapshot};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
const DELTA_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum FunctionStatus {
New,
Deleted,
Modified,
Unchanged,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub struct FunctionState {
pub metrics: MetricsReport,
pub lrs: f64,
pub band: RiskBand,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub struct FunctionDelta {
pub cc: i64,
pub nd: i64,
pub fo: i64,
pub ns: i64,
pub lrs: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub struct BandTransition {
pub from: String,
pub to: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub struct FunctionDeltaEntry {
pub function_id: String,
pub status: FunctionStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub before: Option<FunctionState>,
#[serde(skip_serializing_if = "Option::is_none")]
pub after: Option<FunctionState>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delta: Option<FunctionDelta>,
#[serde(skip_serializing_if = "Option::is_none")]
pub band_transition: Option<BandTransition>,
#[serde(skip_serializing_if = "Option::is_none")]
pub suppression_reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rename_hint: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub struct DeltaCommitInfo {
pub sha: String,
pub parent: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub struct Delta {
#[serde(rename = "schema_version")]
pub schema_version: u32,
pub commit: DeltaCommitInfo,
pub baseline: bool,
pub deltas: Vec<FunctionDeltaEntry>,
#[serde(skip_serializing_if = "Option::is_none")]
pub policy: Option<PolicyResults>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aggregates: Option<crate::aggregates::DeltaAggregates>,
}
impl Delta {
pub fn new(current: &Snapshot, parent: Option<&Snapshot>) -> Result<Self> {
validate_snapshot_versions(current, parent)?;
let parent_sha = current.commit.parents.first().cloned().unwrap_or_default();
if parent.is_none() {
return Ok(build_baseline_delta(current, parent_sha));
}
let parent_snap = parent.unwrap();
let parent_funcs: HashMap<&str, &FunctionSnapshot> = parent_snap
.functions
.iter()
.map(|f| (f.function_id.as_str(), f))
.collect();
let current_funcs: HashMap<&str, &FunctionSnapshot> = current
.functions
.iter()
.map(|f| (f.function_id.as_str(), f))
.collect();
let mut all_ids: Vec<&str> = parent_funcs
.keys()
.chain(current_funcs.keys())
.copied()
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
all_ids.sort();
let mut deltas = compute_function_deltas(&all_ids, &parent_funcs, ¤t_funcs);
apply_rename_hints(&mut deltas, &parent_funcs, ¤t_funcs);
Ok(Delta {
schema_version: DELTA_SCHEMA_VERSION,
commit: DeltaCommitInfo {
sha: current.commit.sha.clone(),
parent: parent_sha,
},
baseline: false,
deltas,
policy: None,
aggregates: None,
})
}
pub fn to_json(&self) -> Result<String> {
serde_json::to_string_pretty(self).context("failed to serialize delta to JSON")
}
pub fn to_jsonl(&self) -> Result<String> {
let mut lines = Vec::with_capacity(self.deltas.len());
for entry in &self.deltas {
lines.push(
serde_json::to_string(entry).context("failed to serialize delta entry to JSON")?,
);
}
Ok(lines.join("\n"))
}
pub fn from_json(json: &str) -> Result<Self> {
let delta: Delta =
serde_json::from_str(json).context("failed to deserialize delta from JSON")?;
if delta.schema_version != DELTA_SCHEMA_VERSION {
anyhow::bail!(
"delta schema version mismatch: expected {}, got {}",
DELTA_SCHEMA_VERSION,
delta.schema_version
);
}
Ok(delta)
}
}
fn validate_snapshot_versions(current: &Snapshot, parent: Option<&Snapshot>) -> Result<()> {
if current.schema_version != crate::snapshot::SNAPSHOT_SCHEMA_VERSION {
anyhow::bail!(
"current snapshot schema version mismatch: expected {}, got {}",
crate::snapshot::SNAPSHOT_SCHEMA_VERSION,
current.schema_version
);
}
if let Some(p) = parent {
if p.schema_version != crate::snapshot::SNAPSHOT_SCHEMA_VERSION {
anyhow::bail!(
"parent snapshot schema version mismatch: expected {}, got {}",
crate::snapshot::SNAPSHOT_SCHEMA_VERSION,
p.schema_version
);
}
}
Ok(())
}
fn build_baseline_delta(current: &Snapshot, parent_sha: String) -> Delta {
let deltas = current
.functions
.iter()
.map(|func| FunctionDeltaEntry {
function_id: func.function_id.clone(),
status: FunctionStatus::New,
before: None,
after: Some(FunctionState {
metrics: func.metrics.clone(),
lrs: func.lrs,
band: func.band,
}),
delta: None,
band_transition: None,
suppression_reason: func.suppression_reason.clone(),
rename_hint: None,
})
.collect();
Delta {
schema_version: DELTA_SCHEMA_VERSION,
commit: DeltaCommitInfo {
sha: current.commit.sha.clone(),
parent: parent_sha,
},
baseline: true,
deltas,
policy: None,
aggregates: None,
}
}
fn compute_function_deltas(
all_ids: &[&str],
parent_funcs: &HashMap<&str, &FunctionSnapshot>,
current_funcs: &HashMap<&str, &FunctionSnapshot>,
) -> Vec<FunctionDeltaEntry> {
let mut deltas = Vec::new();
for function_id in all_ids {
let parent_func = parent_funcs.get(function_id);
let current_func = current_funcs.get(function_id);
match (parent_func, current_func) {
(Some(parent), Some(current)) => {
let status = if functions_differ(parent, current) {
FunctionStatus::Modified
} else {
FunctionStatus::Unchanged
};
let delta = if status == FunctionStatus::Modified {
Some(compute_function_delta(parent, current))
} else {
None
};
let band_transition = if parent.band != current.band {
Some(BandTransition {
from: parent.band.as_str().to_string(),
to: current.band.as_str().to_string(),
})
} else {
None
};
deltas.push(FunctionDeltaEntry {
function_id: function_id.to_string(),
status,
before: Some(FunctionState {
metrics: parent.metrics.clone(),
lrs: parent.lrs,
band: parent.band,
}),
after: Some(FunctionState {
metrics: current.metrics.clone(),
lrs: current.lrs,
band: current.band,
}),
delta,
band_transition,
suppression_reason: current.suppression_reason.clone(),
rename_hint: None,
});
}
(Some(parent), None) => {
deltas.push(FunctionDeltaEntry {
function_id: function_id.to_string(),
status: FunctionStatus::Deleted,
before: Some(FunctionState {
metrics: parent.metrics.clone(),
lrs: parent.lrs,
band: parent.band,
}),
after: None,
delta: Some(compute_delete_delta(parent)),
band_transition: None,
suppression_reason: parent.suppression_reason.clone(),
rename_hint: None,
});
}
(None, Some(current)) => {
deltas.push(FunctionDeltaEntry {
function_id: function_id.to_string(),
status: FunctionStatus::New,
before: None,
after: Some(FunctionState {
metrics: current.metrics.clone(),
lrs: current.lrs,
band: current.band,
}),
delta: None,
band_transition: None,
suppression_reason: current.suppression_reason.clone(),
rename_hint: None,
});
}
(None, None) => {
unreachable!("function_id should exist in at least one snapshot");
}
}
}
deltas
}
fn find_rename_match<'a>(
del_id: &str,
del_func: &FunctionSnapshot,
new_ids: &'a [String],
current_funcs: &HashMap<&str, &FunctionSnapshot>,
matched_new: &std::collections::HashSet<String>,
) -> Option<&'a str> {
let del_name = del_id
.strip_prefix(&format!("{}::", del_func.file))
.unwrap_or(del_id);
for new_id in new_ids {
if matched_new.contains(new_id) {
continue;
}
let new_func = match current_funcs.get(new_id.as_str()) {
Some(f) => f,
None => continue,
};
let new_name = new_id
.strip_prefix(&format!("{}::", new_func.file))
.unwrap_or(new_id.as_str());
if del_name == new_name && del_func.file != new_func.file {
return Some(new_id.as_str());
}
if del_func.file == new_func.file && del_func.line.abs_diff(new_func.line) <= 10 {
return Some(new_id.as_str());
}
}
None
}
fn apply_hints(deltas: &mut [FunctionDeltaEntry], hints: &[(String, String)]) {
for (del_id, new_id) in hints {
for entry in deltas.iter_mut() {
if entry.function_id == *del_id {
entry.rename_hint = Some(new_id.clone());
break;
}
}
}
}
fn apply_rename_hints(
deltas: &mut [FunctionDeltaEntry],
parent_funcs: &HashMap<&str, &FunctionSnapshot>,
current_funcs: &HashMap<&str, &FunctionSnapshot>,
) {
let deleted_ids: Vec<String> = deltas
.iter()
.filter(|e| e.status == FunctionStatus::Deleted)
.map(|e| e.function_id.clone())
.collect();
let new_ids: Vec<String> = deltas
.iter()
.filter(|e| e.status == FunctionStatus::New)
.map(|e| e.function_id.clone())
.collect();
if deleted_ids.is_empty() || new_ids.is_empty() {
return;
}
let mut matched_new: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut hints: Vec<(String, String)> = Vec::new();
for del_id in &deleted_ids {
let del_func = match parent_funcs.get(del_id.as_str()) {
Some(f) => f,
None => continue,
};
if let Some(new_id) =
find_rename_match(del_id, del_func, &new_ids, current_funcs, &matched_new)
{
hints.push((del_id.clone(), new_id.to_string()));
matched_new.insert(new_id.to_string());
}
}
apply_hints(deltas, &hints);
}
fn functions_differ(parent: &FunctionSnapshot, current: &FunctionSnapshot) -> bool {
parent.metrics != current.metrics
|| (parent.lrs - current.lrs).abs() > f64::EPSILON
|| parent.band != current.band
}
fn compute_function_delta(parent: &FunctionSnapshot, current: &FunctionSnapshot) -> FunctionDelta {
FunctionDelta {
cc: current.metrics.cc as i64 - parent.metrics.cc as i64,
nd: current.metrics.nd as i64 - parent.metrics.nd as i64,
fo: current.metrics.fo as i64 - parent.metrics.fo as i64,
ns: current.metrics.ns as i64 - parent.metrics.ns as i64,
lrs: current.lrs - parent.lrs,
}
}
fn compute_delete_delta(parent: &FunctionSnapshot) -> FunctionDelta {
FunctionDelta {
cc: -(parent.metrics.cc as i64),
nd: -(parent.metrics.nd as i64),
fo: -(parent.metrics.fo as i64),
ns: -(parent.metrics.ns as i64),
lrs: -parent.lrs,
}
}
pub fn load_parent_snapshot(repo_root: &Path, parent_sha: &str) -> Result<Option<Snapshot>> {
crate::snapshot::load_snapshot(repo_root, parent_sha)
}
pub fn compute_delta(repo_root: &Path, current: &Snapshot) -> Result<Delta> {
let parent_sha = current.commit.parents.first();
let parent = if let Some(sha) = parent_sha {
load_parent_snapshot(repo_root, sha)?
} else {
None
};
Delta::new(current, parent.as_ref())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::GitContext;
use crate::language::Language;
use crate::report::{FunctionRiskReport, MetricsReport};
use crate::risk::RiskBand;
use crate::snapshot::Snapshot;
fn create_test_snapshot(
sha: &str,
parent_sha: &str,
cc: u32,
lrs: f64,
band: &str,
) -> 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 report = FunctionRiskReport {
file: "src/foo.ts".to_string(),
function: "handler".to_string(),
line: 42,
language: Language::TypeScript,
metrics: MetricsReport {
cc,
nd: 2,
fo: 3,
ns: 1,
loc: 10,
},
risk: crate::report::RiskReport {
r_cc: 2.0,
r_nd: 1.0,
r_fo: 1.0,
r_ns: 1.0,
},
lrs,
band: RiskBand::parse(band).unwrap_or(RiskBand::Low),
suppression_reason: None,
patterns: vec![],
pattern_details: None,
callees: vec![],
};
Snapshot::new(git_context, vec![report])
}
#[test]
fn test_baseline_delta() {
let current = create_test_snapshot("abc123", "", 5, 4.8, "moderate");
let delta = Delta::new(¤t, None).expect("should create baseline delta");
assert!(delta.baseline);
assert_eq!(delta.deltas.len(), 1);
assert_eq!(delta.deltas[0].status, FunctionStatus::New);
}
#[test]
fn test_modified_delta() {
let parent = create_test_snapshot("parent123", "grandparent", 4, 3.9, "moderate");
let current = create_test_snapshot("current123", "parent123", 6, 6.2, "high");
let delta = Delta::new(¤t, Some(&parent)).expect("should create delta");
assert!(!delta.baseline);
assert_eq!(delta.deltas.len(), 1);
assert_eq!(delta.deltas[0].status, FunctionStatus::Modified);
let delta_values = delta.deltas[0].delta.as_ref().unwrap();
assert_eq!(delta_values.cc, 2); assert!((delta_values.lrs - 2.3).abs() < 0.01);
let transition = delta.deltas[0].band_transition.as_ref().unwrap();
assert_eq!(transition.from, "moderate");
assert_eq!(transition.to, "high");
}
#[test]
fn test_unchanged_delta() {
let parent = create_test_snapshot("parent123", "grandparent", 5, 4.8, "moderate");
let current = create_test_snapshot("current123", "parent123", 5, 4.8, "moderate");
let delta = Delta::new(¤t, Some(&parent)).expect("should create delta");
assert_eq!(delta.deltas.len(), 1);
assert_eq!(delta.deltas[0].status, FunctionStatus::Unchanged);
assert!(delta.deltas[0].delta.is_none());
assert!(delta.deltas[0].band_transition.is_none());
}
#[test]
fn test_negative_deltas() {
let parent = create_test_snapshot("parent123", "grandparent", 6, 6.2, "high");
let current = create_test_snapshot("current123", "parent123", 4, 3.9, "moderate");
let delta = Delta::new(¤t, Some(&parent)).expect("should create delta");
let delta_values = delta.deltas[0].delta.as_ref().unwrap();
assert_eq!(delta_values.cc, -2); assert!(delta_values.lrs < 0.0); }
#[test]
fn test_deleted_function() {
let parent = create_test_snapshot("parent123", "grandparent", 5, 4.8, "moderate");
let git_context = GitContext {
head_sha: "current123".to_string(),
parent_shas: vec!["parent123".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 current = Snapshot::new(git_context, vec![]);
let delta = Delta::new(¤t, Some(&parent)).expect("should create delta");
assert_eq!(delta.deltas.len(), 1);
assert_eq!(delta.deltas[0].status, FunctionStatus::Deleted);
assert!(delta.deltas[0].before.is_some());
assert!(delta.deltas[0].after.is_none());
}
}