1use 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#[derive(Hash, Eq, PartialEq)]
47struct SymbolKey {
48 name: String,
49 kind: SymbolKind,
50 parent_name: Option<String>,
51}
52
53fn 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
59const BUDGET_GUARD_THRESHOLD: usize = 5;
61
62pub 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 let vcs_changes = vcs.changes_between_refs(reference, "HEAD")?;
76 let commit_count = vcs.commit_count(reference, "HEAD")?;
77
78 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 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, ¤t, &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 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 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
183fn 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
196fn 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
213fn 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
247fn 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 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 (key, cur_syms) in ¤t_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 (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 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 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, ¤t_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, ¤t_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, ¤t_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, ¤t_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, ¤t_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, ¤t_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, ¤t_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}