astmap_core/diff/
since.rs1use 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
38pub 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 let vcs_changes = vcs.changes_between_refs(reference, "HEAD")?;
52 let commit_count = vcs.commit_count(reference, "HEAD")?;
53
54 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 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, ¤t, &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 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 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
163fn 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