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            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            // Skip nodes without qualified names (call sites, imports, etc.)
292            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    /// Detects if a node was modified and returns the change record.
394    fn detect_modification(
395        &self,
396        base_snap: &NodeSnapshot,
397        target_snap: &NodeSnapshot,
398        qname: &str,
399    ) -> Option<NodeChange> {
400        // Check for signature changes
401        let signature_changed = base_snap.signature != target_snap.signature;
402
403        // Normalize file paths for comparison
404        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        // Check for body changes (line numbers or file location)
408        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    /// Detects renamed nodes using heuristic matching.
440    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    /// Determines if two nodes are likely the same symbol that was renamed.
488    fn is_likely_rename(
489        &self,
490        base_snap: &NodeSnapshot,
491        target_snap: &NodeSnapshot,
492    ) -> Option<f64> {
493        // Criterion 1: Must be same node kind
494        if base_snap.kind != target_snap.kind {
495            return None;
496        }
497
498        let mut confidence = 0.0;
499
500        // Criterion 2: Signature similarity (70% weight)
501        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        // Normalize file paths for comparison
520        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        // Criterion 3: Location proximity (30% weight)
524        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
622/// Collect nodes that are in base but not in target.
623fn 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
634/// Computes the Levenshtein similarity between two strings (0.0 to 1.0).
635fn 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
649/// Convert `NodeKind` to string representation.
650fn 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}