1use std::collections::{HashMap, HashSet};
24use std::path::{Path, PathBuf};
25
26use sqry_core::graph::Language;
27use sqry_core::graph::unified::concurrent::GraphSnapshot;
28use sqry_core::graph::unified::node::kind::NodeKind;
29use sqry_core::graph::unified::resolution::display_graph_qualified_name;
30
31const SIGNATURE_WEIGHT: f64 = 0.7;
36const LOCATION_WEIGHT: f64 = 0.3;
37const SIGNATURE_MIN_SCORE: f64 = 0.7;
38const RENAME_CONFIDENCE_THRESHOLD: f64 = 0.9;
39const SAME_FILE_LINE_WINDOW: i32 = 50;
40const SAME_FILE_LINE_NORMALIZER: f64 = 100.0;
41const SAME_FILE_MAX_PENALTY: f64 = 0.5;
42const SAME_FILE_FAR_SCORE: f64 = 0.3;
43const CROSS_FILE_LOCATION_SCORE: f64 = 0.7;
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
51pub enum ChangeType {
52 Added,
54 Removed,
56 Modified,
59 Renamed,
62 SignatureChanged,
64 Unchanged,
68}
69
70impl ChangeType {
71 #[must_use]
74 pub fn as_str(&self) -> &'static str {
75 match self {
76 ChangeType::Added => "added",
77 ChangeType::Removed => "removed",
78 ChangeType::Modified => "modified",
79 ChangeType::Renamed => "renamed",
80 ChangeType::SignatureChanged => "signature_changed",
81 ChangeType::Unchanged => "unchanged",
82 }
83 }
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct NodeLocation {
93 pub file_path: PathBuf,
96 pub language: String,
99 pub start_line: u32,
101 pub end_line: u32,
103 pub start_column: u32,
105 pub end_column: u32,
107}
108
109#[derive(Debug, Clone)]
111pub struct NodeChange {
112 pub symbol_name: String,
114 pub qualified_name: String,
117 pub kind: String,
120 pub change_type: ChangeType,
122 pub base_location: Option<NodeLocation>,
125 pub target_location: Option<NodeLocation>,
128 pub signature_before: Option<String>,
130 pub signature_after: Option<String>,
132}
133
134#[derive(Debug, Clone, Default, PartialEq, Eq)]
136pub struct DiffSummary {
137 pub added: u64,
139 pub removed: u64,
141 pub modified: u64,
143 pub renamed: u64,
145 pub signature_changed: u64,
147 pub unchanged: u64,
150}
151
152impl DiffSummary {
153 #[must_use]
156 pub fn from_changes(changes: &[NodeChange]) -> Self {
157 let mut summary = Self::default();
158 for change in changes {
159 match change.change_type {
160 ChangeType::Added => summary.added += 1,
161 ChangeType::Removed => summary.removed += 1,
162 ChangeType::Modified => summary.modified += 1,
163 ChangeType::Renamed => summary.renamed += 1,
164 ChangeType::SignatureChanged => summary.signature_changed += 1,
165 ChangeType::Unchanged => summary.unchanged += 1,
166 }
167 }
168 summary
169 }
170}
171
172#[derive(Debug, Clone, Default)]
174pub struct DiffOutput {
175 pub changes: Vec<NodeChange>,
178 pub summary: DiffSummary,
181}
182
183#[derive(Debug, Clone, Default)]
190pub struct DiffOptions {
191 pub old_worktree_path: PathBuf,
195 pub new_worktree_path: PathBuf,
197}
198
199#[derive(Clone)]
205struct NodeSnap {
206 name: String,
207 qualified_name: String,
208 kind: NodeKind,
209 kind_str: String,
210 is_static: bool,
211 signature: Option<String>,
212 file_path: PathBuf,
213 language: String,
214 start_line: u32,
215 end_line: u32,
216 start_column: u32,
217 end_column: u32,
218}
219
220impl NodeSnap {
221 fn display_qualified_name(&self) -> String {
222 Language::from_id(&self.language).map_or_else(
223 || self.qualified_name.clone(),
224 |language| {
225 display_graph_qualified_name(
226 language,
227 &self.qualified_name,
228 self.kind,
229 self.is_static,
230 )
231 },
232 )
233 }
234
235 fn into_location(self) -> NodeLocation {
236 NodeLocation {
237 file_path: self.file_path,
238 language: self.language,
239 start_line: self.start_line,
240 end_line: self.end_line,
241 start_column: self.start_column,
242 end_column: self.end_column,
243 }
244 }
245
246 fn to_location(&self) -> NodeLocation {
247 NodeLocation {
248 file_path: self.file_path.clone(),
249 language: self.language.clone(),
250 start_line: self.start_line,
251 end_line: self.end_line,
252 start_column: self.start_column,
253 end_column: self.end_column,
254 }
255 }
256}
257
258#[must_use]
277pub fn compute_diff(old: &GraphSnapshot, new: &GraphSnapshot, opts: &DiffOptions) -> DiffOutput {
278 let base_map = build_node_map(old, &opts.old_worktree_path);
279 let target_map = build_node_map(new, &opts.new_worktree_path);
280
281 let (added_nodes, modified_changes) = collect_added_and_modified(&base_map, &target_map, opts);
282 let removed_nodes = collect_removed_nodes(&base_map, &target_map);
283
284 let mut changes = modified_changes;
285
286 let (rename_changes, renamed_qnames) = collect_renames(&removed_nodes, &added_nodes, opts);
287 changes.extend(rename_changes);
288
289 append_removed_changes(&mut changes, &removed_nodes, &renamed_qnames);
290 append_added_changes(&mut changes, &added_nodes, &renamed_qnames);
291
292 let summary = DiffSummary::from_changes(&changes);
293 DiffOutput { changes, summary }
294}
295
296fn build_node_map(snapshot: &GraphSnapshot, worktree_path: &Path) -> HashMap<String, NodeSnap> {
299 let strings = snapshot.strings();
300 let files = snapshot.files();
301 let mut map = HashMap::new();
302
303 for (_node_id, entry) in snapshot.iter_nodes() {
304 if entry.is_unified_loser() {
310 continue;
311 }
312 let name = strings
313 .resolve(entry.name)
314 .map(|s| s.to_string())
315 .unwrap_or_default();
316
317 let qualified_name = entry
318 .qualified_name
319 .and_then(|sid| strings.resolve(sid))
320 .map_or_else(|| name.clone(), |s| s.to_string());
321
322 if qualified_name.is_empty() {
324 continue;
325 }
326
327 let signature = entry
328 .signature
329 .and_then(|sid| strings.resolve(sid))
330 .map(|s| s.to_string());
331
332 let file_path = files
333 .resolve(entry.file)
334 .map(|p| {
335 if worktree_path.as_os_str().is_empty() {
336 PathBuf::from(p.as_ref())
337 } else {
338 worktree_path.join(p.as_ref())
339 }
340 })
341 .unwrap_or_default();
342
343 let language = files
344 .language_for_file(entry.file)
345 .map_or_else(|| "unknown".to_string(), |l| l.to_string());
346
347 let snap = NodeSnap {
348 name,
349 qualified_name: qualified_name.clone(),
350 kind: entry.kind,
351 kind_str: node_kind_to_string(entry.kind),
352 is_static: entry.is_static,
353 signature,
354 file_path,
355 language,
356 start_line: entry.start_line,
357 end_line: entry.end_line,
358 start_column: entry.start_column,
359 end_column: entry.end_column,
360 };
361
362 map.insert(qualified_name, snap);
363 }
364
365 map
366}
367
368fn collect_added_and_modified(
369 base_map: &HashMap<String, NodeSnap>,
370 target_map: &HashMap<String, NodeSnap>,
371 opts: &DiffOptions,
372) -> (Vec<NodeSnap>, Vec<NodeChange>) {
373 let mut added = Vec::new();
374 let mut changes = Vec::new();
375
376 for (qname, target_snap) in target_map {
377 match base_map.get(qname) {
378 None => added.push(target_snap.clone()),
379 Some(base_snap) => {
380 if let Some(change) = detect_modification(base_snap, target_snap, opts) {
381 changes.push(change);
382 }
383 }
384 }
385 }
386
387 (added, changes)
388}
389
390fn collect_removed_nodes(
391 base_map: &HashMap<String, NodeSnap>,
392 target_map: &HashMap<String, NodeSnap>,
393) -> Vec<NodeSnap> {
394 base_map
395 .iter()
396 .filter(|(qname, _)| !target_map.contains_key(*qname))
397 .map(|(_, snap)| snap.clone())
398 .collect()
399}
400
401fn detect_modification(
402 base_snap: &NodeSnap,
403 target_snap: &NodeSnap,
404 opts: &DiffOptions,
405) -> Option<NodeChange> {
406 let signature_changed = base_snap.signature != target_snap.signature;
407
408 let base_rel = strip_worktree_prefix(&base_snap.file_path, opts);
411 let target_rel = strip_worktree_prefix(&target_snap.file_path, opts);
412
413 let body_changed = base_snap.start_line != target_snap.start_line
414 || base_snap.end_line != target_snap.end_line
415 || base_rel != target_rel;
416
417 if signature_changed {
418 Some(NodeChange {
419 symbol_name: target_snap.name.clone(),
420 qualified_name: target_snap.display_qualified_name(),
421 kind: target_snap.kind_str.clone(),
422 change_type: ChangeType::SignatureChanged,
423 base_location: Some(base_snap.to_location()),
424 target_location: Some(target_snap.to_location()),
425 signature_before: base_snap.signature.clone(),
426 signature_after: target_snap.signature.clone(),
427 })
428 } else if body_changed {
429 Some(NodeChange {
430 symbol_name: target_snap.name.clone(),
431 qualified_name: target_snap.display_qualified_name(),
432 kind: target_snap.kind_str.clone(),
433 change_type: ChangeType::Modified,
434 base_location: Some(base_snap.to_location()),
435 target_location: Some(target_snap.to_location()),
436 signature_before: base_snap.signature.clone(),
437 signature_after: target_snap.signature.clone(),
438 })
439 } else {
440 None
441 }
442}
443
444fn collect_renames(
445 removed: &[NodeSnap],
446 added: &[NodeSnap],
447 opts: &DiffOptions,
448) -> (Vec<NodeChange>, HashSet<String>) {
449 let renames = detect_renames(removed, added, opts);
450 let mut rename_changes = Vec::new();
451 let mut renamed_qnames = HashSet::new();
452
453 for (base_snap, target_snap) in &renames {
454 renamed_qnames.insert(base_snap.qualified_name.clone());
455 renamed_qnames.insert(target_snap.qualified_name.clone());
456 rename_changes.push(create_renamed_change(base_snap, target_snap));
457 }
458
459 (rename_changes, renamed_qnames)
460}
461
462fn detect_renames(
463 removed: &[NodeSnap],
464 added: &[NodeSnap],
465 opts: &DiffOptions,
466) -> Vec<(NodeSnap, NodeSnap)> {
467 let mut renames = Vec::new();
468 let mut matched_added: HashSet<usize> = HashSet::new();
469
470 for removed_snap in removed {
471 let mut best_match: Option<(usize, f64)> = None;
472
473 for (idx, added_snap) in added.iter().enumerate() {
474 if matched_added.contains(&idx) {
475 continue;
476 }
477 let Some(score) = is_likely_rename(removed_snap, added_snap, opts) else {
478 continue;
479 };
480 let is_better = match best_match {
481 Some((_, best_score)) => score > best_score,
482 None => true,
483 };
484 if is_better {
485 best_match = Some((idx, score));
486 }
487 }
488
489 if let Some((idx, score)) = best_match
490 && score >= RENAME_CONFIDENCE_THRESHOLD
491 {
492 matched_added.insert(idx);
493 renames.push((removed_snap.clone(), added[idx].clone()));
494 }
495 }
496
497 renames
498}
499
500fn is_likely_rename(base: &NodeSnap, target: &NodeSnap, opts: &DiffOptions) -> Option<f64> {
501 if base.kind != target.kind {
503 return None;
504 }
505
506 let sig_score = match (&base.signature, &target.signature) {
508 (Some(base_sig), Some(target_sig)) => {
509 if base_sig == target_sig {
510 1.0
511 } else {
512 levenshtein_similarity(base_sig, target_sig)
513 }
514 }
515 (None, None) => 1.0,
516 _ => return None,
517 };
518 if sig_score < SIGNATURE_MIN_SCORE {
519 return None;
520 }
521 let mut confidence = sig_score * SIGNATURE_WEIGHT;
522
523 let base_rel = strip_worktree_prefix(&base.file_path, opts);
525 let target_rel = strip_worktree_prefix(&target.file_path, opts);
526 let location_score = if base_rel == target_rel {
527 let base_line: i32 = base.start_line.try_into().unwrap_or(i32::MAX);
528 let target_line: i32 = target.start_line.try_into().unwrap_or(i32::MAX);
529 let line_diff = (base_line - target_line).abs();
530 if line_diff <= SAME_FILE_LINE_WINDOW {
531 1.0 - (f64::from(line_diff) / SAME_FILE_LINE_NORMALIZER).min(SAME_FILE_MAX_PENALTY)
532 } else {
533 SAME_FILE_FAR_SCORE
534 }
535 } else {
536 CROSS_FILE_LOCATION_SCORE
537 };
538 confidence += location_score * LOCATION_WEIGHT;
539
540 Some(confidence)
541}
542
543fn create_renamed_change(base: &NodeSnap, target: &NodeSnap) -> NodeChange {
544 NodeChange {
545 symbol_name: target.name.clone(),
546 qualified_name: target.display_qualified_name(),
547 kind: target.kind_str.clone(),
548 change_type: ChangeType::Renamed,
549 base_location: Some(base.to_location()),
550 target_location: Some(target.to_location()),
551 signature_before: base.signature.clone(),
552 signature_after: target.signature.clone(),
553 }
554}
555
556fn append_removed_changes(
557 changes: &mut Vec<NodeChange>,
558 removed: &[NodeSnap],
559 renamed_qnames: &HashSet<String>,
560) {
561 for snap in removed {
562 if !renamed_qnames.contains(&snap.qualified_name) {
563 changes.push(NodeChange {
564 symbol_name: snap.name.clone(),
565 qualified_name: snap.display_qualified_name(),
566 kind: snap.kind_str.clone(),
567 change_type: ChangeType::Removed,
568 base_location: Some(snap.clone().into_location()),
569 target_location: None,
570 signature_before: snap.signature.clone(),
571 signature_after: None,
572 });
573 }
574 }
575}
576
577fn append_added_changes(
578 changes: &mut Vec<NodeChange>,
579 added: &[NodeSnap],
580 renamed_qnames: &HashSet<String>,
581) {
582 for snap in added {
583 if !renamed_qnames.contains(&snap.qualified_name) {
584 changes.push(NodeChange {
585 symbol_name: snap.name.clone(),
586 qualified_name: snap.display_qualified_name(),
587 kind: snap.kind_str.clone(),
588 change_type: ChangeType::Added,
589 base_location: None,
590 target_location: Some(snap.clone().into_location()),
591 signature_before: None,
592 signature_after: snap.signature.clone(),
593 });
594 }
595 }
596}
597
598fn strip_worktree_prefix(path: &Path, opts: &DiffOptions) -> PathBuf {
600 if !opts.old_worktree_path.as_os_str().is_empty()
601 && let Ok(relative) = path.strip_prefix(&opts.old_worktree_path)
602 {
603 return relative.to_path_buf();
604 }
605 if !opts.new_worktree_path.as_os_str().is_empty()
606 && let Ok(relative) = path.strip_prefix(&opts.new_worktree_path)
607 {
608 return relative.to_path_buf();
609 }
610 path.to_path_buf()
611}
612
613fn levenshtein_similarity(a: &str, b: &str) -> f64 {
615 let distance = strsim::levenshtein(a, b);
616 let max_len = a.len().max(b.len());
617 if max_len == 0 {
618 return 1.0;
619 }
620 let distance = f64::from(u32::try_from(distance).unwrap_or(u32::MAX));
621 let max_len = f64::from(u32::try_from(max_len).unwrap_or(u32::MAX));
622 1.0 - (distance / max_len)
623}
624
625fn node_kind_to_string(kind: NodeKind) -> String {
629 match kind {
630 NodeKind::Function => "function",
631 NodeKind::Method => "method",
632 NodeKind::Class => "class",
633 NodeKind::Interface => "interface",
634 NodeKind::Trait => "trait",
635 NodeKind::Module => "module",
636 NodeKind::Variable => "variable",
637 NodeKind::Constant => "constant",
638 NodeKind::Type => "type",
639 NodeKind::Struct => "struct",
640 NodeKind::Enum => "enum",
641 NodeKind::EnumVariant => "enum_variant",
642 NodeKind::Macro => "macro",
643 NodeKind::Parameter => "parameter",
644 NodeKind::Property => "property",
645 NodeKind::Import => "import",
646 NodeKind::Export => "export",
647 NodeKind::Component => "component",
648 NodeKind::Service => "service",
649 NodeKind::Resource => "resource",
650 NodeKind::Endpoint => "endpoint",
651 NodeKind::Test => "test",
652 _ => "other",
653 }
654 .to_string()
655}
656
657#[cfg(test)]
662mod tests {
663 use super::*;
664
665 #[test]
666 fn levenshtein_similarity_bounds() {
667 assert!((levenshtein_similarity("hello", "hello") - 1.0).abs() < 1e-10);
668 assert!((levenshtein_similarity("", "") - 1.0).abs() < 1e-10);
669 assert!(levenshtein_similarity("hello", "hallo") > 0.7);
670 assert!(levenshtein_similarity("hello", "world") < 0.5);
671 }
672
673 #[test]
674 fn change_type_wire_strings_match_pre_db20() {
675 assert_eq!(ChangeType::Added.as_str(), "added");
676 assert_eq!(ChangeType::Removed.as_str(), "removed");
677 assert_eq!(ChangeType::Modified.as_str(), "modified");
678 assert_eq!(ChangeType::Renamed.as_str(), "renamed");
679 assert_eq!(ChangeType::SignatureChanged.as_str(), "signature_changed");
680 assert_eq!(ChangeType::Unchanged.as_str(), "unchanged");
681 }
682
683 #[test]
684 fn diff_summary_from_changes_tallies_each_bucket() {
685 let changes = vec![
686 NodeChange {
687 symbol_name: "a".into(),
688 qualified_name: "a".into(),
689 kind: "function".into(),
690 change_type: ChangeType::Added,
691 base_location: None,
692 target_location: None,
693 signature_before: None,
694 signature_after: None,
695 },
696 NodeChange {
697 symbol_name: "b".into(),
698 qualified_name: "b".into(),
699 kind: "function".into(),
700 change_type: ChangeType::Removed,
701 base_location: None,
702 target_location: None,
703 signature_before: None,
704 signature_after: None,
705 },
706 NodeChange {
707 symbol_name: "c".into(),
708 qualified_name: "c".into(),
709 kind: "function".into(),
710 change_type: ChangeType::SignatureChanged,
711 base_location: None,
712 target_location: None,
713 signature_before: None,
714 signature_after: None,
715 },
716 ];
717 let summary = DiffSummary::from_changes(&changes);
718 assert_eq!(summary.added, 1);
719 assert_eq!(summary.removed, 1);
720 assert_eq!(summary.signature_changed, 1);
721 assert_eq!(summary.modified, 0);
722 assert_eq!(summary.renamed, 0);
723 assert_eq!(summary.unchanged, 0);
724 }
725
726 #[test]
727 fn empty_snapshots_produce_empty_diff() {
728 use std::sync::Arc;
729
730 use sqry_core::graph::unified::concurrent::CodeGraph;
731
732 let old = Arc::new(CodeGraph::new().snapshot());
733 let new = Arc::new(CodeGraph::new().snapshot());
734
735 let cmp = super::super::ComparativeQueryDb::new(old, new);
736 let out = cmp.diff_default();
737 assert!(out.changes.is_empty());
738 assert_eq!(out.summary, DiffSummary::default());
739 }
740
741 #[test]
742 fn strip_worktree_prefix_falls_back_when_empty() {
743 let p = PathBuf::from("/tmp/foo/bar.rs");
744 let out = strip_worktree_prefix(&p, &DiffOptions::default());
745 assert_eq!(out, p);
747 }
748
749 #[test]
750 fn strip_worktree_prefix_strips_old_root() {
751 let opts = DiffOptions {
752 old_worktree_path: PathBuf::from("/tmp/old"),
753 new_worktree_path: PathBuf::from("/tmp/new"),
754 };
755 let p = PathBuf::from("/tmp/old/src/foo.rs");
756 let out = strip_worktree_prefix(&p, &opts);
757 assert_eq!(out, PathBuf::from("src/foo.rs"));
758 }
759}