use std::collections::HashMap;
use std::path::Path;
use serde::Serialize;
use tracing::warn;
use crate::error::DiffError;
use crate::model::{ExtractedSymbol, FileChangeStatus, ImpactEntry, SymbolKind, SymbolWithParent};
use crate::port::{DiffStore, LanguageRegistry, VcsProvider};
#[derive(Debug, Serialize)]
pub struct SinceResult {
pub reference: String,
pub commit_count: usize,
pub added: Vec<SymbolChange>,
pub modified: Vec<SymbolChange>,
pub removed: Vec<SymbolChange>,
pub downstream_impacts: Vec<ImpactEntry>,
pub summary: SinceSummary,
}
#[derive(Debug, Serialize)]
pub struct SymbolChange {
pub name: String,
pub kind: SymbolKind,
pub file: String,
pub line: u32,
pub parent_name: Option<String>,
pub old_signature: Option<String>,
pub new_signature: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct SinceSummary {
pub added_count: usize,
pub modified_count: usize,
pub removed_count: usize,
pub impact_count: usize,
pub files_changed: usize,
}
#[derive(Hash, Eq, PartialEq)]
struct SymbolKey {
name: String,
kind: SymbolKind,
parent_name: Option<String>,
}
fn resolve_parent_name(symbols: &[ExtractedSymbol], sym: &ExtractedSymbol) -> Option<String> {
sym.parent_index
.and_then(|idx| symbols.get(idx).map(|p| p.name.clone()))
}
const BUDGET_GUARD_THRESHOLD: usize = 5;
pub fn since_against_ref(
vcs: &dyn VcsProvider,
reference: &str,
db: &dyn DiffStore,
impact_depth: i64,
supported_ext: &[&str],
lang: &dyn LanguageRegistry,
) -> Result<SinceResult, DiffError> {
let vcs_changes = vcs.changes_between_refs(reference, "HEAD")?;
let commit_count = vcs.commit_count(reference, "HEAD")?;
let (mut added, mut modified, mut removed, mut modified_sym_ids) =
(Vec::new(), Vec::new(), Vec::new(), Vec::new());
for change in &vcs_changes {
let primary_path = if change.status == FileChangeStatus::Deleted {
change.old_path.as_deref().unwrap_or(&change.path)
} else {
&change.path
};
let is_supported = Path::new(primary_path)
.extension()
.and_then(|e| e.to_str())
.is_some_and(|ext| supported_ext.contains(&ext));
if !is_supported {
continue;
}
match change.status {
FileChangeStatus::Added => {
let current = db.all_symbols_for_file(&change.path).unwrap_or_default();
for sym in current {
added.push(symbol_change_from_current(sym));
}
}
FileChangeStatus::Deleted => {
let old_path = change.old_path.as_deref().unwrap_or(&change.path);
let Some(old_symbols) = parse_old_blob(vcs, reference, old_path, lang) else {
warn!("skipping unparseable deleted file: {}", old_path);
continue;
};
for sym in &old_symbols {
removed.push(symbol_change_from_old(sym, &old_symbols, old_path));
}
}
FileChangeStatus::Modified | FileChangeStatus::Renamed => {
let old_path = change.old_path.as_deref().unwrap_or(&change.path);
let Some(old_symbols) = parse_old_blob(vcs, reference, old_path, lang) else {
warn!("skipping unparseable file: {}", old_path);
continue;
};
let current = db.all_symbols_for_file(&change.path).unwrap_or_default();
let (file_added, file_modified, file_removed, file_mod_ids) =
compare_symbols(&old_symbols, ¤t, &change.path);
added.extend(file_added);
modified.extend(file_modified);
removed.extend(file_removed);
modified_sym_ids.extend(file_mod_ids);
}
}
}
let mut downstream_impacts = Vec::new();
let mut seen_ids = std::collections::HashSet::new();
for sym_id in &modified_sym_ids {
seen_ids.insert(*sym_id);
}
for sym_id in &modified_sym_ids {
if let Ok(entries) = db.impact_analysis(*sym_id, impact_depth) {
for entry in entries {
if seen_ids.insert(entry.symbol_id) {
downstream_impacts.push(entry);
}
}
}
}
let files_changed = vcs_changes
.iter()
.filter(|c| {
let primary_path = if c.status == FileChangeStatus::Deleted {
c.old_path.as_deref().unwrap_or(&c.path)
} else {
&c.path
};
Path::new(primary_path)
.extension()
.and_then(|e| e.to_str())
.is_some_and(|ext| supported_ext.contains(&ext))
})
.count();
let summary = SinceSummary {
added_count: added.len(),
modified_count: modified.len(),
removed_count: removed.len(),
impact_count: downstream_impacts.len(),
files_changed,
};
Ok(SinceResult {
reference: reference.to_string(),
commit_count,
added,
modified,
removed,
downstream_impacts,
summary,
})
}
fn symbol_change_from_current(sym: SymbolWithParent) -> SymbolChange {
SymbolChange {
name: sym.name,
kind: sym.kind,
file: sym.file_path,
line: sym.start_line as u32,
parent_name: sym.parent_name,
old_signature: None,
new_signature: sym.signature,
}
}
fn symbol_change_from_old(
sym: &ExtractedSymbol,
all_old: &[ExtractedSymbol],
file_path: &str,
) -> SymbolChange {
SymbolChange {
name: sym.name.clone(),
kind: sym.kind,
file: file_path.to_string(),
line: sym.start_line as u32,
parent_name: resolve_parent_name(all_old, sym),
old_signature: sym.signature.clone(),
new_signature: None,
}
}
fn parse_old_blob(
vcs: &dyn VcsProvider,
reference: &str,
file_path: &str,
lang: &dyn LanguageRegistry,
) -> Option<Vec<ExtractedSymbol>> {
let ext = Path::new(file_path).extension().and_then(|e| e.to_str());
let parser = match ext.and_then(|e| lang.parser_for(e)) {
Some(p) => p,
None => {
warn!("no parser for old file: {}", file_path);
return None;
}
};
let source = match vcs.read_file_at_ref(file_path, reference) {
Ok(Some(s)) => s,
Ok(None) => {
warn!("old blob not found or binary for {}", file_path);
return None;
}
Err(e) => {
warn!("failed to read old blob for {}: {}", file_path, e);
return None;
}
};
let result = parser.parse(&source, Path::new(file_path));
Some(result.symbols)
}
fn compare_symbols(
old_symbols: &[ExtractedSymbol],
current_symbols: &[SymbolWithParent],
file_path: &str,
) -> (
Vec<SymbolChange>,
Vec<SymbolChange>,
Vec<SymbolChange>,
Vec<i64>,
) {
let mut old_map: HashMap<SymbolKey, Vec<&ExtractedSymbol>> = HashMap::new();
for sym in old_symbols {
let key = SymbolKey {
name: sym.name.clone(),
kind: sym.kind,
parent_name: resolve_parent_name(old_symbols, sym),
};
old_map.entry(key).or_default().push(sym);
}
let mut current_map: HashMap<SymbolKey, Vec<&SymbolWithParent>> = HashMap::new();
for sym in current_symbols {
let key = SymbolKey {
name: sym.name.clone(),
kind: sym.kind,
parent_name: sym.parent_name.clone(),
};
current_map.entry(key).or_default().push(sym);
}
let mut added_out = Vec::new();
let mut modified_out = Vec::new();
let mut removed_out = Vec::new();
let mut modified_ids = Vec::new();
for (key, cur_syms) in ¤t_map {
if let Some(old_syms) = old_map.get(key) {
let paired = old_syms.len().min(cur_syms.len());
for i in 0..paired {
if old_syms[i].signature != cur_syms[i].signature {
modified_out.push(SymbolChange {
name: cur_syms[i].name.clone(),
kind: cur_syms[i].kind,
file: file_path.to_string(),
line: cur_syms[i].start_line as u32,
parent_name: cur_syms[i].parent_name.clone(),
old_signature: old_syms[i].signature.clone(),
new_signature: cur_syms[i].signature.clone(),
});
modified_ids.push(cur_syms[i].id);
}
}
for cur in cur_syms.iter().skip(paired) {
added_out.push(SymbolChange {
name: cur.name.clone(),
kind: cur.kind,
file: file_path.to_string(),
line: cur.start_line as u32,
parent_name: cur.parent_name.clone(),
old_signature: None,
new_signature: cur.signature.clone(),
});
}
} else {
for cur in cur_syms {
added_out.push(SymbolChange {
name: cur.name.clone(),
kind: cur.kind,
file: file_path.to_string(),
line: cur.start_line as u32,
parent_name: cur.parent_name.clone(),
old_signature: None,
new_signature: cur.signature.clone(),
});
}
}
}
for (key, old_syms) in &old_map {
let paired = current_map
.get(key)
.map_or(0, |c| c.len().min(old_syms.len()));
for old in old_syms.iter().skip(paired) {
removed_out.push(symbol_change_from_old(old, old_symbols, file_path));
}
}
let total_changes = added_out.len() + modified_out.len() + removed_out.len();
if total_changes > BUDGET_GUARD_THRESHOLD {
modified_ids.clear();
}
(added_out, modified_out, removed_out, modified_ids)
}
#[cfg(test)]
mod tests {
use super::*;
fn make_old_sym(
name: &str,
kind: SymbolKind,
sig: Option<&str>,
parent_index: Option<usize>,
) -> ExtractedSymbol {
ExtractedSymbol {
name: name.to_string(),
kind,
signature: sig.map(|s| s.to_string()),
start_line: 1,
end_line: 5,
start_byte: 0,
end_byte: 100,
parent_index,
}
}
fn make_cur_sym(
id: i64,
name: &str,
kind: SymbolKind,
sig: Option<&str>,
parent_name: Option<&str>,
) -> SymbolWithParent {
SymbolWithParent {
id,
name: name.to_string(),
kind,
file_path: "src/lib.rs".to_string(),
start_line: 1,
end_line: 5,
signature: sig.map(|s| s.to_string()),
parent_name: parent_name.map(|s| s.to_string()),
}
}
#[test]
fn test_compare_symbols_added_new_key() {
let old_symbols = vec![];
let current_symbols = vec![make_cur_sym(
1,
"new_fn",
SymbolKind::Function,
Some("fn new_fn()"),
None,
)];
let (added, modified, removed, _) =
compare_symbols(&old_symbols, ¤t_symbols, "src/lib.rs");
assert_eq!(added.len(), 1);
assert_eq!(added[0].name, "new_fn");
assert!(modified.is_empty());
assert!(removed.is_empty());
}
#[test]
fn test_compare_symbols_removed_key() {
let old_symbols = vec![make_old_sym(
"old_fn",
SymbolKind::Function,
Some("fn old_fn()"),
None,
)];
let current_symbols = vec![];
let (added, modified, removed, _) =
compare_symbols(&old_symbols, ¤t_symbols, "src/lib.rs");
assert!(added.is_empty());
assert!(modified.is_empty());
assert_eq!(removed.len(), 1);
assert_eq!(removed[0].name, "old_fn");
}
#[test]
fn test_compare_symbols_modified_signature() {
let old_symbols = vec![make_old_sym(
"foo",
SymbolKind::Function,
Some("fn foo()"),
None,
)];
let current_symbols = vec![make_cur_sym(
1,
"foo",
SymbolKind::Function,
Some("fn foo(x: i32)"),
None,
)];
let (added, modified, removed, mod_ids) =
compare_symbols(&old_symbols, ¤t_symbols, "src/lib.rs");
assert!(added.is_empty());
assert_eq!(modified.len(), 1);
assert_eq!(modified[0].name, "foo");
assert_eq!(modified[0].old_signature.as_deref(), Some("fn foo()"));
assert_eq!(modified[0].new_signature.as_deref(), Some("fn foo(x: i32)"));
assert_eq!(mod_ids, vec![1]);
assert!(removed.is_empty());
}
#[test]
fn test_compare_symbols_unchanged_signature() {
let old_symbols = vec![make_old_sym(
"foo",
SymbolKind::Function,
Some("fn foo()"),
None,
)];
let current_symbols = vec![make_cur_sym(
1,
"foo",
SymbolKind::Function,
Some("fn foo()"),
None,
)];
let (added, modified, removed, _) =
compare_symbols(&old_symbols, ¤t_symbols, "src/lib.rs");
assert!(added.is_empty());
assert!(modified.is_empty());
assert!(removed.is_empty());
}
#[test]
fn test_compare_symbols_extra_current_beyond_paired() {
let old_symbols = vec![make_old_sym(
"foo",
SymbolKind::Function,
Some("fn foo()"),
None,
)];
let current_symbols = vec![
make_cur_sym(1, "foo", SymbolKind::Function, Some("fn foo()"), None),
make_cur_sym(2, "foo", SymbolKind::Function, Some("fn foo(x: i32)"), None),
];
let (added, modified, removed, _) =
compare_symbols(&old_symbols, ¤t_symbols, "src/lib.rs");
assert_eq!(added.len(), 1, "extra current symbol should be added");
assert_eq!(added[0].new_signature.as_deref(), Some("fn foo(x: i32)"));
assert!(modified.is_empty());
assert!(removed.is_empty());
}
#[test]
fn test_compare_symbols_extra_old_beyond_paired() {
let old_symbols = vec![
make_old_sym("foo", SymbolKind::Function, Some("fn foo()"), None),
make_old_sym("foo", SymbolKind::Function, Some("fn foo(x: i32)"), None),
];
let current_symbols = vec![make_cur_sym(
1,
"foo",
SymbolKind::Function,
Some("fn foo()"),
None,
)];
let (added, modified, removed, _) =
compare_symbols(&old_symbols, ¤t_symbols, "src/lib.rs");
assert!(added.is_empty());
assert!(modified.is_empty());
assert_eq!(removed.len(), 1, "extra old symbol should be removed");
assert_eq!(removed[0].old_signature.as_deref(), Some("fn foo(x: i32)"));
}
#[test]
fn test_compare_symbols_budget_guard_clears_modified_ids() {
let old_symbols: Vec<ExtractedSymbol> = (0..6)
.map(|i| {
make_old_sym(
&format!("fn_{}", i),
SymbolKind::Function,
Some(&format!("fn fn_{}()", i)),
None,
)
})
.collect();
let current_symbols: Vec<SymbolWithParent> = Vec::new();
let (_, _, removed, mod_ids) =
compare_symbols(&old_symbols, ¤t_symbols, "src/lib.rs");
assert_eq!(removed.len(), 6);
assert!(
mod_ids.is_empty(),
"budget guard should clear modified_ids when total_changes > {}",
BUDGET_GUARD_THRESHOLD
);
}
#[test]
fn test_resolve_parent_name_with_parent() {
let symbols = vec![
make_old_sym("MyClass", SymbolKind::Class, None, None),
make_old_sym("method", SymbolKind::Method, None, Some(0)),
];
let parent = resolve_parent_name(&symbols, &symbols[1]);
assert_eq!(parent, Some("MyClass".to_string()));
}
#[test]
fn test_resolve_parent_name_without_parent() {
let symbols = vec![make_old_sym("top_fn", SymbolKind::Function, None, None)];
let parent = resolve_parent_name(&symbols, &symbols[0]);
assert_eq!(parent, None);
}
#[test]
fn test_resolve_parent_name_out_of_bounds() {
let symbols = vec![make_old_sym("orphan", SymbolKind::Method, None, Some(99))];
let parent = resolve_parent_name(&symbols, &symbols[0]);
assert_eq!(
parent, None,
"out-of-bounds parent_index should return None"
);
}
#[test]
fn test_symbol_change_from_current_fields() {
let sym = SymbolWithParent {
id: 42,
name: "my_fn".to_string(),
kind: SymbolKind::Function,
file_path: "src/lib.rs".to_string(),
start_line: 10,
end_line: 20,
signature: Some("fn my_fn(x: i32)".to_string()),
parent_name: Some("MyStruct".to_string()),
};
let change = symbol_change_from_current(sym);
assert_eq!(change.name, "my_fn");
assert_eq!(change.kind, SymbolKind::Function);
assert_eq!(change.file, "src/lib.rs");
assert_eq!(change.line, 10);
assert_eq!(change.parent_name, Some("MyStruct".to_string()));
assert!(change.old_signature.is_none());
assert_eq!(change.new_signature, Some("fn my_fn(x: i32)".to_string()));
}
#[test]
fn test_symbol_change_from_old_fields() {
let symbols = vec![
make_old_sym("Parent", SymbolKind::Class, None, None),
ExtractedSymbol {
name: "child".to_string(),
kind: SymbolKind::Method,
signature: Some("def child(self)".to_string()),
start_line: 5,
end_line: 10,
start_byte: 50,
end_byte: 200,
parent_index: Some(0),
},
];
let change = symbol_change_from_old(&symbols[1], &symbols, "src/app.py");
assert_eq!(change.name, "child");
assert_eq!(change.kind, SymbolKind::Method);
assert_eq!(change.file, "src/app.py");
assert_eq!(change.line, 5);
assert_eq!(change.parent_name, Some("Parent".to_string()));
assert_eq!(change.old_signature, Some("def child(self)".to_string()));
assert!(change.new_signature.is_none());
}
}