Skip to main content

astmap_core/diff/
since.rs

1// Symbol-level structural diff across git refs.
2// Compares parsed symbols from old git blobs against current DB symbols.
3
4use std::collections::HashMap;
5use std::path::Path;
6
7use serde::Serialize;
8use tracing::warn;
9
10use crate::error::DiffError;
11use crate::model::{ExtractedSymbol, FileChangeStatus, ImpactEntry, SymbolKind, SymbolWithParent};
12use crate::port::{DiffStore, LanguageRegistry, VcsProvider};
13
14#[derive(Debug, Serialize)]
15pub struct SinceResult {
16    pub reference: String,
17    pub commit_count: usize,
18    pub added: Vec<SymbolChange>,
19    pub modified: Vec<SymbolChange>,
20    pub removed: Vec<SymbolChange>,
21    pub downstream_impacts: Vec<ImpactEntry>,
22    pub summary: SinceSummary,
23}
24
25#[derive(Debug, Serialize)]
26pub struct SymbolChange {
27    pub name: String,
28    pub kind: SymbolKind,
29    pub file: String,
30    pub line: u32,
31    pub parent_name: Option<String>,
32    pub old_signature: Option<String>,
33    pub new_signature: Option<String>,
34}
35
36#[derive(Debug, Serialize)]
37pub struct SinceSummary {
38    pub added_count: usize,
39    pub modified_count: usize,
40    pub removed_count: usize,
41    pub impact_count: usize,
42    pub files_changed: usize,
43}
44
45/// Key for matching symbols across git refs: (name, kind, parent_name).
46#[derive(Hash, Eq, PartialEq)]
47struct SymbolKey {
48    name: String,
49    kind: SymbolKind,
50    parent_name: Option<String>,
51}
52
53/// Resolve parent_index in ExtractedSymbol to parent name string.
54fn resolve_parent_name(symbols: &[ExtractedSymbol], sym: &ExtractedSymbol) -> Option<String> {
55    sym.parent_index
56        .and_then(|idx| symbols.get(idx).map(|p| p.name.clone()))
57}
58
59/// Max changed symbols per file before skipping per-symbol impact analysis.
60const BUDGET_GUARD_THRESHOLD: usize = 5;
61
62/// Show structural changes (added/removed/modified symbols) since a git reference.
63///
64/// Expects the caller to have run a scan first to ensure the DB matches the current working tree.
65/// Parses old file content from git history and compares against current DB symbols.
66pub fn since_against_ref(
67    vcs: &dyn VcsProvider,
68    reference: &str,
69    db: &dyn DiffStore,
70    impact_depth: i64,
71    supported_ext: &[&str],
72    lang: &dyn LanguageRegistry,
73) -> Result<SinceResult, DiffError> {
74    // Phase 1: get changes and commit count from VCS
75    let vcs_changes = vcs.changes_between_refs(reference, "HEAD")?;
76    let commit_count = vcs.commit_count(reference, "HEAD")?;
77
78    // Phase 2: filter by supported extensions and process deltas
79    let (mut added, mut modified, mut removed, mut modified_sym_ids) =
80        (Vec::new(), Vec::new(), Vec::new(), Vec::new());
81
82    for change in &vcs_changes {
83        // Filter by supported extensions
84        let primary_path = if change.status == FileChangeStatus::Deleted {
85            change.old_path.as_deref().unwrap_or(&change.path)
86        } else {
87            &change.path
88        };
89        let is_supported = Path::new(primary_path)
90            .extension()
91            .and_then(|e| e.to_str())
92            .is_some_and(|ext| supported_ext.contains(&ext));
93        if !is_supported {
94            continue;
95        }
96
97        match change.status {
98            FileChangeStatus::Added => {
99                let current = db.all_symbols_for_file(&change.path).unwrap_or_default();
100                for sym in current {
101                    added.push(symbol_change_from_current(sym));
102                }
103            }
104            FileChangeStatus::Deleted => {
105                let old_path = change.old_path.as_deref().unwrap_or(&change.path);
106                let Some(old_symbols) = parse_old_blob(vcs, reference, old_path, lang) else {
107                    warn!("skipping unparseable deleted file: {}", old_path);
108                    continue;
109                };
110                for sym in &old_symbols {
111                    removed.push(symbol_change_from_old(sym, &old_symbols, old_path));
112                }
113            }
114            FileChangeStatus::Modified | FileChangeStatus::Renamed => {
115                let old_path = change.old_path.as_deref().unwrap_or(&change.path);
116                let Some(old_symbols) = parse_old_blob(vcs, reference, old_path, lang) else {
117                    warn!("skipping unparseable file: {}", old_path);
118                    continue;
119                };
120                let current = db.all_symbols_for_file(&change.path).unwrap_or_default();
121
122                let (file_added, file_modified, file_removed, file_mod_ids) =
123                    compare_symbols(&old_symbols, &current, &change.path);
124                added.extend(file_added);
125                modified.extend(file_modified);
126                removed.extend(file_removed);
127                modified_sym_ids.extend(file_mod_ids);
128            }
129        }
130    }
131
132    // Phase 3: impact analysis for modified symbols
133    let mut downstream_impacts = Vec::new();
134    let mut seen_ids = std::collections::HashSet::new();
135    for sym_id in &modified_sym_ids {
136        seen_ids.insert(*sym_id);
137    }
138    for sym_id in &modified_sym_ids {
139        if let Ok(entries) = db.impact_analysis(*sym_id, impact_depth) {
140            for entry in entries {
141                if seen_ids.insert(entry.symbol_id) {
142                    downstream_impacts.push(entry);
143                }
144            }
145        }
146    }
147
148    // Count files that passed the extension filter
149    let files_changed = vcs_changes
150        .iter()
151        .filter(|c| {
152            let primary_path = if c.status == FileChangeStatus::Deleted {
153                c.old_path.as_deref().unwrap_or(&c.path)
154            } else {
155                &c.path
156            };
157            Path::new(primary_path)
158                .extension()
159                .and_then(|e| e.to_str())
160                .is_some_and(|ext| supported_ext.contains(&ext))
161        })
162        .count();
163
164    let summary = SinceSummary {
165        added_count: added.len(),
166        modified_count: modified.len(),
167        removed_count: removed.len(),
168        impact_count: downstream_impacts.len(),
169        files_changed,
170    };
171
172    Ok(SinceResult {
173        reference: reference.to_string(),
174        commit_count,
175        added,
176        modified,
177        removed,
178        downstream_impacts,
179        summary,
180    })
181}
182
183/// Build a SymbolChange from a current DB symbol (added case).
184fn symbol_change_from_current(sym: SymbolWithParent) -> SymbolChange {
185    SymbolChange {
186        name: sym.name,
187        kind: sym.kind,
188        file: sym.file_path,
189        line: sym.start_line as u32,
190        parent_name: sym.parent_name,
191        old_signature: None,
192        new_signature: sym.signature,
193    }
194}
195
196/// Build a SymbolChange from an old parsed symbol (removed case).
197fn symbol_change_from_old(
198    sym: &ExtractedSymbol,
199    all_old: &[ExtractedSymbol],
200    file_path: &str,
201) -> SymbolChange {
202    SymbolChange {
203        name: sym.name.clone(),
204        kind: sym.kind,
205        file: file_path.to_string(),
206        line: sym.start_line as u32,
207        parent_name: resolve_parent_name(all_old, sym),
208        old_signature: sym.signature.clone(),
209        new_signature: None,
210    }
211}
212
213/// Parse old file content from a git ref and extract symbols with tree-sitter.
214/// Returns `None` on failure (unsupported language, missing blob, binary content)
215/// so callers can distinguish "parse failed" from "file has no symbols".
216fn parse_old_blob(
217    vcs: &dyn VcsProvider,
218    reference: &str,
219    file_path: &str,
220    lang: &dyn LanguageRegistry,
221) -> Option<Vec<ExtractedSymbol>> {
222    let ext = Path::new(file_path).extension().and_then(|e| e.to_str());
223    let parser = match ext.and_then(|e| lang.parser_for(e)) {
224        Some(p) => p,
225        None => {
226            warn!("no parser for old file: {}", file_path);
227            return None;
228        }
229    };
230
231    let source = match vcs.read_file_at_ref(file_path, reference) {
232        Ok(Some(s)) => s,
233        Ok(None) => {
234            warn!("old blob not found or binary for {}", file_path);
235            return None;
236        }
237        Err(e) => {
238            warn!("failed to read old blob for {}: {}", file_path, e);
239            return None;
240        }
241    };
242
243    let result = parser.parse(&source, Path::new(file_path));
244    Some(result.symbols)
245}
246
247/// Compare old (git blob parsed) symbols against current (DB) symbols for one file.
248/// Uses a multimap to handle duplicate symbol names (e.g. overloaded methods).
249/// Returns (added, modified, removed, modified_sym_ids).
250fn compare_symbols(
251    old_symbols: &[ExtractedSymbol],
252    current_symbols: &[SymbolWithParent],
253    file_path: &str,
254) -> (
255    Vec<SymbolChange>,
256    Vec<SymbolChange>,
257    Vec<SymbolChange>,
258    Vec<i64>,
259) {
260    // Build multimaps: key -> Vec<symbol> (preserves insertion order = file order)
261    let mut old_map: HashMap<SymbolKey, Vec<&ExtractedSymbol>> = HashMap::new();
262    for sym in old_symbols {
263        let key = SymbolKey {
264            name: sym.name.clone(),
265            kind: sym.kind,
266            parent_name: resolve_parent_name(old_symbols, sym),
267        };
268        old_map.entry(key).or_default().push(sym);
269    }
270
271    let mut current_map: HashMap<SymbolKey, Vec<&SymbolWithParent>> = HashMap::new();
272    for sym in current_symbols {
273        let key = SymbolKey {
274            name: sym.name.clone(),
275            kind: sym.kind,
276            parent_name: sym.parent_name.clone(),
277        };
278        current_map.entry(key).or_default().push(sym);
279    }
280
281    let mut added_out = Vec::new();
282    let mut modified_out = Vec::new();
283    let mut removed_out = Vec::new();
284    let mut modified_ids = Vec::new();
285
286    // For each key in current: pair with old by position, extras are "added"
287    for (key, cur_syms) in &current_map {
288        if let Some(old_syms) = old_map.get(key) {
289            let paired = old_syms.len().min(cur_syms.len());
290            for i in 0..paired {
291                if old_syms[i].signature != cur_syms[i].signature {
292                    modified_out.push(SymbolChange {
293                        name: cur_syms[i].name.clone(),
294                        kind: cur_syms[i].kind,
295                        file: file_path.to_string(),
296                        line: cur_syms[i].start_line as u32,
297                        parent_name: cur_syms[i].parent_name.clone(),
298                        old_signature: old_syms[i].signature.clone(),
299                        new_signature: cur_syms[i].signature.clone(),
300                    });
301                    modified_ids.push(cur_syms[i].id);
302                }
303            }
304            for cur in cur_syms.iter().skip(paired) {
305                added_out.push(SymbolChange {
306                    name: cur.name.clone(),
307                    kind: cur.kind,
308                    file: file_path.to_string(),
309                    line: cur.start_line as u32,
310                    parent_name: cur.parent_name.clone(),
311                    old_signature: None,
312                    new_signature: cur.signature.clone(),
313                });
314            }
315        } else {
316            for cur in cur_syms {
317                added_out.push(SymbolChange {
318                    name: cur.name.clone(),
319                    kind: cur.kind,
320                    file: file_path.to_string(),
321                    line: cur.start_line as u32,
322                    parent_name: cur.parent_name.clone(),
323                    old_signature: None,
324                    new_signature: cur.signature.clone(),
325                });
326            }
327        }
328    }
329
330    // For each key in old: extras beyond paired count are "removed"
331    for (key, old_syms) in &old_map {
332        let paired = current_map
333            .get(key)
334            .map_or(0, |c| c.len().min(old_syms.len()));
335        for old in old_syms.iter().skip(paired) {
336            removed_out.push(symbol_change_from_old(old, old_symbols, file_path));
337        }
338    }
339
340    // Budget guard: skip impact analysis if too many changes in this file
341    let total_changes = added_out.len() + modified_out.len() + removed_out.len();
342    if total_changes > BUDGET_GUARD_THRESHOLD {
343        modified_ids.clear();
344    }
345
346    (added_out, modified_out, removed_out, modified_ids)
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    // --- Pure function unit tests for compare_symbols ---
354
355    fn make_old_sym(
356        name: &str,
357        kind: SymbolKind,
358        sig: Option<&str>,
359        parent_index: Option<usize>,
360    ) -> ExtractedSymbol {
361        ExtractedSymbol {
362            name: name.to_string(),
363            kind,
364            signature: sig.map(|s| s.to_string()),
365            start_line: 1,
366            end_line: 5,
367            start_byte: 0,
368            end_byte: 100,
369            parent_index,
370        }
371    }
372
373    fn make_cur_sym(
374        id: i64,
375        name: &str,
376        kind: SymbolKind,
377        sig: Option<&str>,
378        parent_name: Option<&str>,
379    ) -> SymbolWithParent {
380        SymbolWithParent {
381            id,
382            name: name.to_string(),
383            kind,
384            file_path: "src/lib.rs".to_string(),
385            start_line: 1,
386            end_line: 5,
387            signature: sig.map(|s| s.to_string()),
388            parent_name: parent_name.map(|s| s.to_string()),
389        }
390    }
391
392    #[test]
393    fn test_compare_symbols_added_new_key() {
394        let old_symbols = vec![];
395        let current_symbols = vec![make_cur_sym(
396            1,
397            "new_fn",
398            SymbolKind::Function,
399            Some("fn new_fn()"),
400            None,
401        )];
402        let (added, modified, removed, _) =
403            compare_symbols(&old_symbols, &current_symbols, "src/lib.rs");
404        assert_eq!(added.len(), 1);
405        assert_eq!(added[0].name, "new_fn");
406        assert!(modified.is_empty());
407        assert!(removed.is_empty());
408    }
409
410    #[test]
411    fn test_compare_symbols_removed_key() {
412        let old_symbols = vec![make_old_sym(
413            "old_fn",
414            SymbolKind::Function,
415            Some("fn old_fn()"),
416            None,
417        )];
418        let current_symbols = vec![];
419        let (added, modified, removed, _) =
420            compare_symbols(&old_symbols, &current_symbols, "src/lib.rs");
421        assert!(added.is_empty());
422        assert!(modified.is_empty());
423        assert_eq!(removed.len(), 1);
424        assert_eq!(removed[0].name, "old_fn");
425    }
426
427    #[test]
428    fn test_compare_symbols_modified_signature() {
429        let old_symbols = vec![make_old_sym(
430            "foo",
431            SymbolKind::Function,
432            Some("fn foo()"),
433            None,
434        )];
435        let current_symbols = vec![make_cur_sym(
436            1,
437            "foo",
438            SymbolKind::Function,
439            Some("fn foo(x: i32)"),
440            None,
441        )];
442        let (added, modified, removed, mod_ids) =
443            compare_symbols(&old_symbols, &current_symbols, "src/lib.rs");
444        assert!(added.is_empty());
445        assert_eq!(modified.len(), 1);
446        assert_eq!(modified[0].name, "foo");
447        assert_eq!(modified[0].old_signature.as_deref(), Some("fn foo()"));
448        assert_eq!(modified[0].new_signature.as_deref(), Some("fn foo(x: i32)"));
449        assert_eq!(mod_ids, vec![1]);
450        assert!(removed.is_empty());
451    }
452
453    #[test]
454    fn test_compare_symbols_unchanged_signature() {
455        let old_symbols = vec![make_old_sym(
456            "foo",
457            SymbolKind::Function,
458            Some("fn foo()"),
459            None,
460        )];
461        let current_symbols = vec![make_cur_sym(
462            1,
463            "foo",
464            SymbolKind::Function,
465            Some("fn foo()"),
466            None,
467        )];
468        let (added, modified, removed, _) =
469            compare_symbols(&old_symbols, &current_symbols, "src/lib.rs");
470        assert!(added.is_empty());
471        assert!(modified.is_empty());
472        assert!(removed.is_empty());
473    }
474
475    #[test]
476    fn test_compare_symbols_extra_current_beyond_paired() {
477        let old_symbols = vec![make_old_sym(
478            "foo",
479            SymbolKind::Function,
480            Some("fn foo()"),
481            None,
482        )];
483        let current_symbols = vec![
484            make_cur_sym(1, "foo", SymbolKind::Function, Some("fn foo()"), None),
485            make_cur_sym(2, "foo", SymbolKind::Function, Some("fn foo(x: i32)"), None),
486        ];
487        let (added, modified, removed, _) =
488            compare_symbols(&old_symbols, &current_symbols, "src/lib.rs");
489        assert_eq!(added.len(), 1, "extra current symbol should be added");
490        assert_eq!(added[0].new_signature.as_deref(), Some("fn foo(x: i32)"));
491        assert!(modified.is_empty());
492        assert!(removed.is_empty());
493    }
494
495    #[test]
496    fn test_compare_symbols_extra_old_beyond_paired() {
497        let old_symbols = vec![
498            make_old_sym("foo", SymbolKind::Function, Some("fn foo()"), None),
499            make_old_sym("foo", SymbolKind::Function, Some("fn foo(x: i32)"), None),
500        ];
501        let current_symbols = vec![make_cur_sym(
502            1,
503            "foo",
504            SymbolKind::Function,
505            Some("fn foo()"),
506            None,
507        )];
508        let (added, modified, removed, _) =
509            compare_symbols(&old_symbols, &current_symbols, "src/lib.rs");
510        assert!(added.is_empty());
511        assert!(modified.is_empty());
512        assert_eq!(removed.len(), 1, "extra old symbol should be removed");
513        assert_eq!(removed[0].old_signature.as_deref(), Some("fn foo(x: i32)"));
514    }
515
516    #[test]
517    fn test_compare_symbols_budget_guard_clears_modified_ids() {
518        let old_symbols: Vec<ExtractedSymbol> = (0..6)
519            .map(|i| {
520                make_old_sym(
521                    &format!("fn_{}", i),
522                    SymbolKind::Function,
523                    Some(&format!("fn fn_{}()", i)),
524                    None,
525                )
526            })
527            .collect();
528        let current_symbols: Vec<SymbolWithParent> = Vec::new();
529
530        let (_, _, removed, mod_ids) =
531            compare_symbols(&old_symbols, &current_symbols, "src/lib.rs");
532        assert_eq!(removed.len(), 6);
533        assert!(
534            mod_ids.is_empty(),
535            "budget guard should clear modified_ids when total_changes > {}",
536            BUDGET_GUARD_THRESHOLD
537        );
538    }
539
540    #[test]
541    fn test_resolve_parent_name_with_parent() {
542        let symbols = vec![
543            make_old_sym("MyClass", SymbolKind::Class, None, None),
544            make_old_sym("method", SymbolKind::Method, None, Some(0)),
545        ];
546        let parent = resolve_parent_name(&symbols, &symbols[1]);
547        assert_eq!(parent, Some("MyClass".to_string()));
548    }
549
550    #[test]
551    fn test_resolve_parent_name_without_parent() {
552        let symbols = vec![make_old_sym("top_fn", SymbolKind::Function, None, None)];
553        let parent = resolve_parent_name(&symbols, &symbols[0]);
554        assert_eq!(parent, None);
555    }
556
557    #[test]
558    fn test_resolve_parent_name_out_of_bounds() {
559        let symbols = vec![make_old_sym("orphan", SymbolKind::Method, None, Some(99))];
560        let parent = resolve_parent_name(&symbols, &symbols[0]);
561        assert_eq!(
562            parent, None,
563            "out-of-bounds parent_index should return None"
564        );
565    }
566
567    #[test]
568    fn test_symbol_change_from_current_fields() {
569        let sym = SymbolWithParent {
570            id: 42,
571            name: "my_fn".to_string(),
572            kind: SymbolKind::Function,
573            file_path: "src/lib.rs".to_string(),
574            start_line: 10,
575            end_line: 20,
576            signature: Some("fn my_fn(x: i32)".to_string()),
577            parent_name: Some("MyStruct".to_string()),
578        };
579        let change = symbol_change_from_current(sym);
580        assert_eq!(change.name, "my_fn");
581        assert_eq!(change.kind, SymbolKind::Function);
582        assert_eq!(change.file, "src/lib.rs");
583        assert_eq!(change.line, 10);
584        assert_eq!(change.parent_name, Some("MyStruct".to_string()));
585        assert!(change.old_signature.is_none());
586        assert_eq!(change.new_signature, Some("fn my_fn(x: i32)".to_string()));
587    }
588
589    #[test]
590    fn test_symbol_change_from_old_fields() {
591        let symbols = vec![
592            make_old_sym("Parent", SymbolKind::Class, None, None),
593            ExtractedSymbol {
594                name: "child".to_string(),
595                kind: SymbolKind::Method,
596                signature: Some("def child(self)".to_string()),
597                start_line: 5,
598                end_line: 10,
599                start_byte: 50,
600                end_byte: 200,
601                parent_index: Some(0),
602            },
603        ];
604        let change = symbol_change_from_old(&symbols[1], &symbols, "src/app.py");
605        assert_eq!(change.name, "child");
606        assert_eq!(change.kind, SymbolKind::Method);
607        assert_eq!(change.file, "src/app.py");
608        assert_eq!(change.line, 5);
609        assert_eq!(change.parent_name, Some("Parent".to_string()));
610        assert_eq!(change.old_signature, Some("def child(self)".to_string()));
611        assert!(change.new_signature.is_none());
612    }
613}