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 let name = strings
282 .resolve(entry.name)
283 .map(|s| s.to_string())
284 .unwrap_or_default();
285
286 let qualified_name = entry
287 .qualified_name
288 .and_then(|sid| strings.resolve(sid))
289 .map_or_else(|| name.clone(), |s| s.to_string());
290
291 if qualified_name.is_empty() {
293 continue;
294 }
295
296 let signature = entry
297 .signature
298 .and_then(|sid| strings.resolve(sid))
299 .map(|s| s.to_string());
300
301 let file_path = files
302 .resolve(entry.file)
303 .map(|p| worktree_path.join(p.as_ref()))
304 .unwrap_or_default();
305
306 let node_snap = NodeSnapshot {
307 name,
308 qualified_name: qualified_name.clone(),
309 kind_str: node_kind_to_string(entry.kind),
310 kind: entry.kind,
311 signature,
312 file_path,
313 start_line: entry.start_line,
314 end_line: entry.end_line,
315 start_column: entry.start_column,
316 end_column: entry.end_column,
317 };
318
319 map.insert(qualified_name, node_snap);
320 }
321
322 map
323 }
324
325 fn collect_added_and_modified(
326 &self,
327 base_map: &HashMap<String, NodeSnapshot>,
328 target_map: &HashMap<String, NodeSnapshot>,
329 ) -> (Vec<NodeSnapshot>, Vec<NodeChange>) {
330 let mut added_nodes = Vec::new();
331 let mut changes = Vec::new();
332
333 for (qname, target_snap) in target_map {
334 match base_map.get(qname) {
335 None => {
336 added_nodes.push(target_snap.clone());
337 }
338 Some(base_snap) => {
339 if let Some(change) = self.detect_modification(base_snap, target_snap, qname) {
340 changes.push(change);
341 }
342 }
343 }
344 }
345
346 (added_nodes, changes)
347 }
348
349 fn collect_renames(
350 &self,
351 removed_nodes: &[NodeSnapshot],
352 added_nodes: &[NodeSnapshot],
353 ) -> (Vec<NodeChange>, HashSet<String>) {
354 let renames = self.detect_renames(removed_nodes, added_nodes);
355 let mut rename_changes = Vec::new();
356 let mut renamed_qnames = HashSet::new();
357
358 for (base_snap, target_snap) in &renames {
359 renamed_qnames.insert(base_snap.qualified_name.clone());
360 renamed_qnames.insert(target_snap.qualified_name.clone());
361 rename_changes.push(self.create_renamed_change(base_snap, target_snap));
362 }
363
364 (rename_changes, renamed_qnames)
365 }
366
367 fn append_removed_changes(
368 &self,
369 changes: &mut Vec<NodeChange>,
370 removed_nodes: &[NodeSnapshot],
371 renamed_qnames: &HashSet<String>,
372 ) {
373 for base_snap in removed_nodes {
374 if !renamed_qnames.contains(&base_snap.qualified_name) {
375 changes.push(self.create_removed_change(base_snap));
376 }
377 }
378 }
379
380 fn append_added_changes(
381 &self,
382 changes: &mut Vec<NodeChange>,
383 added_nodes: &[NodeSnapshot],
384 renamed_qnames: &HashSet<String>,
385 ) {
386 for target_snap in added_nodes {
387 if !renamed_qnames.contains(&target_snap.qualified_name) {
388 changes.push(self.create_added_change(target_snap));
389 }
390 }
391 }
392
393 fn detect_modification(
395 &self,
396 base_snap: &NodeSnapshot,
397 target_snap: &NodeSnapshot,
398 qname: &str,
399 ) -> Option<NodeChange> {
400 let signature_changed = base_snap.signature != target_snap.signature;
402
403 let base_rel = self.strip_worktree_prefix(&base_snap.file_path);
405 let target_rel = self.strip_worktree_prefix(&target_snap.file_path);
406
407 let body_changed = base_snap.start_line != target_snap.start_line
409 || base_snap.end_line != target_snap.end_line
410 || base_rel != target_rel;
411
412 if signature_changed {
413 Some(NodeChange {
414 name: target_snap.name.clone(),
415 qualified_name: qname.to_string(),
416 kind: target_snap.kind_str.clone(),
417 change_type: ChangeType::SignatureChanged,
418 base_location: Some(self.node_snap_to_location(base_snap, true)),
419 target_location: Some(self.node_snap_to_location(target_snap, false)),
420 signature_before: base_snap.signature.clone(),
421 signature_after: target_snap.signature.clone(),
422 })
423 } else if body_changed {
424 Some(NodeChange {
425 name: target_snap.name.clone(),
426 qualified_name: qname.to_string(),
427 kind: target_snap.kind_str.clone(),
428 change_type: ChangeType::Modified,
429 base_location: Some(self.node_snap_to_location(base_snap, true)),
430 target_location: Some(self.node_snap_to_location(target_snap, false)),
431 signature_before: base_snap.signature.clone(),
432 signature_after: target_snap.signature.clone(),
433 })
434 } else {
435 None
436 }
437 }
438
439 fn detect_renames(
441 &self,
442 removed: &[NodeSnapshot],
443 added: &[NodeSnapshot],
444 ) -> Vec<(NodeSnapshot, NodeSnapshot)> {
445 let mut renames = Vec::new();
446 let mut matched_added = HashSet::new();
447
448 for removed_snap in removed {
449 let mut best_match: Option<(usize, f64)> = None;
450
451 for (idx, added_snap) in added.iter().enumerate() {
452 if matched_added.contains(&idx) {
453 continue;
454 }
455
456 let Some(score) = self.is_likely_rename(removed_snap, added_snap) else {
457 continue;
458 };
459
460 let is_better = match best_match {
461 Some((_, best_score)) => score > best_score,
462 None => true,
463 };
464 if is_better {
465 best_match = Some((idx, score));
466 }
467 }
468
469 if let Some((idx, score)) = best_match {
470 if score >= RENAME_CONFIDENCE_THRESHOLD {
471 matched_added.insert(idx);
472 renames.push((removed_snap.clone(), added[idx].clone()));
473
474 tracing::debug!(
475 from = %removed_snap.qualified_name,
476 to = %added[idx].qualified_name,
477 confidence = %score,
478 "Detected rename"
479 );
480 }
481 }
482 }
483
484 renames
485 }
486
487 fn is_likely_rename(
489 &self,
490 base_snap: &NodeSnapshot,
491 target_snap: &NodeSnapshot,
492 ) -> Option<f64> {
493 if base_snap.kind != target_snap.kind {
495 return None;
496 }
497
498 let mut confidence = 0.0;
499
500 let sig_score = match (&base_snap.signature, &target_snap.signature) {
502 (Some(base_sig), Some(target_sig)) => {
503 if base_sig == target_sig {
504 1.0
505 } else {
506 levenshtein_similarity(base_sig, target_sig)
507 }
508 }
509 (None, None) => 1.0,
510 _ => return None,
511 };
512
513 if sig_score < SIGNATURE_MIN_SCORE {
514 return None;
515 }
516
517 confidence += sig_score * SIGNATURE_WEIGHT;
518
519 let base_rel = self.strip_worktree_prefix(&base_snap.file_path);
521 let target_rel = self.strip_worktree_prefix(&target_snap.file_path);
522
523 let location_score = if base_rel == target_rel {
525 let base_line: i32 = base_snap.start_line.try_into().unwrap_or(i32::MAX);
526 let target_line: i32 = target_snap.start_line.try_into().unwrap_or(i32::MAX);
527 let line_diff = (base_line - target_line).abs();
528 if line_diff <= SAME_FILE_LINE_WINDOW {
529 1.0 - (f64::from(line_diff) / SAME_FILE_LINE_NORMALIZER).min(SAME_FILE_MAX_PENALTY)
530 } else {
531 SAME_FILE_FAR_SCORE
532 }
533 } else {
534 CROSS_FILE_LOCATION_SCORE
535 };
536
537 confidence += location_score * LOCATION_WEIGHT;
538
539 Some(confidence)
540 }
541
542 fn create_added_change(&self, snap: &NodeSnapshot) -> NodeChange {
543 NodeChange {
544 name: snap.name.clone(),
545 qualified_name: snap.qualified_name.clone(),
546 kind: snap.kind_str.clone(),
547 change_type: ChangeType::Added,
548 base_location: None,
549 target_location: Some(self.node_snap_to_location(snap, false)),
550 signature_before: None,
551 signature_after: snap.signature.clone(),
552 }
553 }
554
555 fn create_removed_change(&self, snap: &NodeSnapshot) -> NodeChange {
556 NodeChange {
557 name: snap.name.clone(),
558 qualified_name: snap.qualified_name.clone(),
559 kind: snap.kind_str.clone(),
560 change_type: ChangeType::Removed,
561 base_location: Some(self.node_snap_to_location(snap, true)),
562 target_location: None,
563 signature_before: snap.signature.clone(),
564 signature_after: None,
565 }
566 }
567
568 fn create_renamed_change(
569 &self,
570 base_snap: &NodeSnapshot,
571 target_snap: &NodeSnapshot,
572 ) -> NodeChange {
573 NodeChange {
574 name: target_snap.name.clone(),
575 qualified_name: target_snap.qualified_name.clone(),
576 kind: target_snap.kind_str.clone(),
577 change_type: ChangeType::Renamed,
578 base_location: Some(self.node_snap_to_location(base_snap, true)),
579 target_location: Some(self.node_snap_to_location(target_snap, false)),
580 signature_before: base_snap.signature.clone(),
581 signature_after: target_snap.signature.clone(),
582 }
583 }
584
585 fn node_snap_to_location(&self, snap: &NodeSnapshot, is_base: bool) -> NodeLocation {
586 let relative_path = self.translate_worktree_path_to_relative(&snap.file_path, is_base);
587
588 NodeLocation {
589 file_path: relative_path,
590 start_line: snap.start_line,
591 end_line: snap.end_line,
592 start_column: snap.start_column,
593 end_column: snap.end_column,
594 }
595 }
596
597 fn strip_worktree_prefix(&self, path: &Path) -> PathBuf {
598 if let Ok(relative) = path.strip_prefix(&self.base_worktree_path) {
599 return relative.to_path_buf();
600 }
601 if let Ok(relative) = path.strip_prefix(&self.target_worktree_path) {
602 return relative.to_path_buf();
603 }
604 path.to_path_buf()
605 }
606
607 fn translate_worktree_path_to_relative(&self, worktree_path: &Path, is_base: bool) -> PathBuf {
608 let worktree_root = if is_base {
609 &self.base_worktree_path
610 } else {
611 &self.target_worktree_path
612 };
613
614 if let Ok(relative) = worktree_path.strip_prefix(worktree_root) {
615 return relative.to_path_buf();
616 }
617
618 worktree_path.to_path_buf()
619 }
620}
621
622fn collect_removed_nodes(
624 base_map: &HashMap<String, NodeSnapshot>,
625 target_map: &HashMap<String, NodeSnapshot>,
626) -> Vec<NodeSnapshot> {
627 base_map
628 .iter()
629 .filter(|(qname, _)| !target_map.contains_key(*qname))
630 .map(|(_, snap)| snap.clone())
631 .collect()
632}
633
634fn levenshtein_similarity(a: &str, b: &str) -> f64 {
636 use rapidfuzz::distance::levenshtein;
637
638 let max_len = a.len().max(b.len());
639 if max_len == 0 {
640 return 1.0;
641 }
642
643 let distance = levenshtein::distance(a.chars(), b.chars());
644 let distance = f64::from(u32::try_from(distance).unwrap_or(u32::MAX));
645 let max_len = f64::from(u32::try_from(max_len).unwrap_or(u32::MAX));
646 1.0 - (distance / max_len)
647}
648
649fn node_kind_to_string(kind: NodeKind) -> String {
651 match kind {
652 NodeKind::Function => "function",
653 NodeKind::Method => "method",
654 NodeKind::Class => "class",
655 NodeKind::Interface => "interface",
656 NodeKind::Trait => "trait",
657 NodeKind::Module => "module",
658 NodeKind::Variable => "variable",
659 NodeKind::Constant => "constant",
660 NodeKind::Type => "type",
661 NodeKind::Struct => "struct",
662 NodeKind::Enum => "enum",
663 NodeKind::EnumVariant => "enum_variant",
664 NodeKind::Macro => "macro",
665 NodeKind::Parameter => "parameter",
666 NodeKind::Property => "property",
667 NodeKind::Import => "import",
668 NodeKind::Export => "export",
669 NodeKind::Component => "component",
670 NodeKind::Service => "service",
671 NodeKind::Resource => "resource",
672 NodeKind::Endpoint => "endpoint",
673 NodeKind::Test => "test",
674 _ => "other",
675 }
676 .to_string()
677}
678
679#[cfg(test)]
680mod tests {
681 use super::*;
682
683 #[test]
684 fn test_levenshtein_similarity_identical() {
685 assert!((levenshtein_similarity("hello", "hello") - 1.0).abs() < f64::EPSILON);
686 }
687
688 #[test]
689 fn test_levenshtein_similarity_empty() {
690 assert!((levenshtein_similarity("", "") - 1.0).abs() < f64::EPSILON);
691 }
692
693 #[test]
694 fn test_levenshtein_similarity_similar() {
695 let score = levenshtein_similarity("hello", "hallo");
696 assert!(score > 0.7);
697 }
698
699 #[test]
700 fn test_levenshtein_similarity_different() {
701 let score = levenshtein_similarity("hello", "world");
702 assert!(score < 0.5);
703 }
704
705 #[test]
706 fn test_diff_summary_from_changes() {
707 let changes = vec![
708 NodeChange {
709 name: "foo".to_string(),
710 qualified_name: "mod::foo".to_string(),
711 kind: "function".to_string(),
712 change_type: ChangeType::Added,
713 base_location: None,
714 target_location: None,
715 signature_before: None,
716 signature_after: None,
717 },
718 NodeChange {
719 name: "bar".to_string(),
720 qualified_name: "mod::bar".to_string(),
721 kind: "function".to_string(),
722 change_type: ChangeType::Removed,
723 base_location: None,
724 target_location: None,
725 signature_before: None,
726 signature_after: None,
727 },
728 NodeChange {
729 name: "baz".to_string(),
730 qualified_name: "mod::baz".to_string(),
731 kind: "function".to_string(),
732 change_type: ChangeType::Modified,
733 base_location: None,
734 target_location: None,
735 signature_before: None,
736 signature_after: None,
737 },
738 ];
739
740 let summary = DiffSummary::from_changes(&changes);
741 assert_eq!(summary.added, 1);
742 assert_eq!(summary.removed, 1);
743 assert_eq!(summary.modified, 1);
744 assert_eq!(summary.renamed, 0);
745 assert_eq!(summary.signature_changed, 0);
746 }
747
748 #[test]
749 fn test_change_type_as_str() {
750 assert_eq!(ChangeType::Added.as_str(), "added");
751 assert_eq!(ChangeType::Removed.as_str(), "removed");
752 assert_eq!(ChangeType::Modified.as_str(), "modified");
753 assert_eq!(ChangeType::Renamed.as_str(), "renamed");
754 assert_eq!(ChangeType::SignatureChanged.as_str(), "signature_changed");
755 assert_eq!(ChangeType::Unchanged.as_str(), "unchanged");
756 }
757
758 #[test]
759 fn test_node_kind_to_string() {
760 assert_eq!(node_kind_to_string(NodeKind::Function), "function");
761 assert_eq!(node_kind_to_string(NodeKind::Class), "class");
762 assert_eq!(node_kind_to_string(NodeKind::Method), "method");
763 }
764}