use serde::Serialize;
use crate::error::DiffError;
use crate::model::{FileChangeStatus, ImpactEntry, SymbolResult, VcsFileChange};
use crate::port::{DiffStore, VcsProvider};
#[derive(Debug, Serialize)]
pub struct DiffResult {
pub changed_files: Vec<ChangedFile>,
pub unindexed_files: Vec<String>,
pub impact: Vec<ImpactEntry>,
pub summary: DiffSummary,
}
#[derive(Debug, Serialize)]
pub struct ChangedFile {
pub path: String,
pub status: FileChangeStatus,
pub affected_symbols: Vec<SymbolResult>,
}
#[derive(Debug, Serialize)]
pub struct DiffSummary {
pub changed_files: usize,
pub affected_symbols: usize,
pub impacted_symbols: usize,
pub unindexed: usize,
}
pub fn diff_working_tree(
vcs: &dyn VcsProvider,
db: &dyn DiffStore,
impact_depth: i64,
supported_ext: &[&str],
) -> Result<DiffResult, DiffError> {
let changes = vcs.working_tree_changes(None)?;
process_diff(db, &changes, impact_depth, supported_ext)
}
pub fn diff_against_base(
vcs: &dyn VcsProvider,
base: &str,
db: &dyn DiffStore,
impact_depth: i64,
supported_ext: &[&str],
) -> Result<DiffResult, DiffError> {
let changes = vcs.changes_between_refs(base, "HEAD")?;
process_diff(db, &changes, impact_depth, supported_ext)
}
fn process_diff(
db: &dyn DiffStore,
changes: &[VcsFileChange],
impact_depth: i64,
supported_ext: &[&str],
) -> Result<DiffResult, DiffError> {
let mut changed_files = Vec::new();
let mut unindexed_files = Vec::new();
let mut all_affected_symbol_ids = Vec::new();
for change in changes {
let is_supported = std::path::Path::new(&change.path)
.extension()
.and_then(|e| e.to_str())
.is_some_and(|ext| supported_ext.contains(&ext));
if !is_supported {
continue;
}
let file_id = db.get_file_id(&change.path)?;
if file_id.is_none() && change.status != FileChangeStatus::Deleted {
unindexed_files.push(change.path.clone());
continue;
}
match change.status {
FileChangeStatus::Deleted => {
changed_files.push(ChangedFile {
path: change.path.clone(),
status: change.status,
affected_symbols: vec![],
});
}
FileChangeStatus::Added => {
let symbols = db.symbols_in_line_range(&change.path, 1, i64::MAX)?;
for s in &symbols {
all_affected_symbol_ids.push(s.id);
}
changed_files.push(ChangedFile {
path: change.path.clone(),
status: change.status,
affected_symbols: symbols,
});
}
FileChangeStatus::Modified | FileChangeStatus::Renamed => {
if change.changed_lines.is_empty() {
let symbols = db.symbols_in_line_range(&change.path, 1, i64::MAX)?;
for s in &symbols {
all_affected_symbol_ids.push(s.id);
}
changed_files.push(ChangedFile {
path: change.path.clone(),
status: change.status,
affected_symbols: symbols,
});
} else {
let mut affected = Vec::new();
for (start, end) in &change.changed_lines {
let symbols = db.symbols_in_line_range(&change.path, *start, *end)?;
for s in symbols {
if !affected.iter().any(|a: &SymbolResult| a.id == s.id) {
all_affected_symbol_ids.push(s.id);
affected.push(s);
}
}
}
changed_files.push(ChangedFile {
path: change.path.clone(),
status: change.status,
affected_symbols: affected,
});
}
}
}
}
let mut impact = Vec::new();
let mut seen_ids = std::collections::HashSet::new();
for sym_id in &all_affected_symbol_ids {
seen_ids.insert(*sym_id);
}
for sym_id in &all_affected_symbol_ids {
let entries = db.impact_analysis(*sym_id, impact_depth)?;
for entry in entries {
if seen_ids.insert(entry.symbol_id) {
impact.push(entry);
}
}
}
let total_affected = all_affected_symbol_ids.len();
let summary = DiffSummary {
changed_files: changed_files.len(),
affected_symbols: total_affected,
impacted_symbols: impact.len(),
unindexed: unindexed_files.len(),
};
Ok(DiffResult {
changed_files,
unindexed_files,
impact,
summary,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::{DbError, DiffError};
use crate::model::{Config, SymbolKind, SymbolWithParent, VcsFileChange};
use crate::port::VcsProvider;
use crate::{ConfigStore, SymbolLookup};
use std::collections::HashMap;
struct MockVcs {
working_changes: Vec<VcsFileChange>,
between_changes: Vec<VcsFileChange>,
}
impl VcsProvider for MockVcs {
fn working_tree_changes(
&self,
_base_ref: Option<&str>,
) -> Result<Vec<VcsFileChange>, DiffError> {
Ok(self.working_changes.clone())
}
fn changes_between_refs(
&self,
_from: &str,
_to: &str,
) -> Result<Vec<VcsFileChange>, DiffError> {
Ok(self.between_changes.clone())
}
fn read_file_at_ref(&self, _path: &str, _ref: &str) -> Result<Option<String>, DiffError> {
Ok(None)
}
fn commit_count(&self, _from: &str, _to: &str) -> Result<usize, DiffError> {
Ok(0)
}
fn resolve_ref(&self, _refspec: &str) -> Result<bool, DiffError> {
Ok(false)
}
fn head_sha(&self) -> Result<String, DiffError> {
Ok("abc".to_string())
}
fn root_commit_sha(&self) -> Result<Option<String>, DiffError> {
Ok(None)
}
}
struct MockDiffStore {
file_ids: HashMap<String, i64>,
symbols: HashMap<String, Vec<SymbolResult>>,
impacts: HashMap<i64, Vec<ImpactEntry>>,
}
impl MockDiffStore {
fn new() -> Self {
Self {
file_ids: HashMap::new(),
symbols: HashMap::new(),
impacts: HashMap::new(),
}
}
}
impl ConfigStore for MockDiffStore {
fn config_get(&self, _key: &str) -> Result<Option<String>, DbError> {
Ok(None)
}
fn config_set(&self, _key: &str, _value: &str) -> Result<(), DbError> {
Ok(())
}
fn config_list(&self) -> Result<Vec<Config>, DbError> {
Ok(vec![])
}
}
impl SymbolLookup for MockDiffStore {
fn get_file_id(&self, path: &str) -> Result<Option<i64>, DbError> {
Ok(self.file_ids.get(path).copied())
}
fn get_symbol_by_id(&self, _id: i64) -> Result<Option<crate::Symbol>, DbError> {
Ok(None)
}
fn search_symbols_by_name(
&self,
_name: &str,
_limit: i64,
) -> Result<Vec<SymbolResult>, DbError> {
Ok(vec![])
}
fn get_symbols_for_file(&self, _file_id: i64) -> Result<Vec<crate::Symbol>, DbError> {
Ok(vec![])
}
fn get_children(&self, _symbol_id: i64) -> Result<Vec<crate::Symbol>, DbError> {
Ok(vec![])
}
fn impact_analysis(&self, id: i64, _depth: i64) -> Result<Vec<ImpactEntry>, DbError> {
Ok(self.impacts.get(&id).cloned().unwrap_or_default())
}
fn stats(&self) -> Result<(i64, i64, i64), DbError> {
Ok((0, 0, 0))
}
}
impl DiffStore for MockDiffStore {
fn symbols_in_line_range(
&self,
path: &str,
start: i64,
end: i64,
) -> Result<Vec<SymbolResult>, DbError> {
Ok(self
.symbols
.get(path)
.map(|syms| {
syms.iter()
.filter(|s| s.start_line <= end && s.end_line >= start)
.cloned()
.collect()
})
.unwrap_or_default())
}
fn all_symbols_for_file(&self, _path: &str) -> Result<Vec<SymbolWithParent>, DbError> {
Ok(vec![])
}
}
fn make_sym(id: i64, name: &str, path: &str, start: i64, end: i64) -> SymbolResult {
SymbolResult {
id,
name: name.to_string(),
kind: SymbolKind::Function,
file_path: path.to_string(),
start_line: start,
end_line: end,
signature: None,
summary: None,
}
}
fn make_impact(id: i64, name: &str, path: &str, depth: i64) -> ImpactEntry {
ImpactEntry {
symbol_id: id,
symbol_name: name.to_string(),
symbol_kind: SymbolKind::Function,
file_path: path.to_string(),
depth,
dep_type: crate::model::DepType::Calls,
}
}
#[test]
fn test_process_diff_deleted_file() {
let db = MockDiffStore::new();
let changes = vec![VcsFileChange {
path: "src/old.rs".to_string(),
old_path: None,
status: FileChangeStatus::Deleted,
changed_lines: vec![],
}];
let result = process_diff(&db, &changes, 3, &["rs"]).unwrap();
assert_eq!(result.changed_files.len(), 1);
assert_eq!(result.changed_files[0].status, FileChangeStatus::Deleted);
assert!(result.changed_files[0].affected_symbols.is_empty());
}
#[test]
fn test_process_diff_added_file() {
let mut db = MockDiffStore::new();
db.file_ids.insert("src/new.rs".to_string(), 1);
db.symbols.insert(
"src/new.rs".to_string(),
vec![make_sym(10, "new_fn", "src/new.rs", 1, 10)],
);
let changes = vec![VcsFileChange {
path: "src/new.rs".to_string(),
old_path: None,
status: FileChangeStatus::Added,
changed_lines: vec![],
}];
let result = process_diff(&db, &changes, 3, &["rs"]).unwrap();
assert_eq!(result.changed_files.len(), 1);
assert_eq!(result.changed_files[0].status, FileChangeStatus::Added);
assert_eq!(result.changed_files[0].affected_symbols.len(), 1);
assert_eq!(result.summary.affected_symbols, 1);
}
#[test]
fn test_process_diff_modified_with_lines() {
let mut db = MockDiffStore::new();
db.file_ids.insert("src/lib.rs".to_string(), 1);
db.symbols.insert(
"src/lib.rs".to_string(),
vec![
make_sym(10, "foo", "src/lib.rs", 1, 10),
make_sym(11, "bar", "src/lib.rs", 20, 30),
],
);
let changes = vec![VcsFileChange {
path: "src/lib.rs".to_string(),
old_path: None,
status: FileChangeStatus::Modified,
changed_lines: vec![(5, 7)],
}];
let result = process_diff(&db, &changes, 3, &["rs"]).unwrap();
assert_eq!(result.changed_files.len(), 1);
assert_eq!(result.changed_files[0].affected_symbols.len(), 1);
assert_eq!(result.changed_files[0].affected_symbols[0].name, "foo");
}
#[test]
fn test_process_diff_modified_no_lines() {
let mut db = MockDiffStore::new();
db.file_ids.insert("src/lib.rs".to_string(), 1);
db.symbols.insert(
"src/lib.rs".to_string(),
vec![make_sym(10, "foo", "src/lib.rs", 1, 10)],
);
let changes = vec![VcsFileChange {
path: "src/lib.rs".to_string(),
old_path: None,
status: FileChangeStatus::Modified,
changed_lines: vec![],
}];
let result = process_diff(&db, &changes, 3, &["rs"]).unwrap();
assert_eq!(result.changed_files[0].affected_symbols.len(), 1);
}
#[test]
fn test_process_diff_unindexed_file() {
let db = MockDiffStore::new();
let changes = vec![VcsFileChange {
path: "src/unknown.rs".to_string(),
old_path: None,
status: FileChangeStatus::Added,
changed_lines: vec![],
}];
let result = process_diff(&db, &changes, 3, &["rs"]).unwrap();
assert_eq!(result.unindexed_files, vec!["src/unknown.rs"]);
assert_eq!(result.summary.unindexed, 1);
}
#[test]
fn test_process_diff_filters_unsupported_ext() {
let db = MockDiffStore::new();
let changes = vec![VcsFileChange {
path: "README.md".to_string(),
old_path: None,
status: FileChangeStatus::Modified,
changed_lines: vec![],
}];
let result = process_diff(&db, &changes, 3, &["rs"]).unwrap();
assert!(result.changed_files.is_empty());
}
#[test]
fn test_process_diff_impact_dedup() {
let mut db = MockDiffStore::new();
db.file_ids.insert("src/a.rs".to_string(), 1);
db.symbols.insert(
"src/a.rs".to_string(),
vec![
make_sym(10, "fn_a", "src/a.rs", 1, 5),
make_sym(11, "fn_b", "src/a.rs", 6, 10),
],
);
db.impacts
.insert(10, vec![make_impact(99, "downstream", "src/b.rs", 1)]);
db.impacts
.insert(11, vec![make_impact(99, "downstream", "src/b.rs", 1)]);
let changes = vec![VcsFileChange {
path: "src/a.rs".to_string(),
old_path: None,
status: FileChangeStatus::Modified,
changed_lines: vec![(1, 10)],
}];
let result = process_diff(&db, &changes, 3, &["rs"]).unwrap();
assert_eq!(result.impact.len(), 1);
assert_eq!(result.impact[0].symbol_name, "downstream");
}
#[test]
fn test_diff_working_tree_delegates() {
let vcs = MockVcs {
working_changes: vec![],
between_changes: vec![],
};
let db = MockDiffStore::new();
let result = diff_working_tree(&vcs, &db, 3, &["rs"]).unwrap();
assert!(result.changed_files.is_empty());
}
#[test]
fn test_diff_against_base_delegates() {
let vcs = MockVcs {
working_changes: vec![],
between_changes: vec![],
};
let db = MockDiffStore::new();
let result = diff_against_base(&vcs, "main", &db, 3, &["rs"]).unwrap();
assert!(result.changed_files.is_empty());
}
#[test]
fn test_process_diff_renamed_file() {
let mut db = MockDiffStore::new();
db.file_ids.insert("src/renamed.rs".to_string(), 1);
db.symbols.insert(
"src/renamed.rs".to_string(),
vec![make_sym(10, "foo", "src/renamed.rs", 1, 10)],
);
let changes = vec![VcsFileChange {
path: "src/renamed.rs".to_string(),
old_path: Some("src/old_name.rs".to_string()),
status: FileChangeStatus::Renamed,
changed_lines: vec![(1, 5)],
}];
let result = process_diff(&db, &changes, 3, &["rs"]).unwrap();
assert_eq!(result.changed_files.len(), 1);
assert_eq!(result.changed_files[0].status, FileChangeStatus::Renamed);
assert_eq!(result.changed_files[0].affected_symbols.len(), 1);
}
#[test]
fn test_process_diff_dedup_symbols_in_overlapping_ranges() {
let mut db = MockDiffStore::new();
db.file_ids.insert("src/lib.rs".to_string(), 1);
db.symbols.insert(
"src/lib.rs".to_string(),
vec![make_sym(10, "foo", "src/lib.rs", 1, 20)],
);
let changes = vec![VcsFileChange {
path: "src/lib.rs".to_string(),
old_path: None,
status: FileChangeStatus::Modified,
changed_lines: vec![(3, 5), (10, 15)],
}];
let result = process_diff(&db, &changes, 3, &["rs"]).unwrap();
assert_eq!(result.changed_files[0].affected_symbols.len(), 1);
assert_eq!(result.summary.affected_symbols, 1);
}
}