Skip to main content

astmap_core/diff/
change.rs

1// Shared symbol comparison types and functions.
2// Used by both `since` (git blob vs DB) and daemon (DB snapshot vs DB).
3
4use std::collections::HashMap;
5
6use serde::Serialize;
7
8use crate::model::{ExtractedSymbol, SymbolKind, SymbolWithParent};
9
10/// Max changed symbols per file before skipping per-symbol impact analysis.
11pub const BUDGET_GUARD_THRESHOLD: usize = 5;
12
13/// Classification of a symbol change.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
15#[serde(rename_all = "snake_case")]
16pub enum ChangeType {
17    Added,
18    Modified,
19    Removed,
20}
21
22/// A single symbol-level change between two states.
23#[derive(Debug, Clone, Serialize)]
24pub struct SymbolChange {
25    pub name: String,
26    pub kind: SymbolKind,
27    pub change_type: ChangeType,
28    pub file_path: String,
29    pub line: u32,
30    pub parent_name: Option<String>,
31    pub old_signature: Option<String>,
32    pub new_signature: Option<String>,
33}
34
35/// Key for matching symbols across states: (name, kind, parent_name).
36#[derive(Hash, Eq, PartialEq)]
37pub(crate) struct SymbolKey {
38    pub name: String,
39    pub kind: SymbolKind,
40    pub parent_name: Option<String>,
41}
42
43/// Resolve parent_index in ExtractedSymbol to parent name string.
44pub(crate) fn resolve_parent_name(
45    symbols: &[ExtractedSymbol],
46    sym: &ExtractedSymbol,
47) -> Option<String> {
48    sym.parent_index
49        .and_then(|idx| symbols.get(idx).map(|p| p.name.clone()))
50}
51
52/// Build a SymbolChange from a current DB symbol (added case).
53pub(crate) fn symbol_change_from_current(
54    sym: SymbolWithParent,
55    change_type: ChangeType,
56) -> SymbolChange {
57    SymbolChange {
58        name: sym.name,
59        kind: sym.kind,
60        change_type,
61        file_path: sym.file_path,
62        line: sym.start_line as u32,
63        parent_name: sym.parent_name,
64        old_signature: None,
65        new_signature: sym.signature,
66    }
67}
68
69/// Build a SymbolChange from an old parsed symbol (removed case).
70pub(crate) fn symbol_change_from_old(
71    sym: &ExtractedSymbol,
72    all_old: &[ExtractedSymbol],
73    file_path: &str,
74) -> SymbolChange {
75    SymbolChange {
76        name: sym.name.clone(),
77        kind: sym.kind,
78        change_type: ChangeType::Removed,
79        file_path: file_path.to_string(),
80        line: sym.start_line as u32,
81        parent_name: resolve_parent_name(all_old, sym),
82        old_signature: sym.signature.clone(),
83        new_signature: None,
84    }
85}
86
87/// Compare old (git blob parsed) symbols against current (DB) symbols for one file.
88/// Used by `since_against_ref()` for git ref comparison.
89/// Returns (changes, modified_sym_ids). Budget guard clears modified_sym_ids
90/// when total changes exceed BUDGET_GUARD_THRESHOLD.
91pub fn compare_extracted_symbols(
92    old_symbols: &[ExtractedSymbol],
93    current_symbols: &[SymbolWithParent],
94    file_path: &str,
95) -> (Vec<SymbolChange>, Vec<i64>) {
96    // Build multimaps: key -> Vec<symbol> (preserves insertion order = file order)
97    let mut old_map: HashMap<SymbolKey, Vec<&ExtractedSymbol>> = HashMap::new();
98    for sym in old_symbols {
99        let key = SymbolKey {
100            name: sym.name.clone(),
101            kind: sym.kind,
102            parent_name: resolve_parent_name(old_symbols, sym),
103        };
104        old_map.entry(key).or_default().push(sym);
105    }
106
107    let mut current_map: HashMap<SymbolKey, Vec<&SymbolWithParent>> = HashMap::new();
108    for sym in current_symbols {
109        let key = SymbolKey {
110            name: sym.name.clone(),
111            kind: sym.kind,
112            parent_name: sym.parent_name.clone(),
113        };
114        current_map.entry(key).or_default().push(sym);
115    }
116
117    let mut changes = Vec::new();
118    let mut modified_ids = Vec::new();
119
120    // For each key in current: pair with old by position, extras are "added"
121    for (key, cur_syms) in &current_map {
122        if let Some(old_syms) = old_map.get(key) {
123            let paired = old_syms.len().min(cur_syms.len());
124            for i in 0..paired {
125                if old_syms[i].signature != cur_syms[i].signature {
126                    changes.push(SymbolChange {
127                        name: cur_syms[i].name.clone(),
128                        kind: cur_syms[i].kind,
129                        change_type: ChangeType::Modified,
130                        file_path: file_path.to_string(),
131                        line: cur_syms[i].start_line as u32,
132                        parent_name: cur_syms[i].parent_name.clone(),
133                        old_signature: old_syms[i].signature.clone(),
134                        new_signature: cur_syms[i].signature.clone(),
135                    });
136                    modified_ids.push(cur_syms[i].id);
137                }
138            }
139            for cur in cur_syms.iter().skip(paired) {
140                changes.push(SymbolChange {
141                    name: cur.name.clone(),
142                    kind: cur.kind,
143                    change_type: ChangeType::Added,
144                    file_path: file_path.to_string(),
145                    line: cur.start_line as u32,
146                    parent_name: cur.parent_name.clone(),
147                    old_signature: None,
148                    new_signature: cur.signature.clone(),
149                });
150            }
151        } else {
152            for cur in cur_syms {
153                changes.push(SymbolChange {
154                    name: cur.name.clone(),
155                    kind: cur.kind,
156                    change_type: ChangeType::Added,
157                    file_path: file_path.to_string(),
158                    line: cur.start_line as u32,
159                    parent_name: cur.parent_name.clone(),
160                    old_signature: None,
161                    new_signature: cur.signature.clone(),
162                });
163            }
164        }
165    }
166
167    // For each key in old: extras beyond paired count are "removed"
168    for (key, old_syms) in &old_map {
169        let paired = current_map
170            .get(key)
171            .map_or(0, |c| c.len().min(old_syms.len()));
172        for old in old_syms.iter().skip(paired) {
173            changes.push(symbol_change_from_old(old, old_symbols, file_path));
174        }
175    }
176
177    // Budget guard: skip impact analysis if too many changes in this file
178    if changes.len() > BUDGET_GUARD_THRESHOLD {
179        modified_ids.clear();
180    }
181
182    (changes, modified_ids)
183}
184
185/// Compare two DB snapshots of symbols for one file (before vs after scan).
186/// Used by the daemon for push-based structural event detection.
187/// Returns (changes, modified_sym_ids). Budget guard clears modified_sym_ids
188/// when total changes exceed BUDGET_GUARD_THRESHOLD.
189pub fn compare_db_symbols(
190    old_symbols: &[SymbolWithParent],
191    current_symbols: &[SymbolWithParent],
192    file_path: &str,
193) -> (Vec<SymbolChange>, Vec<i64>) {
194    let mut old_map: HashMap<SymbolKey, Vec<&SymbolWithParent>> = HashMap::new();
195    for sym in old_symbols {
196        let key = SymbolKey {
197            name: sym.name.clone(),
198            kind: sym.kind,
199            parent_name: sym.parent_name.clone(),
200        };
201        old_map.entry(key).or_default().push(sym);
202    }
203
204    let mut current_map: HashMap<SymbolKey, Vec<&SymbolWithParent>> = HashMap::new();
205    for sym in current_symbols {
206        let key = SymbolKey {
207            name: sym.name.clone(),
208            kind: sym.kind,
209            parent_name: sym.parent_name.clone(),
210        };
211        current_map.entry(key).or_default().push(sym);
212    }
213
214    let mut changes = Vec::new();
215    let mut modified_ids = Vec::new();
216
217    for (key, cur_syms) in &current_map {
218        if let Some(old_syms) = old_map.get(key) {
219            let paired = old_syms.len().min(cur_syms.len());
220            for i in 0..paired {
221                if old_syms[i].signature != cur_syms[i].signature {
222                    changes.push(SymbolChange {
223                        name: cur_syms[i].name.clone(),
224                        kind: cur_syms[i].kind,
225                        change_type: ChangeType::Modified,
226                        file_path: file_path.to_string(),
227                        line: cur_syms[i].start_line as u32,
228                        parent_name: cur_syms[i].parent_name.clone(),
229                        old_signature: old_syms[i].signature.clone(),
230                        new_signature: cur_syms[i].signature.clone(),
231                    });
232                    modified_ids.push(cur_syms[i].id);
233                }
234            }
235            for cur in cur_syms.iter().skip(paired) {
236                changes.push(symbol_change_from_current(
237                    (*cur).clone(),
238                    ChangeType::Added,
239                ));
240            }
241        } else {
242            for cur in cur_syms {
243                changes.push(symbol_change_from_current(
244                    (*cur).clone(),
245                    ChangeType::Added,
246                ));
247            }
248        }
249    }
250
251    for (key, old_syms) in &old_map {
252        let paired = current_map
253            .get(key)
254            .map_or(0, |c| c.len().min(old_syms.len()));
255        for old in old_syms.iter().skip(paired) {
256            changes.push(SymbolChange {
257                name: old.name.clone(),
258                kind: old.kind,
259                change_type: ChangeType::Removed,
260                file_path: file_path.to_string(),
261                line: old.start_line as u32,
262                parent_name: old.parent_name.clone(),
263                old_signature: old.signature.clone(),
264                new_signature: None,
265            });
266        }
267    }
268
269    if changes.len() > BUDGET_GUARD_THRESHOLD {
270        modified_ids.clear();
271    }
272
273    (changes, modified_ids)
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    fn make_extracted(
281        name: &str,
282        kind: SymbolKind,
283        sig: Option<&str>,
284        parent_index: Option<usize>,
285    ) -> ExtractedSymbol {
286        ExtractedSymbol {
287            name: name.to_string(),
288            kind,
289            signature: sig.map(|s| s.to_string()),
290            start_line: 1,
291            end_line: 5,
292            start_byte: 0,
293            end_byte: 100,
294            parent_index,
295        }
296    }
297
298    fn make_db_sym(
299        id: i64,
300        name: &str,
301        kind: SymbolKind,
302        sig: Option<&str>,
303        parent_name: Option<&str>,
304    ) -> SymbolWithParent {
305        SymbolWithParent {
306            id,
307            name: name.to_string(),
308            kind,
309            file_path: "src/lib.rs".to_string(),
310            start_line: 1,
311            end_line: 5,
312            signature: sig.map(|s| s.to_string()),
313            parent_name: parent_name.map(|s| s.to_string()),
314        }
315    }
316
317    // --- compare_extracted_symbols tests ---
318
319    #[test]
320    fn extracted_added_new_key() {
321        let (changes, _) = compare_extracted_symbols(
322            &[],
323            &[make_db_sym(
324                1,
325                "new_fn",
326                SymbolKind::Function,
327                Some("fn new_fn()"),
328                None,
329            )],
330            "src/lib.rs",
331        );
332        assert_eq!(changes.len(), 1);
333        assert_eq!(changes[0].name, "new_fn");
334        assert_eq!(changes[0].change_type, ChangeType::Added);
335    }
336
337    #[test]
338    fn extracted_removed_key() {
339        let old = vec![make_extracted(
340            "old_fn",
341            SymbolKind::Function,
342            Some("fn old_fn()"),
343            None,
344        )];
345        let (changes, _) = compare_extracted_symbols(&old, &[], "src/lib.rs");
346        assert_eq!(changes.len(), 1);
347        assert_eq!(changes[0].name, "old_fn");
348        assert_eq!(changes[0].change_type, ChangeType::Removed);
349    }
350
351    #[test]
352    fn extracted_modified_signature() {
353        let old = vec![make_extracted(
354            "foo",
355            SymbolKind::Function,
356            Some("fn foo()"),
357            None,
358        )];
359        let cur = vec![make_db_sym(
360            1,
361            "foo",
362            SymbolKind::Function,
363            Some("fn foo(x: i32)"),
364            None,
365        )];
366        let (changes, mod_ids) = compare_extracted_symbols(&old, &cur, "src/lib.rs");
367        assert_eq!(changes.len(), 1);
368        assert_eq!(changes[0].change_type, ChangeType::Modified);
369        assert_eq!(changes[0].old_signature.as_deref(), Some("fn foo()"));
370        assert_eq!(changes[0].new_signature.as_deref(), Some("fn foo(x: i32)"));
371        assert_eq!(mod_ids, vec![1]);
372    }
373
374    #[test]
375    fn extracted_unchanged() {
376        let old = vec![make_extracted(
377            "foo",
378            SymbolKind::Function,
379            Some("fn foo()"),
380            None,
381        )];
382        let cur = vec![make_db_sym(
383            1,
384            "foo",
385            SymbolKind::Function,
386            Some("fn foo()"),
387            None,
388        )];
389        let (changes, _) = compare_extracted_symbols(&old, &cur, "src/lib.rs");
390        assert!(changes.is_empty());
391    }
392
393    #[test]
394    fn extracted_budget_guard() {
395        let old: Vec<ExtractedSymbol> = (0..6)
396            .map(|i| {
397                make_extracted(
398                    &format!("fn_{i}"),
399                    SymbolKind::Function,
400                    Some(&format!("fn fn_{i}()")),
401                    None,
402                )
403            })
404            .collect();
405        let (changes, mod_ids) = compare_extracted_symbols(&old, &[], "src/lib.rs");
406        assert_eq!(changes.len(), 6);
407        assert!(mod_ids.is_empty(), "budget guard should clear modified_ids");
408    }
409
410    // --- compare_db_symbols tests ---
411
412    #[test]
413    fn db_added_new_key() {
414        let cur = vec![make_db_sym(
415            1,
416            "new_fn",
417            SymbolKind::Function,
418            Some("fn new_fn()"),
419            None,
420        )];
421        let (changes, _) = compare_db_symbols(&[], &cur, "src/lib.rs");
422        assert_eq!(changes.len(), 1);
423        assert_eq!(changes[0].name, "new_fn");
424        assert_eq!(changes[0].change_type, ChangeType::Added);
425    }
426
427    #[test]
428    fn db_removed_key() {
429        let old = vec![make_db_sym(
430            1,
431            "old_fn",
432            SymbolKind::Function,
433            Some("fn old_fn()"),
434            None,
435        )];
436        let (changes, _) = compare_db_symbols(&old, &[], "src/lib.rs");
437        assert_eq!(changes.len(), 1);
438        assert_eq!(changes[0].name, "old_fn");
439        assert_eq!(changes[0].change_type, ChangeType::Removed);
440    }
441
442    #[test]
443    fn db_modified_signature() {
444        let old = vec![make_db_sym(
445            1,
446            "foo",
447            SymbolKind::Function,
448            Some("fn foo()"),
449            None,
450        )];
451        let cur = vec![make_db_sym(
452            1,
453            "foo",
454            SymbolKind::Function,
455            Some("fn foo(x: i32)"),
456            None,
457        )];
458        let (changes, mod_ids) = compare_db_symbols(&old, &cur, "src/lib.rs");
459        assert_eq!(changes.len(), 1);
460        assert_eq!(changes[0].change_type, ChangeType::Modified);
461        assert_eq!(mod_ids, vec![1]);
462    }
463
464    #[test]
465    fn db_unchanged() {
466        let old = vec![make_db_sym(
467            1,
468            "foo",
469            SymbolKind::Function,
470            Some("fn foo()"),
471            None,
472        )];
473        let cur = vec![make_db_sym(
474            1,
475            "foo",
476            SymbolKind::Function,
477            Some("fn foo()"),
478            None,
479        )];
480        let (changes, _) = compare_db_symbols(&old, &cur, "src/lib.rs");
481        assert!(changes.is_empty());
482    }
483
484    #[test]
485    fn db_budget_guard() {
486        let old: Vec<SymbolWithParent> = (0..6)
487            .map(|i| {
488                make_db_sym(
489                    i,
490                    &format!("fn_{i}"),
491                    SymbolKind::Function,
492                    Some(&format!("fn fn_{i}()")),
493                    None,
494                )
495            })
496            .collect();
497        let (changes, mod_ids) = compare_db_symbols(&old, &[], "src/lib.rs");
498        assert_eq!(changes.len(), 6);
499        assert!(mod_ids.is_empty(), "budget guard should clear modified_ids");
500    }
501
502    #[test]
503    fn db_extra_current_beyond_paired() {
504        let old = vec![make_db_sym(
505            1,
506            "foo",
507            SymbolKind::Function,
508            Some("fn foo()"),
509            None,
510        )];
511        let cur = vec![
512            make_db_sym(1, "foo", SymbolKind::Function, Some("fn foo()"), None),
513            make_db_sym(2, "foo", SymbolKind::Function, Some("fn foo(x: i32)"), None),
514        ];
515        let (changes, _) = compare_db_symbols(&old, &cur, "src/lib.rs");
516        assert_eq!(changes.len(), 1);
517        assert_eq!(changes[0].change_type, ChangeType::Added);
518    }
519
520    #[test]
521    fn db_extra_old_beyond_paired() {
522        let old = vec![
523            make_db_sym(1, "foo", SymbolKind::Function, Some("fn foo()"), None),
524            make_db_sym(2, "foo", SymbolKind::Function, Some("fn foo(x: i32)"), None),
525        ];
526        let cur = vec![make_db_sym(
527            1,
528            "foo",
529            SymbolKind::Function,
530            Some("fn foo()"),
531            None,
532        )];
533        let (changes, _) = compare_db_symbols(&old, &cur, "src/lib.rs");
534        assert_eq!(changes.len(), 1);
535        assert_eq!(changes[0].change_type, ChangeType::Removed);
536    }
537
538    // --- ChangeType equality ---
539
540    #[test]
541    fn change_type_equality() {
542        assert_eq!(ChangeType::Added, ChangeType::Added);
543        assert_eq!(ChangeType::Modified, ChangeType::Modified);
544        assert_eq!(ChangeType::Removed, ChangeType::Removed);
545        assert_ne!(ChangeType::Added, ChangeType::Removed);
546    }
547
548    // --- Helper tests ---
549
550    #[test]
551    fn resolve_parent_name_with_parent() {
552        let symbols = vec![
553            make_extracted("MyClass", SymbolKind::Class, None, None),
554            make_extracted("method", SymbolKind::Method, None, Some(0)),
555        ];
556        assert_eq!(
557            resolve_parent_name(&symbols, &symbols[1]),
558            Some("MyClass".to_string())
559        );
560    }
561
562    #[test]
563    fn resolve_parent_name_without_parent() {
564        let symbols = vec![make_extracted("top_fn", SymbolKind::Function, None, None)];
565        assert_eq!(resolve_parent_name(&symbols, &symbols[0]), None);
566    }
567
568    #[test]
569    fn resolve_parent_name_out_of_bounds() {
570        let symbols = vec![make_extracted("orphan", SymbolKind::Method, None, Some(99))];
571        assert_eq!(resolve_parent_name(&symbols, &symbols[0]), None);
572    }
573}