1use 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
32pub 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
43pub 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
55fn 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 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 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 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 if change.changed_lines.is_empty() {
106 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 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 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 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 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 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 assert_eq!(result.changed_files[0].affected_symbols.len(), 1);
521 assert_eq!(result.summary.affected_symbols, 1);
522 }
523}