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 parent_root = common_root(&parent_snap.functions);
let current_root = common_root(¤t.functions);
let parent_by_rel: HashMap<String, &FunctionSnapshot> = parent_snap
.functions
.iter()
.map(|f| (relative_match_key(f, parent_root.as_deref()), f))
.collect();
let current_by_rel: HashMap<String, &FunctionSnapshot> = current
.functions
.iter()
.map(|f| (relative_match_key(f, current_root.as_deref()), f))
.collect();
let mut all_rel_ids: Vec<&str> = parent_by_rel
.keys()
.chain(current_by_rel.keys())
.map(String::as_str)
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
all_rel_ids.sort();
let mut deltas = compute_function_deltas(&all_rel_ids, &parent_by_rel, ¤t_by_rel);
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_rel_ids: &[&str],
parent_by_rel: &HashMap<String, &FunctionSnapshot>,
current_by_rel: &HashMap<String, &FunctionSnapshot>,
) -> Vec<FunctionDeltaEntry> {
let mut deltas = Vec::new();
for rel_id in all_rel_ids {
let parent_func = parent_by_rel.get(*rel_id);
let current_func = current_by_rel.get(*rel_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: current.function_id.clone(),
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: parent.function_id.clone(),
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: current.function_id.clone(),
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!("rel_id should exist in at least one snapshot");
}
}
}
deltas
}
fn common_root(functions: &[FunctionSnapshot]) -> Option<std::path::PathBuf> {
let mut files = functions.iter().map(|f| Path::new(&f.file));
let first = files.next()?;
let mut root: Vec<std::path::Component> = first
.parent()
.map(|p| p.components().collect())
.unwrap_or_default();
for file in files {
let parent_components: Vec<_> = file
.parent()
.map(|p| p.components().collect())
.unwrap_or_default();
let common_len = root
.iter()
.zip(parent_components.iter())
.take_while(|(a, b)| a == b)
.count();
root.truncate(common_len);
if root.is_empty() {
break;
}
}
if root.is_empty() {
None
} else {
Some(root.into_iter().collect())
}
}
fn relative_match_key(f: &FunctionSnapshot, root: Option<&Path>) -> String {
match root.and_then(|r| Path::new(&f.file).strip_prefix(r).ok()) {
Some(rel) => format!("{}::{}", rel.to_string_lossy(), symbol_of(f)),
None => f.function_id.clone(),
}
}
fn symbol_of(f: &FunctionSnapshot) -> &str {
f.function_id
.strip_prefix(&format!("{}::", f.file))
.unwrap_or(&f.function_id)
}
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 mut deleted_ids: Vec<String> = deltas
.iter()
.filter(|e| e.status == FunctionStatus::Deleted)
.map(|e| e.function_id.clone())
.collect();
let mut 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;
}
deleted_ids.sort();
new_ids.sort();
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 {
let loaded = load_parent_snapshot(repo_root, sha)?;
if loaded.is_none() {
eprintln!(
"warning: no snapshot found for parent commit {sha}; treating this analysis \
as a baseline and SKIPPING policy evaluation. If parent history is expected \
to be available (e.g. in CI), this may hide real policy violations — run \
`hotspots analyze` on the parent commit first, or restore its snapshot cache."
);
}
loaded
} 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;
#[test]
fn common_root_finds_shared_ancestor_across_multiple_files() {
let fns = vec![
make_fn_snapshot("a", "/tmp/wt1/src/x.rs", 1),
make_fn_snapshot("b", "/tmp/wt1/src/sub/y.rs", 1),
make_fn_snapshot("c", "/tmp/wt1/tests/z.rs", 1),
];
assert_eq!(
common_root(&fns),
Some(std::path::PathBuf::from("/tmp/wt1"))
);
}
#[test]
fn common_root_similar_but_distinct_prefixes_dont_false_match() {
let fns = vec![
make_fn_snapshot("a", "/tmp/wtA-old/src/x.rs", 1),
make_fn_snapshot("b", "/tmp/wtA-new/src/x.rs", 1),
];
assert_eq!(common_root(&fns), Some(std::path::PathBuf::from("/tmp")));
}
#[test]
fn common_root_empty_snapshot_is_none() {
assert_eq!(common_root(&[]), None);
}
#[test]
fn relative_match_key_strips_each_snapshots_own_root() {
let f1 = make_fn_snapshot("foo", "/tmp/wt1/src/x.rs", 10);
let f2 = make_fn_snapshot("foo", "/tmp/wt2/src/x.rs", 10);
let root1 = std::path::PathBuf::from("/tmp/wt1");
let root2 = std::path::PathBuf::from("/tmp/wt2");
assert_eq!(
relative_match_key(&f1, Some(&root1)),
relative_match_key(&f2, Some(&root2))
);
}
#[test]
fn relative_match_key_distinguishes_genuinely_different_files() {
let root = std::path::PathBuf::from("/tmp/wt1");
let x = make_fn_snapshot("foo", "/tmp/wt1/src/x.rs", 10);
let y = make_fn_snapshot("foo", "/tmp/wt1/src/y.rs", 10);
assert_ne!(
relative_match_key(&x, Some(&root)),
relative_match_key(&y, Some(&root))
);
}
#[test]
fn relative_match_key_falls_back_to_function_id_when_root_is_none() {
let f = make_fn_snapshot("foo", "/tmp/wt1/src/x.rs", 10);
assert_eq!(relative_match_key(&f, None), f.function_id);
}
#[test]
fn delta_new_matches_identical_code_across_different_worktree_roots() {
let mk_report = |name: &str, file: String, line: u32| FunctionRiskReport {
file,
function: name.to_string(),
line,
language: Language::TypeScript,
metrics: MetricsReport {
cc: 1,
nd: 1,
fo: 1,
ns: 1,
loc: 5,
},
risk: crate::report::RiskReport {
r_cc: 1.0,
r_nd: 1.0,
r_fo: 1.0,
r_ns: 1.0,
},
lrs: 1.0,
band: RiskBand::Low,
suppression_reason: None,
patterns: vec![],
pattern_details: None,
callees: vec![],
explanation: None,
};
let mk = |root: &str| {
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: None,
author: None,
is_fix_commit: Some(false),
is_revert_commit: Some(false),
ticket_ids: vec![],
};
Snapshot::new(
git_context,
vec![
mk_report("foo", format!("{root}/src/x.rs"), 10),
mk_report("bar", format!("{root}/src/y.rs"), 5),
],
)
};
let parent = mk("/tmp/worktree-aaaa");
let current = mk("/tmp/worktree-bbbb");
let delta = Delta::new(¤t, Some(&parent)).expect("should create delta");
assert_eq!(
delta.deltas.len(),
2,
"expected exactly 2 matched functions, not 4 (2 new + 2 deleted)"
);
for entry in &delta.deltas {
assert_eq!(
entry.status,
FunctionStatus::Unchanged,
"function_id {} should match across worktree roots",
entry.function_id
);
}
}
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![],
explanation: None,
};
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_compute_delta_missing_parent_snapshot_is_still_baseline() {
let repo_root_dir = tempfile::tempdir().expect("failed to create temp dir");
let repo_root = repo_root_dir.path();
let current = create_test_snapshot("current123", "parent123", 5, 4.8, "moderate");
let delta =
compute_delta(repo_root, ¤t).expect("should compute baseline delta, not error");
assert!(delta.baseline);
assert_eq!(delta.commit.parent, "parent123");
assert_eq!(delta.deltas.len(), 1);
assert_eq!(delta.deltas[0].status, FunctionStatus::New);
}
#[test]
fn test_compute_delta_true_root_commit_is_baseline() {
let repo_root_dir = tempfile::tempdir().expect("failed to create temp dir");
let repo_root = repo_root_dir.path();
let mut current = create_test_snapshot("root123", "unused", 5, 4.8, "moderate");
current.commit.parents.clear();
assert!(current.commit.parents.is_empty());
let delta = compute_delta(repo_root, ¤t).expect("should compute 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());
}
fn make_fn_snapshot(name: &str, file: &str, line: u32) -> FunctionSnapshot {
let git_context = GitContext {
head_sha: "sha".to_string(),
parent_shas: vec![],
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: file.to_string(),
function: name.to_string(),
line,
language: Language::TypeScript,
metrics: MetricsReport {
cc: 1,
nd: 1,
fo: 1,
ns: 1,
loc: 5,
},
risk: crate::report::RiskReport {
r_cc: 1.0,
r_nd: 1.0,
r_fo: 1.0,
r_ns: 1.0,
},
lrs: 1.0,
band: RiskBand::Low,
suppression_reason: None,
patterns: vec![],
pattern_details: None,
callees: vec![],
explanation: None,
};
Snapshot::new(git_context, vec![report]).functions[0].clone()
}
fn make_delta_entry(function_id: &str, status: FunctionStatus) -> FunctionDeltaEntry {
FunctionDeltaEntry {
function_id: function_id.to_string(),
status,
before: None,
after: None,
delta: None,
band_transition: None,
suppression_reason: None,
rename_hint: None,
}
}
#[test]
fn test_apply_rename_hints_is_order_independent() {
let del_a = make_fn_snapshot("a.ts::foo", "a.ts", 1);
let del_b = make_fn_snapshot("b.ts::foo", "b.ts", 1);
let new_c = make_fn_snapshot("c.ts::foo", "c.ts", 1);
let parent_funcs: HashMap<&str, &FunctionSnapshot> =
[("a.ts::foo", &del_a), ("b.ts::foo", &del_b)]
.into_iter()
.collect();
let current_funcs: HashMap<&str, &FunctionSnapshot> =
[("c.ts::foo", &new_c)].into_iter().collect();
let mut deltas_order1 = vec![
make_delta_entry("b.ts::foo", FunctionStatus::Deleted),
make_delta_entry("a.ts::foo", FunctionStatus::Deleted),
make_delta_entry("c.ts::foo", FunctionStatus::New),
];
apply_rename_hints(&mut deltas_order1, &parent_funcs, ¤t_funcs);
let mut deltas_order2 = vec![
make_delta_entry("a.ts::foo", FunctionStatus::Deleted),
make_delta_entry("c.ts::foo", FunctionStatus::New),
make_delta_entry("b.ts::foo", FunctionStatus::Deleted),
];
apply_rename_hints(&mut deltas_order2, &parent_funcs, ¤t_funcs);
let hint_for = |deltas: &[FunctionDeltaEntry], id: &str| {
deltas
.iter()
.find(|e| e.function_id == id)
.and_then(|e| e.rename_hint.clone())
};
assert_eq!(
hint_for(&deltas_order1, "a.ts::foo"),
hint_for(&deltas_order2, "a.ts::foo")
);
assert_eq!(
hint_for(&deltas_order1, "b.ts::foo"),
hint_for(&deltas_order2, "b.ts::foo")
);
let matched_count = [
hint_for(&deltas_order1, "a.ts::foo"),
hint_for(&deltas_order1, "b.ts::foo"),
]
.iter()
.filter(|h| h.is_some())
.count();
assert_eq!(matched_count, 1);
}
}