Skip to main content

sqry_core/graph/
diff.rs

1//! Graph diff and comparison for semantic diff operations.
2//!
3//! This module provides functionality for comparing two CodeGraph instances
4//! to detect symbol changes between different versions of code (e.g., git refs).
5
6// Allow collapsible_if for readability in this module - the nested if patterns
7// are clearer than collapsed let-chains for the rename detection logic.
8#![allow(clippy::collapsible_if)]
9// Allow needless_borrow since the borrow patterns are consistent for function calls
10#![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
19// ============================================================================
20// Constants for rename detection heuristics
21// ============================================================================
22
23const 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// ============================================================================
34// Core Types
35// ============================================================================
36
37/// Type of change detected for a symbol.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39pub enum ChangeType {
40    /// Node was added in target version
41    Added,
42    /// Node was removed in target version
43    Removed,
44    /// Node body/location changed
45    Modified,
46    /// Node was renamed
47    Renamed,
48    /// Node signature changed
49    SignatureChanged,
50    /// Node is unchanged (only included if requested)
51    Unchanged,
52}
53
54impl ChangeType {
55    /// Returns the string representation of the change type.
56    #[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/// Location information for a symbol.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct NodeLocation {
72    /// File path relative to workspace
73    pub file_path: PathBuf,
74    /// Start line (1-based)
75    pub start_line: u32,
76    /// End line (1-based)
77    pub end_line: u32,
78    /// Start column (0-based)
79    pub start_column: u32,
80    /// End column (0-based)
81    pub end_column: u32,
82}
83
84/// A change record for a single symbol.
85#[derive(Debug, Clone)]
86pub struct NodeChange {
87    /// Node name
88    pub name: String,
89    /// Fully qualified name
90    pub qualified_name: String,
91    /// Node kind (function, class, etc.)
92    pub kind: String,
93    /// Type of change
94    pub change_type: ChangeType,
95    /// Location in base version (for removed, modified, renamed)
96    pub base_location: Option<NodeLocation>,
97    /// Location in target version (for added, modified, renamed)
98    pub target_location: Option<NodeLocation>,
99    /// Signature in base version
100    pub signature_before: Option<String>,
101    /// Signature in target version
102    pub signature_after: Option<String>,
103}
104
105/// Summary statistics for a diff.
106#[derive(Debug, Clone, Default)]
107pub struct DiffSummary {
108    /// Number of added symbols
109    pub added: u64,
110    /// Number of removed symbols
111    pub removed: u64,
112    /// Number of modified symbols
113    pub modified: u64,
114    /// Number of renamed symbols
115    pub renamed: u64,
116    /// Number of signature-changed symbols
117    pub signature_changed: u64,
118    /// Number of unchanged symbols
119    pub unchanged: u64,
120}
121
122impl DiffSummary {
123    /// Computes summary from a list of changes.
124    #[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/// Result of a graph comparison.
144#[derive(Debug, Clone)]
145pub struct DiffResult {
146    /// All detected changes
147    pub changes: Vec<NodeChange>,
148    /// Summary statistics
149    pub summary: DiffSummary,
150}
151
152// ============================================================================
153// Internal Node Snapshot
154// ============================================================================
155
156/// Internal representation of a node for comparison.
157#[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
171// ============================================================================
172// Graph Comparator
173// ============================================================================
174
175/// Compares two `CodeGraph` instances and produces change records.
176///
177/// This comparator uses heuristic matching to detect renames and
178/// distinguishes between body changes and signature changes.
179///
180/// # Example
181///
182/// ```no_run
183/// use sqry_core::graph::diff::GraphComparator;
184/// use sqry_core::graph::CodeGraph;
185/// use std::sync::Arc;
186/// use std::path::PathBuf;
187///
188/// let base_graph: Arc<CodeGraph> = // ... build graph for base version
189/// # unimplemented!();
190/// let target_graph: Arc<CodeGraph> = // ... build graph for target version
191/// # unimplemented!();
192///
193/// let comparator = GraphComparator::new(
194///     base_graph,
195///     target_graph,
196///     PathBuf::from("/workspace"),
197///     PathBuf::from("/tmp/base-worktree"),
198///     PathBuf::from("/tmp/target-worktree"),
199/// );
200///
201/// let result = comparator.compute_changes()?;
202/// println!("Found {} changes", result.changes.len());
203/// # Ok::<(), anyhow::Error>(())
204/// ```
205pub struct GraphComparator {
206    base: Arc<CodeGraph>,
207    target: Arc<CodeGraph>,
208    #[allow(dead_code)] // Kept for future use (translating paths to workspace)
209    workspace_root: PathBuf,
210    base_worktree_path: PathBuf,
211    target_worktree_path: PathBuf,
212}
213
214impl GraphComparator {
215    /// Creates a new graph comparator.
216    ///
217    /// # Arguments
218    ///
219    /// * `base` - `CodeGraph` for the base version
220    /// * `target` - `CodeGraph` for the target version
221    /// * `workspace_root` - Path to the actual workspace root
222    /// * `base_worktree_path` - Path to the temporary base worktree
223    /// * `target_worktree_path` - Path to the temporary target worktree
224    #[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    /// Computes all symbol changes between base and target graphs.
242    ///
243    /// # Errors
244    ///
245    /// Returns error if graph access fails.
246    pub fn compute_changes(&self) -> anyhow::Result<DiffResult> {
247        tracing::debug!("Computing symbol changes from CodeGraph");
248
249        // Build qualified_name maps for O(1) lookup
250        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    /// Builds a map of `qualified_name` -> `NodeSnapshot` from the graph.
273    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            // Gate 0d iter-2 fix: skip unified losers from
282            // snapshot-diff node map. See
283            // `NodeEntry::is_unified_loser`.
284            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            // Skip nodes without qualified names (call sites, imports, etc.)
298            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    /// Detects if a node was modified and returns the change record.
400    fn detect_modification(
401        &self,
402        base_snap: &NodeSnapshot,
403        target_snap: &NodeSnapshot,
404        qname: &str,
405    ) -> Option<NodeChange> {
406        // Check for signature changes
407        let signature_changed = base_snap.signature != target_snap.signature;
408
409        // Normalize file paths for comparison
410        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        // Check for body changes (line numbers or file location)
414        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    /// Detects renamed nodes using heuristic matching.
446    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    /// Determines if two nodes are likely the same symbol that was renamed.
494    fn is_likely_rename(
495        &self,
496        base_snap: &NodeSnapshot,
497        target_snap: &NodeSnapshot,
498    ) -> Option<f64> {
499        // Criterion 1: Must be same node kind
500        if base_snap.kind != target_snap.kind {
501            return None;
502        }
503
504        let mut confidence = 0.0;
505
506        // Criterion 2: Signature similarity (70% weight)
507        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        // Normalize file paths for comparison
526        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        // Criterion 3: Location proximity (30% weight)
530        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
628/// Collect nodes that are in base but not in target.
629fn 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
640/// Computes the Levenshtein similarity between two strings (0.0 to 1.0).
641fn 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
655/// Convert `NodeKind` to string representation.
656fn 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}