1#![allow(clippy::collapsible_if)]
9#![allow(clippy::needless_borrow)]
11
12use std::collections::{HashMap, HashSet};
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15
16use super::unified::concurrent::CodeGraph;
17use super::unified::node::kind::NodeKind;
18
19const SIGNATURE_WEIGHT: f64 = 0.7;
24const LOCATION_WEIGHT: f64 = 0.3;
25const SIGNATURE_MIN_SCORE: f64 = 0.7;
26const RENAME_CONFIDENCE_THRESHOLD: f64 = 0.9;
27const SAME_FILE_LINE_WINDOW: i32 = 50;
28const SAME_FILE_LINE_NORMALIZER: f64 = 100.0;
29const SAME_FILE_MAX_PENALTY: f64 = 0.5;
30const SAME_FILE_FAR_SCORE: f64 = 0.3;
31const CROSS_FILE_LOCATION_SCORE: f64 = 0.7;
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39pub enum ChangeType {
40 Added,
42 Removed,
44 Modified,
46 Renamed,
48 SignatureChanged,
50 Unchanged,
52}
53
54impl ChangeType {
55 #[must_use]
57 pub fn as_str(&self) -> &'static str {
58 match self {
59 ChangeType::Added => "added",
60 ChangeType::Removed => "removed",
61 ChangeType::Modified => "modified",
62 ChangeType::Renamed => "renamed",
63 ChangeType::SignatureChanged => "signature_changed",
64 ChangeType::Unchanged => "unchanged",
65 }
66 }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct NodeLocation {
72 pub file_path: PathBuf,
74 pub start_line: u32,
76 pub end_line: u32,
78 pub start_column: u32,
80 pub end_column: u32,
82}
83
84#[derive(Debug, Clone)]
86pub struct NodeChange {
87 pub name: String,
89 pub qualified_name: String,
91 pub kind: String,
93 pub change_type: ChangeType,
95 pub base_location: Option<NodeLocation>,
97 pub target_location: Option<NodeLocation>,
99 pub signature_before: Option<String>,
101 pub signature_after: Option<String>,
103}
104
105#[derive(Debug, Clone, Default)]
107pub struct DiffSummary {
108 pub added: u64,
110 pub removed: u64,
112 pub modified: u64,
114 pub renamed: u64,
116 pub signature_changed: u64,
118 pub unchanged: u64,
120}
121
122impl DiffSummary {
123 #[must_use]
125 pub fn from_changes(changes: &[NodeChange]) -> Self {
126 let mut summary = Self::default();
127
128 for change in changes {
129 match change.change_type {
130 ChangeType::Added => summary.added += 1,
131 ChangeType::Removed => summary.removed += 1,
132 ChangeType::Modified => summary.modified += 1,
133 ChangeType::Renamed => summary.renamed += 1,
134 ChangeType::SignatureChanged => summary.signature_changed += 1,
135 ChangeType::Unchanged => summary.unchanged += 1,
136 }
137 }
138
139 summary
140 }
141}
142
143#[derive(Debug, Clone)]
145pub struct DiffResult {
146 pub changes: Vec<NodeChange>,
148 pub summary: DiffSummary,
150}
151
152#[derive(Clone)]
158struct NodeSnapshot {
159 name: String,
160 qualified_name: String,
161 kind_str: String,
162 kind: NodeKind,
163 signature: Option<String>,
164 file_path: PathBuf,
165 start_line: u32,
166 end_line: u32,
167 start_column: u32,
168 end_column: u32,
169}
170
171pub struct GraphComparator {
206 base: Arc<CodeGraph>,
207 target: Arc<CodeGraph>,
208 #[allow(dead_code)] workspace_root: PathBuf,
210 base_worktree_path: PathBuf,
211 target_worktree_path: PathBuf,
212}
213
214impl GraphComparator {
215 #[must_use]
225 pub fn new(
226 base: Arc<CodeGraph>,
227 target: Arc<CodeGraph>,
228 workspace_root: PathBuf,
229 base_worktree_path: PathBuf,
230 target_worktree_path: PathBuf,
231 ) -> Self {
232 Self {
233 base,
234 target,
235 workspace_root,
236 base_worktree_path,
237 target_worktree_path,
238 }
239 }
240
241 pub fn compute_changes(&self) -> anyhow::Result<DiffResult> {
247 tracing::debug!("Computing symbol changes from CodeGraph");
248
249 let base_map = Self::build_node_map(&self.base, &self.base_worktree_path);
251 let target_map = Self::build_node_map(&self.target, &self.target_worktree_path);
252
253 let (added_nodes, modified_changes) =
254 self.collect_added_and_modified(&base_map, &target_map);
255 let removed_nodes = collect_removed_nodes(&base_map, &target_map);
256
257 let mut changes = modified_changes;
258
259 let (rename_changes, renamed_qnames) = self.collect_renames(&removed_nodes, &added_nodes);
260 changes.extend(rename_changes);
261
262 self.append_removed_changes(&mut changes, &removed_nodes, &renamed_qnames);
263 self.append_added_changes(&mut changes, &added_nodes, &renamed_qnames);
264
265 let summary = DiffSummary::from_changes(&changes);
266
267 tracing::debug!(total_changes = changes.len(), "Computed symbol changes");
268
269 Ok(DiffResult { changes, summary })
270 }
271
272 fn build_node_map(graph: &CodeGraph, worktree_path: &Path) -> HashMap<String, NodeSnapshot> {
274 let snapshot = graph.snapshot();
275 let strings = snapshot.strings();
276 let files = snapshot.files();
277
278 let mut map = HashMap::new();
279
280 for (_node_id, entry) in snapshot.iter_nodes() {
281 if entry.is_unified_loser() {
285 continue;
286 }
287 let name = strings
288 .resolve(entry.name)
289 .map(|s| s.to_string())
290 .unwrap_or_default();
291
292 let qualified_name = entry
293 .qualified_name
294 .and_then(|sid| strings.resolve(sid))
295 .map_or_else(|| name.clone(), |s| s.to_string());
296
297 if qualified_name.is_empty() {
299 continue;
300 }
301
302 let signature = entry
303 .signature
304 .and_then(|sid| strings.resolve(sid))
305 .map(|s| s.to_string());
306
307 let file_path = files
308 .resolve(entry.file)
309 .map(|p| worktree_path.join(p.as_ref()))
310 .unwrap_or_default();
311
312 let node_snap = NodeSnapshot {
313 name,
314 qualified_name: qualified_name.clone(),
315 kind_str: node_kind_to_string(entry.kind),
316 kind: entry.kind,
317 signature,
318 file_path,
319 start_line: entry.start_line,
320 end_line: entry.end_line,
321 start_column: entry.start_column,
322 end_column: entry.end_column,
323 };
324
325 map.insert(qualified_name, node_snap);
326 }
327
328 map
329 }
330
331 fn collect_added_and_modified(
332 &self,
333 base_map: &HashMap<String, NodeSnapshot>,
334 target_map: &HashMap<String, NodeSnapshot>,
335 ) -> (Vec<NodeSnapshot>, Vec<NodeChange>) {
336 let mut added_nodes = Vec::new();
337 let mut changes = Vec::new();
338
339 for (qname, target_snap) in target_map {
340 match base_map.get(qname) {
341 None => {
342 added_nodes.push(target_snap.clone());
343 }
344 Some(base_snap) => {
345 if let Some(change) = self.detect_modification(base_snap, target_snap, qname) {
346 changes.push(change);
347 }
348 }
349 }
350 }
351
352 (added_nodes, changes)
353 }
354
355 fn collect_renames(
356 &self,
357 removed_nodes: &[NodeSnapshot],
358 added_nodes: &[NodeSnapshot],
359 ) -> (Vec<NodeChange>, HashSet<String>) {
360 let renames = self.detect_renames(removed_nodes, added_nodes);
361 let mut rename_changes = Vec::new();
362 let mut renamed_qnames = HashSet::new();
363
364 for (base_snap, target_snap) in &renames {
365 renamed_qnames.insert(base_snap.qualified_name.clone());
366 renamed_qnames.insert(target_snap.qualified_name.clone());
367 rename_changes.push(self.create_renamed_change(base_snap, target_snap));
368 }
369
370 (rename_changes, renamed_qnames)
371 }
372
373 fn append_removed_changes(
374 &self,
375 changes: &mut Vec<NodeChange>,
376 removed_nodes: &[NodeSnapshot],
377 renamed_qnames: &HashSet<String>,
378 ) {
379 for base_snap in removed_nodes {
380 if !renamed_qnames.contains(&base_snap.qualified_name) {
381 changes.push(self.create_removed_change(base_snap));
382 }
383 }
384 }
385
386 fn append_added_changes(
387 &self,
388 changes: &mut Vec<NodeChange>,
389 added_nodes: &[NodeSnapshot],
390 renamed_qnames: &HashSet<String>,
391 ) {
392 for target_snap in added_nodes {
393 if !renamed_qnames.contains(&target_snap.qualified_name) {
394 changes.push(self.create_added_change(target_snap));
395 }
396 }
397 }
398
399 fn detect_modification(
401 &self,
402 base_snap: &NodeSnapshot,
403 target_snap: &NodeSnapshot,
404 qname: &str,
405 ) -> Option<NodeChange> {
406 let signature_changed = base_snap.signature != target_snap.signature;
408
409 let base_rel = self.strip_worktree_prefix(&base_snap.file_path);
411 let target_rel = self.strip_worktree_prefix(&target_snap.file_path);
412
413 let body_changed = base_snap.start_line != target_snap.start_line
415 || base_snap.end_line != target_snap.end_line
416 || base_rel != target_rel;
417
418 if signature_changed {
419 Some(NodeChange {
420 name: target_snap.name.clone(),
421 qualified_name: qname.to_string(),
422 kind: target_snap.kind_str.clone(),
423 change_type: ChangeType::SignatureChanged,
424 base_location: Some(self.node_snap_to_location(base_snap, true)),
425 target_location: Some(self.node_snap_to_location(target_snap, false)),
426 signature_before: base_snap.signature.clone(),
427 signature_after: target_snap.signature.clone(),
428 })
429 } else if body_changed {
430 Some(NodeChange {
431 name: target_snap.name.clone(),
432 qualified_name: qname.to_string(),
433 kind: target_snap.kind_str.clone(),
434 change_type: ChangeType::Modified,
435 base_location: Some(self.node_snap_to_location(base_snap, true)),
436 target_location: Some(self.node_snap_to_location(target_snap, false)),
437 signature_before: base_snap.signature.clone(),
438 signature_after: target_snap.signature.clone(),
439 })
440 } else {
441 None
442 }
443 }
444
445 fn detect_renames(
447 &self,
448 removed: &[NodeSnapshot],
449 added: &[NodeSnapshot],
450 ) -> Vec<(NodeSnapshot, NodeSnapshot)> {
451 let mut renames = Vec::new();
452 let mut matched_added = HashSet::new();
453
454 for removed_snap in removed {
455 let mut best_match: Option<(usize, f64)> = None;
456
457 for (idx, added_snap) in added.iter().enumerate() {
458 if matched_added.contains(&idx) {
459 continue;
460 }
461
462 let Some(score) = self.is_likely_rename(removed_snap, added_snap) else {
463 continue;
464 };
465
466 let is_better = match best_match {
467 Some((_, best_score)) => score > best_score,
468 None => true,
469 };
470 if is_better {
471 best_match = Some((idx, score));
472 }
473 }
474
475 if let Some((idx, score)) = best_match {
476 if score >= RENAME_CONFIDENCE_THRESHOLD {
477 matched_added.insert(idx);
478 renames.push((removed_snap.clone(), added[idx].clone()));
479
480 tracing::debug!(
481 from = %removed_snap.qualified_name,
482 to = %added[idx].qualified_name,
483 confidence = %score,
484 "Detected rename"
485 );
486 }
487 }
488 }
489
490 renames
491 }
492
493 fn is_likely_rename(
495 &self,
496 base_snap: &NodeSnapshot,
497 target_snap: &NodeSnapshot,
498 ) -> Option<f64> {
499 if base_snap.kind != target_snap.kind {
501 return None;
502 }
503
504 let mut confidence = 0.0;
505
506 let sig_score = match (&base_snap.signature, &target_snap.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
519 if sig_score < SIGNATURE_MIN_SCORE {
520 return None;
521 }
522
523 confidence += sig_score * SIGNATURE_WEIGHT;
524
525 let base_rel = self.strip_worktree_prefix(&base_snap.file_path);
527 let target_rel = self.strip_worktree_prefix(&target_snap.file_path);
528
529 let location_score = if base_rel == target_rel {
531 let base_line: i32 = base_snap.start_line.try_into().unwrap_or(i32::MAX);
532 let target_line: i32 = target_snap.start_line.try_into().unwrap_or(i32::MAX);
533 let line_diff = (base_line - target_line).abs();
534 if line_diff <= SAME_FILE_LINE_WINDOW {
535 1.0 - (f64::from(line_diff) / SAME_FILE_LINE_NORMALIZER).min(SAME_FILE_MAX_PENALTY)
536 } else {
537 SAME_FILE_FAR_SCORE
538 }
539 } else {
540 CROSS_FILE_LOCATION_SCORE
541 };
542
543 confidence += location_score * LOCATION_WEIGHT;
544
545 Some(confidence)
546 }
547
548 fn create_added_change(&self, snap: &NodeSnapshot) -> NodeChange {
549 NodeChange {
550 name: snap.name.clone(),
551 qualified_name: snap.qualified_name.clone(),
552 kind: snap.kind_str.clone(),
553 change_type: ChangeType::Added,
554 base_location: None,
555 target_location: Some(self.node_snap_to_location(snap, false)),
556 signature_before: None,
557 signature_after: snap.signature.clone(),
558 }
559 }
560
561 fn create_removed_change(&self, snap: &NodeSnapshot) -> NodeChange {
562 NodeChange {
563 name: snap.name.clone(),
564 qualified_name: snap.qualified_name.clone(),
565 kind: snap.kind_str.clone(),
566 change_type: ChangeType::Removed,
567 base_location: Some(self.node_snap_to_location(snap, true)),
568 target_location: None,
569 signature_before: snap.signature.clone(),
570 signature_after: None,
571 }
572 }
573
574 fn create_renamed_change(
575 &self,
576 base_snap: &NodeSnapshot,
577 target_snap: &NodeSnapshot,
578 ) -> NodeChange {
579 NodeChange {
580 name: target_snap.name.clone(),
581 qualified_name: target_snap.qualified_name.clone(),
582 kind: target_snap.kind_str.clone(),
583 change_type: ChangeType::Renamed,
584 base_location: Some(self.node_snap_to_location(base_snap, true)),
585 target_location: Some(self.node_snap_to_location(target_snap, false)),
586 signature_before: base_snap.signature.clone(),
587 signature_after: target_snap.signature.clone(),
588 }
589 }
590
591 fn node_snap_to_location(&self, snap: &NodeSnapshot, is_base: bool) -> NodeLocation {
592 let relative_path = self.translate_worktree_path_to_relative(&snap.file_path, is_base);
593
594 NodeLocation {
595 file_path: relative_path,
596 start_line: snap.start_line,
597 end_line: snap.end_line,
598 start_column: snap.start_column,
599 end_column: snap.end_column,
600 }
601 }
602
603 fn strip_worktree_prefix(&self, path: &Path) -> PathBuf {
604 if let Ok(relative) = path.strip_prefix(&self.base_worktree_path) {
605 return relative.to_path_buf();
606 }
607 if let Ok(relative) = path.strip_prefix(&self.target_worktree_path) {
608 return relative.to_path_buf();
609 }
610 path.to_path_buf()
611 }
612
613 fn translate_worktree_path_to_relative(&self, worktree_path: &Path, is_base: bool) -> PathBuf {
614 let worktree_root = if is_base {
615 &self.base_worktree_path
616 } else {
617 &self.target_worktree_path
618 };
619
620 if let Ok(relative) = worktree_path.strip_prefix(worktree_root) {
621 return relative.to_path_buf();
622 }
623
624 worktree_path.to_path_buf()
625 }
626}
627
628fn collect_removed_nodes(
630 base_map: &HashMap<String, NodeSnapshot>,
631 target_map: &HashMap<String, NodeSnapshot>,
632) -> Vec<NodeSnapshot> {
633 base_map
634 .iter()
635 .filter(|(qname, _)| !target_map.contains_key(*qname))
636 .map(|(_, snap)| snap.clone())
637 .collect()
638}
639
640fn levenshtein_similarity(a: &str, b: &str) -> f64 {
642 use rapidfuzz::distance::levenshtein;
643
644 let max_len = a.len().max(b.len());
645 if max_len == 0 {
646 return 1.0;
647 }
648
649 let distance = levenshtein::distance(a.chars(), b.chars());
650 let distance = f64::from(u32::try_from(distance).unwrap_or(u32::MAX));
651 let max_len = f64::from(u32::try_from(max_len).unwrap_or(u32::MAX));
652 1.0 - (distance / max_len)
653}
654
655fn node_kind_to_string(kind: NodeKind) -> String {
657 match kind {
658 NodeKind::Function => "function",
659 NodeKind::Method => "method",
660 NodeKind::Class => "class",
661 NodeKind::Interface => "interface",
662 NodeKind::Trait => "trait",
663 NodeKind::Module => "module",
664 NodeKind::Variable => "variable",
665 NodeKind::Constant => "constant",
666 NodeKind::Type => "type",
667 NodeKind::Struct => "struct",
668 NodeKind::Enum => "enum",
669 NodeKind::EnumVariant => "enum_variant",
670 NodeKind::Macro => "macro",
671 NodeKind::Parameter => "parameter",
672 NodeKind::Property => "property",
673 NodeKind::Import => "import",
674 NodeKind::Export => "export",
675 NodeKind::Component => "component",
676 NodeKind::Service => "service",
677 NodeKind::Resource => "resource",
678 NodeKind::Endpoint => "endpoint",
679 NodeKind::Test => "test",
680 _ => "other",
681 }
682 .to_string()
683}
684
685#[cfg(test)]
686mod tests {
687 use super::*;
688
689 #[test]
690 fn test_levenshtein_similarity_identical() {
691 assert!((levenshtein_similarity("hello", "hello") - 1.0).abs() < f64::EPSILON);
692 }
693
694 #[test]
695 fn test_levenshtein_similarity_empty() {
696 assert!((levenshtein_similarity("", "") - 1.0).abs() < f64::EPSILON);
697 }
698
699 #[test]
700 fn test_levenshtein_similarity_similar() {
701 let score = levenshtein_similarity("hello", "hallo");
702 assert!(score > 0.7);
703 }
704
705 #[test]
706 fn test_levenshtein_similarity_different() {
707 let score = levenshtein_similarity("hello", "world");
708 assert!(score < 0.5);
709 }
710
711 #[test]
712 fn test_diff_summary_from_changes() {
713 let changes = vec![
714 NodeChange {
715 name: "foo".to_string(),
716 qualified_name: "mod::foo".to_string(),
717 kind: "function".to_string(),
718 change_type: ChangeType::Added,
719 base_location: None,
720 target_location: None,
721 signature_before: None,
722 signature_after: None,
723 },
724 NodeChange {
725 name: "bar".to_string(),
726 qualified_name: "mod::bar".to_string(),
727 kind: "function".to_string(),
728 change_type: ChangeType::Removed,
729 base_location: None,
730 target_location: None,
731 signature_before: None,
732 signature_after: None,
733 },
734 NodeChange {
735 name: "baz".to_string(),
736 qualified_name: "mod::baz".to_string(),
737 kind: "function".to_string(),
738 change_type: ChangeType::Modified,
739 base_location: None,
740 target_location: None,
741 signature_before: None,
742 signature_after: None,
743 },
744 ];
745
746 let summary = DiffSummary::from_changes(&changes);
747 assert_eq!(summary.added, 1);
748 assert_eq!(summary.removed, 1);
749 assert_eq!(summary.modified, 1);
750 assert_eq!(summary.renamed, 0);
751 assert_eq!(summary.signature_changed, 0);
752 }
753
754 #[test]
755 fn test_change_type_as_str() {
756 assert_eq!(ChangeType::Added.as_str(), "added");
757 assert_eq!(ChangeType::Removed.as_str(), "removed");
758 assert_eq!(ChangeType::Modified.as_str(), "modified");
759 assert_eq!(ChangeType::Renamed.as_str(), "renamed");
760 assert_eq!(ChangeType::SignatureChanged.as_str(), "signature_changed");
761 assert_eq!(ChangeType::Unchanged.as_str(), "unchanged");
762 }
763
764 #[test]
765 fn test_node_kind_to_string() {
766 assert_eq!(node_kind_to_string(NodeKind::Function), "function");
767 assert_eq!(node_kind_to_string(NodeKind::Class), "class");
768 assert_eq!(node_kind_to_string(NodeKind::Method), "method");
769 }
770}