Skip to main content

astmap_core/diff/
working.rs

1// Line-level diff: maps changed line ranges to affected symbols.
2
3use serde::Serialize;
4
5use crate::error::DiffError;
6use crate::model::{FileChangeStatus, ImpactEntry, SymbolResult, VcsFileChange};
7use crate::port::{DiffStore, VcsProvider};
8
9#[derive(Debug, Serialize)]
10pub struct DiffResult {
11    pub changed_files: Vec<ChangedFile>,
12    pub unindexed_files: Vec<String>,
13    pub impact: Vec<ImpactEntry>,
14    pub summary: DiffSummary,
15}
16
17#[derive(Debug, Serialize)]
18pub struct ChangedFile {
19    pub path: String,
20    pub status: FileChangeStatus,
21    pub affected_symbols: Vec<SymbolResult>,
22}
23
24#[derive(Debug, Serialize)]
25pub struct DiffSummary {
26    pub changed_files: usize,
27    pub affected_symbols: usize,
28    pub impacted_symbols: usize,
29    pub unindexed: usize,
30}
31
32/// Map working tree diff (uncommitted changes) to affected symbols.
33pub fn diff_working_tree(
34    vcs: &dyn VcsProvider,
35    db: &dyn DiffStore,
36    impact_depth: i64,
37    supported_ext: &[&str],
38) -> Result<DiffResult, DiffError> {
39    let changes = vcs.working_tree_changes(None)?;
40    process_diff(db, &changes, impact_depth, supported_ext)
41}
42
43/// Map diff against a base branch/commit to affected symbols.
44pub fn diff_against_base(
45    vcs: &dyn VcsProvider,
46    base: &str,
47    db: &dyn DiffStore,
48    impact_depth: i64,
49    supported_ext: &[&str],
50) -> Result<DiffResult, DiffError> {
51    let changes = vcs.changes_between_refs(base, "HEAD")?;
52    process_diff(db, &changes, impact_depth, supported_ext)
53}
54
55/// Process VCS file changes into a DiffResult with symbol lookups and impact analysis.
56fn process_diff(
57    db: &dyn DiffStore,
58    changes: &[VcsFileChange],
59    impact_depth: i64,
60    supported_ext: &[&str],
61) -> Result<DiffResult, DiffError> {
62    let mut changed_files = Vec::new();
63    let mut unindexed_files = Vec::new();
64    let mut all_affected_symbol_ids = Vec::new();
65
66    for change in changes {
67        // Filter by supported extensions
68        let is_supported = std::path::Path::new(&change.path)
69            .extension()
70            .and_then(|e| e.to_str())
71            .is_some_and(|ext| supported_ext.contains(&ext));
72        if !is_supported {
73            continue;
74        }
75
76        // Check if file is indexed
77        let file_id = db.get_file_id(&change.path)?;
78        if file_id.is_none() && change.status != FileChangeStatus::Deleted {
79            unindexed_files.push(change.path.clone());
80            continue;
81        }
82
83        match change.status {
84            FileChangeStatus::Deleted => {
85                changed_files.push(ChangedFile {
86                    path: change.path.clone(),
87                    status: change.status,
88                    affected_symbols: vec![],
89                });
90            }
91            FileChangeStatus::Added => {
92                // Added file — all symbols affected
93                let symbols = db.symbols_in_line_range(&change.path, 1, i64::MAX)?;
94                for s in &symbols {
95                    all_affected_symbol_ids.push(s.id);
96                }
97                changed_files.push(ChangedFile {
98                    path: change.path.clone(),
99                    status: change.status,
100                    affected_symbols: symbols,
101                });
102            }
103            FileChangeStatus::Modified | FileChangeStatus::Renamed => {
104                // Modified/Renamed file — find symbols overlapping changed lines
105                if change.changed_lines.is_empty() {
106                    // No line-level info; treat as entire file changed
107                    let symbols = db.symbols_in_line_range(&change.path, 1, i64::MAX)?;
108                    for s in &symbols {
109                        all_affected_symbol_ids.push(s.id);
110                    }
111                    changed_files.push(ChangedFile {
112                        path: change.path.clone(),
113                        status: change.status,
114                        affected_symbols: symbols,
115                    });
116                } else {
117                    let mut affected = Vec::new();
118                    for (start, end) in &change.changed_lines {
119                        let symbols = db.symbols_in_line_range(&change.path, *start, *end)?;
120                        for s in symbols {
121                            if !affected.iter().any(|a: &SymbolResult| a.id == s.id) {
122                                all_affected_symbol_ids.push(s.id);
123                                affected.push(s);
124                            }
125                        }
126                    }
127                    changed_files.push(ChangedFile {
128                        path: change.path.clone(),
129                        status: change.status,
130                        affected_symbols: affected,
131                    });
132                }
133            }
134        }
135    }
136
137    // Transitive impact analysis
138    let mut impact = Vec::new();
139    let mut seen_ids = std::collections::HashSet::new();
140    for sym_id in &all_affected_symbol_ids {
141        seen_ids.insert(*sym_id);
142    }
143    for sym_id in &all_affected_symbol_ids {
144        let entries = db.impact_analysis(*sym_id, impact_depth)?;
145        for entry in entries {
146            if seen_ids.insert(entry.symbol_id) {
147                impact.push(entry);
148            }
149        }
150    }
151
152    let total_affected = all_affected_symbol_ids.len();
153    let summary = DiffSummary {
154        changed_files: changed_files.len(),
155        affected_symbols: total_affected,
156        impacted_symbols: impact.len(),
157        unindexed: unindexed_files.len(),
158    };
159
160    Ok(DiffResult {
161        changed_files,
162        unindexed_files,
163        impact,
164        summary,
165    })
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use crate::error::{DbError, DiffError};
172    use crate::model::{Config, SymbolKind, SymbolWithParent, VcsFileChange};
173    use crate::port::VcsProvider;
174    use crate::{ConfigStore, SymbolLookup};
175    use std::collections::HashMap;
176    // --- Mock VcsProvider ---
177
178    struct MockVcs {
179        working_changes: Vec<VcsFileChange>,
180        between_changes: Vec<VcsFileChange>,
181    }
182
183    impl VcsProvider for MockVcs {
184        fn working_tree_changes(
185            &self,
186            _base_ref: Option<&str>,
187        ) -> Result<Vec<VcsFileChange>, DiffError> {
188            Ok(self.working_changes.clone())
189        }
190        fn changes_between_refs(
191            &self,
192            _from: &str,
193            _to: &str,
194        ) -> Result<Vec<VcsFileChange>, DiffError> {
195            Ok(self.between_changes.clone())
196        }
197        fn read_file_at_ref(&self, _path: &str, _ref: &str) -> Result<Option<String>, DiffError> {
198            Ok(None)
199        }
200        fn commit_count(&self, _from: &str, _to: &str) -> Result<usize, DiffError> {
201            Ok(0)
202        }
203        fn resolve_ref(&self, _refspec: &str) -> Result<bool, DiffError> {
204            Ok(false)
205        }
206        fn head_sha(&self) -> Result<String, DiffError> {
207            Ok("abc".to_string())
208        }
209        fn root_commit_sha(&self) -> Result<Option<String>, DiffError> {
210            Ok(None)
211        }
212    }
213
214    // --- Mock DiffStore ---
215
216    struct MockDiffStore {
217        file_ids: HashMap<String, i64>,
218        symbols: HashMap<String, Vec<SymbolResult>>,
219        impacts: HashMap<i64, Vec<ImpactEntry>>,
220    }
221
222    impl MockDiffStore {
223        fn new() -> Self {
224            Self {
225                file_ids: HashMap::new(),
226                symbols: HashMap::new(),
227                impacts: HashMap::new(),
228            }
229        }
230    }
231
232    impl ConfigStore for MockDiffStore {
233        fn config_get(&self, _key: &str) -> Result<Option<String>, DbError> {
234            Ok(None)
235        }
236        fn config_set(&self, _key: &str, _value: &str) -> Result<(), DbError> {
237            Ok(())
238        }
239        fn config_list(&self) -> Result<Vec<Config>, DbError> {
240            Ok(vec![])
241        }
242    }
243
244    impl SymbolLookup for MockDiffStore {
245        fn get_file_id(&self, path: &str) -> Result<Option<i64>, DbError> {
246            Ok(self.file_ids.get(path).copied())
247        }
248        fn get_symbol_by_id(&self, _id: i64) -> Result<Option<crate::Symbol>, DbError> {
249            Ok(None)
250        }
251        fn search_symbols_by_name(
252            &self,
253            _name: &str,
254            _limit: i64,
255        ) -> Result<Vec<SymbolResult>, DbError> {
256            Ok(vec![])
257        }
258        fn get_symbols_for_file(&self, _file_id: i64) -> Result<Vec<crate::Symbol>, DbError> {
259            Ok(vec![])
260        }
261        fn get_children(&self, _symbol_id: i64) -> Result<Vec<crate::Symbol>, DbError> {
262            Ok(vec![])
263        }
264        fn impact_analysis(&self, id: i64, _depth: i64) -> Result<Vec<ImpactEntry>, DbError> {
265            Ok(self.impacts.get(&id).cloned().unwrap_or_default())
266        }
267        fn stats(&self) -> Result<(i64, i64, i64), DbError> {
268            Ok((0, 0, 0))
269        }
270    }
271
272    impl DiffStore for MockDiffStore {
273        fn symbols_in_line_range(
274            &self,
275            path: &str,
276            start: i64,
277            end: i64,
278        ) -> Result<Vec<SymbolResult>, DbError> {
279            Ok(self
280                .symbols
281                .get(path)
282                .map(|syms| {
283                    syms.iter()
284                        .filter(|s| s.start_line <= end && s.end_line >= start)
285                        .cloned()
286                        .collect()
287                })
288                .unwrap_or_default())
289        }
290        fn all_symbols_for_file(&self, _path: &str) -> Result<Vec<SymbolWithParent>, DbError> {
291            Ok(vec![])
292        }
293    }
294
295    fn make_sym(id: i64, name: &str, path: &str, start: i64, end: i64) -> SymbolResult {
296        SymbolResult {
297            id,
298            name: name.to_string(),
299            kind: SymbolKind::Function,
300            file_path: path.to_string(),
301            start_line: start,
302            end_line: end,
303            signature: None,
304            summary: None,
305        }
306    }
307
308    fn make_impact(id: i64, name: &str, path: &str, depth: i64) -> ImpactEntry {
309        ImpactEntry {
310            symbol_id: id,
311            symbol_name: name.to_string(),
312            symbol_kind: SymbolKind::Function,
313            file_path: path.to_string(),
314            depth,
315            dep_type: crate::model::DepType::Calls,
316        }
317    }
318
319    #[test]
320    fn test_process_diff_deleted_file() {
321        let db = MockDiffStore::new();
322        let changes = vec![VcsFileChange {
323            path: "src/old.rs".to_string(),
324            old_path: None,
325            status: FileChangeStatus::Deleted,
326            changed_lines: vec![],
327        }];
328
329        let result = process_diff(&db, &changes, 3, &["rs"]).unwrap();
330        assert_eq!(result.changed_files.len(), 1);
331        assert_eq!(result.changed_files[0].status, FileChangeStatus::Deleted);
332        assert!(result.changed_files[0].affected_symbols.is_empty());
333    }
334
335    #[test]
336    fn test_process_diff_added_file() {
337        let mut db = MockDiffStore::new();
338        db.file_ids.insert("src/new.rs".to_string(), 1);
339        db.symbols.insert(
340            "src/new.rs".to_string(),
341            vec![make_sym(10, "new_fn", "src/new.rs", 1, 10)],
342        );
343        let changes = vec![VcsFileChange {
344            path: "src/new.rs".to_string(),
345            old_path: None,
346            status: FileChangeStatus::Added,
347            changed_lines: vec![],
348        }];
349
350        let result = process_diff(&db, &changes, 3, &["rs"]).unwrap();
351        assert_eq!(result.changed_files.len(), 1);
352        assert_eq!(result.changed_files[0].status, FileChangeStatus::Added);
353        assert_eq!(result.changed_files[0].affected_symbols.len(), 1);
354        assert_eq!(result.summary.affected_symbols, 1);
355    }
356
357    #[test]
358    fn test_process_diff_modified_with_lines() {
359        let mut db = MockDiffStore::new();
360        db.file_ids.insert("src/lib.rs".to_string(), 1);
361        db.symbols.insert(
362            "src/lib.rs".to_string(),
363            vec![
364                make_sym(10, "foo", "src/lib.rs", 1, 10),
365                make_sym(11, "bar", "src/lib.rs", 20, 30),
366            ],
367        );
368        let changes = vec![VcsFileChange {
369            path: "src/lib.rs".to_string(),
370            old_path: None,
371            status: FileChangeStatus::Modified,
372            changed_lines: vec![(5, 7)],
373        }];
374
375        let result = process_diff(&db, &changes, 3, &["rs"]).unwrap();
376        assert_eq!(result.changed_files.len(), 1);
377        assert_eq!(result.changed_files[0].affected_symbols.len(), 1);
378        assert_eq!(result.changed_files[0].affected_symbols[0].name, "foo");
379    }
380
381    #[test]
382    fn test_process_diff_modified_no_lines() {
383        let mut db = MockDiffStore::new();
384        db.file_ids.insert("src/lib.rs".to_string(), 1);
385        db.symbols.insert(
386            "src/lib.rs".to_string(),
387            vec![make_sym(10, "foo", "src/lib.rs", 1, 10)],
388        );
389        let changes = vec![VcsFileChange {
390            path: "src/lib.rs".to_string(),
391            old_path: None,
392            status: FileChangeStatus::Modified,
393            changed_lines: vec![],
394        }];
395
396        let result = process_diff(&db, &changes, 3, &["rs"]).unwrap();
397        assert_eq!(result.changed_files[0].affected_symbols.len(), 1);
398    }
399
400    #[test]
401    fn test_process_diff_unindexed_file() {
402        let db = MockDiffStore::new();
403        let changes = vec![VcsFileChange {
404            path: "src/unknown.rs".to_string(),
405            old_path: None,
406            status: FileChangeStatus::Added,
407            changed_lines: vec![],
408        }];
409
410        let result = process_diff(&db, &changes, 3, &["rs"]).unwrap();
411        assert_eq!(result.unindexed_files, vec!["src/unknown.rs"]);
412        assert_eq!(result.summary.unindexed, 1);
413    }
414
415    #[test]
416    fn test_process_diff_filters_unsupported_ext() {
417        let db = MockDiffStore::new();
418        let changes = vec![VcsFileChange {
419            path: "README.md".to_string(),
420            old_path: None,
421            status: FileChangeStatus::Modified,
422            changed_lines: vec![],
423        }];
424
425        let result = process_diff(&db, &changes, 3, &["rs"]).unwrap();
426        assert!(result.changed_files.is_empty());
427    }
428
429    #[test]
430    fn test_process_diff_impact_dedup() {
431        let mut db = MockDiffStore::new();
432        db.file_ids.insert("src/a.rs".to_string(), 1);
433        db.symbols.insert(
434            "src/a.rs".to_string(),
435            vec![
436                make_sym(10, "fn_a", "src/a.rs", 1, 5),
437                make_sym(11, "fn_b", "src/a.rs", 6, 10),
438            ],
439        );
440        // Both symbols have impact on sym 99
441        db.impacts
442            .insert(10, vec![make_impact(99, "downstream", "src/b.rs", 1)]);
443        db.impacts
444            .insert(11, vec![make_impact(99, "downstream", "src/b.rs", 1)]);
445
446        let changes = vec![VcsFileChange {
447            path: "src/a.rs".to_string(),
448            old_path: None,
449            status: FileChangeStatus::Modified,
450            changed_lines: vec![(1, 10)],
451        }];
452
453        let result = process_diff(&db, &changes, 3, &["rs"]).unwrap();
454        // Impact should be deduplicated
455        assert_eq!(result.impact.len(), 1);
456        assert_eq!(result.impact[0].symbol_name, "downstream");
457    }
458
459    #[test]
460    fn test_diff_working_tree_delegates() {
461        let vcs = MockVcs {
462            working_changes: vec![],
463            between_changes: vec![],
464        };
465        let db = MockDiffStore::new();
466        let result = diff_working_tree(&vcs, &db, 3, &["rs"]).unwrap();
467        assert!(result.changed_files.is_empty());
468    }
469
470    #[test]
471    fn test_diff_against_base_delegates() {
472        let vcs = MockVcs {
473            working_changes: vec![],
474            between_changes: vec![],
475        };
476        let db = MockDiffStore::new();
477        let result = diff_against_base(&vcs, "main", &db, 3, &["rs"]).unwrap();
478        assert!(result.changed_files.is_empty());
479    }
480
481    #[test]
482    fn test_process_diff_renamed_file() {
483        let mut db = MockDiffStore::new();
484        db.file_ids.insert("src/renamed.rs".to_string(), 1);
485        db.symbols.insert(
486            "src/renamed.rs".to_string(),
487            vec![make_sym(10, "foo", "src/renamed.rs", 1, 10)],
488        );
489        let changes = vec![VcsFileChange {
490            path: "src/renamed.rs".to_string(),
491            old_path: Some("src/old_name.rs".to_string()),
492            status: FileChangeStatus::Renamed,
493            changed_lines: vec![(1, 5)],
494        }];
495
496        let result = process_diff(&db, &changes, 3, &["rs"]).unwrap();
497        assert_eq!(result.changed_files.len(), 1);
498        assert_eq!(result.changed_files[0].status, FileChangeStatus::Renamed);
499        assert_eq!(result.changed_files[0].affected_symbols.len(), 1);
500    }
501
502    #[test]
503    fn test_process_diff_dedup_symbols_in_overlapping_ranges() {
504        let mut db = MockDiffStore::new();
505        db.file_ids.insert("src/lib.rs".to_string(), 1);
506        db.symbols.insert(
507            "src/lib.rs".to_string(),
508            vec![make_sym(10, "foo", "src/lib.rs", 1, 20)],
509        );
510
511        let changes = vec![VcsFileChange {
512            path: "src/lib.rs".to_string(),
513            old_path: None,
514            status: FileChangeStatus::Modified,
515            changed_lines: vec![(3, 5), (10, 15)],
516        }];
517
518        let result = process_diff(&db, &changes, 3, &["rs"]).unwrap();
519        // Same symbol overlaps both ranges but should only appear once
520        assert_eq!(result.changed_files[0].affected_symbols.len(), 1);
521        assert_eq!(result.summary.affected_symbols, 1);
522    }
523}