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::path::Path;
5
6use serde::Serialize;
7use tracing::warn;
8
9use crate::error::DiffError;
10use crate::model::{ExtractedSymbol, FileChangeStatus, ImpactEntry};
11use crate::port::{DiffStore, LanguageRegistry, VcsProvider};
12
13use super::change::{
14    compare_extracted_symbols, symbol_change_from_current, symbol_change_from_old, ChangeType,
15    SymbolChange,
16};
17
18#[derive(Debug, Serialize)]
19pub struct SinceResult {
20    pub reference: String,
21    pub commit_count: usize,
22    pub added: Vec<SymbolChange>,
23    pub modified: Vec<SymbolChange>,
24    pub removed: Vec<SymbolChange>,
25    pub downstream_impacts: Vec<ImpactEntry>,
26    pub summary: SinceSummary,
27}
28
29#[derive(Debug, Serialize)]
30pub struct SinceSummary {
31    pub added_count: usize,
32    pub modified_count: usize,
33    pub removed_count: usize,
34    pub impact_count: usize,
35    pub files_changed: usize,
36}
37
38/// Show structural changes (added/removed/modified symbols) since a git reference.
39///
40/// Expects the caller to have run a scan first to ensure the DB matches the current working tree.
41/// Parses old file content from git history and compares against current DB symbols.
42pub fn since_against_ref(
43    vcs: &dyn VcsProvider,
44    reference: &str,
45    db: &dyn DiffStore,
46    impact_depth: i64,
47    supported_ext: &[&str],
48    lang: &dyn LanguageRegistry,
49) -> Result<SinceResult, DiffError> {
50    // Phase 1: get changes and commit count from VCS
51    let vcs_changes = vcs.changes_between_refs(reference, "HEAD")?;
52    let commit_count = vcs.commit_count(reference, "HEAD")?;
53
54    // Phase 2: filter by supported extensions and process deltas
55    let (mut added, mut modified, mut removed, mut modified_sym_ids) =
56        (Vec::new(), Vec::new(), Vec::new(), Vec::new());
57
58    for change in &vcs_changes {
59        // Filter by supported extensions
60        let primary_path = if change.status == FileChangeStatus::Deleted {
61            change.old_path.as_deref().unwrap_or(&change.path)
62        } else {
63            &change.path
64        };
65        let is_supported = Path::new(primary_path)
66            .extension()
67            .and_then(|e| e.to_str())
68            .is_some_and(|ext| supported_ext.contains(&ext));
69        if !is_supported {
70            continue;
71        }
72
73        match change.status {
74            FileChangeStatus::Added => {
75                let current = db.all_symbols_for_file(&change.path).unwrap_or_default();
76                for sym in current {
77                    added.push(symbol_change_from_current(sym, ChangeType::Added));
78                }
79            }
80            FileChangeStatus::Deleted => {
81                let old_path = change.old_path.as_deref().unwrap_or(&change.path);
82                let Some(old_symbols) = parse_old_blob(vcs, reference, old_path, lang) else {
83                    warn!("skipping unparseable deleted file: {}", old_path);
84                    continue;
85                };
86                for sym in &old_symbols {
87                    removed.push(symbol_change_from_old(sym, &old_symbols, old_path));
88                }
89            }
90            FileChangeStatus::Modified | FileChangeStatus::Renamed => {
91                let old_path = change.old_path.as_deref().unwrap_or(&change.path);
92                let Some(old_symbols) = parse_old_blob(vcs, reference, old_path, lang) else {
93                    warn!("skipping unparseable file: {}", old_path);
94                    continue;
95                };
96                let current = db.all_symbols_for_file(&change.path).unwrap_or_default();
97
98                let (changes, file_mod_ids) =
99                    compare_extracted_symbols(&old_symbols, &current, &change.path);
100                for c in changes {
101                    match c.change_type {
102                        ChangeType::Added => added.push(c),
103                        ChangeType::Modified => modified.push(c),
104                        ChangeType::Removed => removed.push(c),
105                    }
106                }
107                modified_sym_ids.extend(file_mod_ids);
108            }
109        }
110    }
111
112    // Phase 3: impact analysis for modified symbols
113    let mut downstream_impacts = Vec::new();
114    let mut seen_ids = std::collections::HashSet::new();
115    for sym_id in &modified_sym_ids {
116        seen_ids.insert(*sym_id);
117    }
118    for sym_id in &modified_sym_ids {
119        if let Ok(entries) = db.impact_analysis(*sym_id, impact_depth) {
120            for entry in entries {
121                if seen_ids.insert(entry.symbol_id) {
122                    downstream_impacts.push(entry);
123                }
124            }
125        }
126    }
127
128    // Count files that passed the extension filter
129    let files_changed = vcs_changes
130        .iter()
131        .filter(|c| {
132            let primary_path = if c.status == FileChangeStatus::Deleted {
133                c.old_path.as_deref().unwrap_or(&c.path)
134            } else {
135                &c.path
136            };
137            Path::new(primary_path)
138                .extension()
139                .and_then(|e| e.to_str())
140                .is_some_and(|ext| supported_ext.contains(&ext))
141        })
142        .count();
143
144    let summary = SinceSummary {
145        added_count: added.len(),
146        modified_count: modified.len(),
147        removed_count: removed.len(),
148        impact_count: downstream_impacts.len(),
149        files_changed,
150    };
151
152    Ok(SinceResult {
153        reference: reference.to_string(),
154        commit_count,
155        added,
156        modified,
157        removed,
158        downstream_impacts,
159        summary,
160    })
161}
162
163/// Parse old file content from a git ref and extract symbols with tree-sitter.
164/// Returns `None` on failure (unsupported language, missing blob, binary content)
165/// so callers can distinguish "parse failed" from "file has no symbols".
166fn parse_old_blob(
167    vcs: &dyn VcsProvider,
168    reference: &str,
169    file_path: &str,
170    lang: &dyn LanguageRegistry,
171) -> Option<Vec<ExtractedSymbol>> {
172    let ext = Path::new(file_path).extension().and_then(|e| e.to_str());
173    let parser = match ext.and_then(|e| lang.parser_for(e)) {
174        Some(p) => p,
175        None => {
176            warn!("no parser for old file: {}", file_path);
177            return None;
178        }
179    };
180
181    let source = match vcs.read_file_at_ref(file_path, reference) {
182        Ok(Some(s)) => s,
183        Ok(None) => {
184            warn!("old blob not found or binary for {}", file_path);
185            return None;
186        }
187        Err(e) => {
188            warn!("failed to read old blob for {}: {}", file_path, e);
189            return None;
190        }
191    };
192
193    let result = parser.parse(&source, Path::new(file_path));
194    Some(result.symbols)
195}
196
197// Tests for compare_symbols are in change.rs.
198// Tests for since_against_ref require VcsProvider mock (integration tests).