Skip to main content

sqry_db/comparative/
diff.rs

1//! Semantic diff implementation for [`super::ComparativeQueryDb`].
2//!
3//! Ported from `sqry-mcp::execution::diff_comparator` (which in turn adapted
4//! `sqry-core::graph::diff::GraphComparator`) as part of Phase 3C / DB20. The
5//! MCP copy diverged from sqry-core's in three ways, all of which are
6//! preserved here so the MCP wire format is byte-for-byte stable:
7//!
8//! 1. Qualified names are formatted through
9//!    [`sqry_core::graph::unified::resolution::display_graph_qualified_name`]
10//!    when a known language is associated with the source file. This lets
11//!    per-language plugins override the stored qualified name (e.g. Swift
12//!    inserting `Type.` for `is_static` members).
13//! 2. The `is_static` flag is threaded through the comparator so the above
14//!    display logic receives the same information the graph node stored.
15//! 3. Line numbers / columns / paths are reported as plain fields on
16//!    [`NodeLocation`]; the MCP handler wraps them in its transport DTO
17//!    (`NodeRefData` + `fileUri`) so the wire format is owned by MCP.
18//!
19//! Rename detection heuristics (Levenshtein over signatures weighted 70%,
20//! location proximity weighted 30%, 90% confidence threshold) match the
21//! previous MCP `GraphComparator` bit-for-bit.
22
23use std::collections::{HashMap, HashSet};
24use std::path::{Path, PathBuf};
25
26use sqry_core::graph::Language;
27use sqry_core::graph::unified::concurrent::GraphSnapshot;
28use sqry_core::graph::unified::node::kind::NodeKind;
29use sqry_core::graph::unified::resolution::display_graph_qualified_name;
30
31// ============================================================================
32// Rename heuristic constants (frozen; match the pre-DB20 MCP comparator)
33// ============================================================================
34
35const SIGNATURE_WEIGHT: f64 = 0.7;
36const LOCATION_WEIGHT: f64 = 0.3;
37const SIGNATURE_MIN_SCORE: f64 = 0.7;
38const RENAME_CONFIDENCE_THRESHOLD: f64 = 0.9;
39const SAME_FILE_LINE_WINDOW: i32 = 50;
40const SAME_FILE_LINE_NORMALIZER: f64 = 100.0;
41const SAME_FILE_MAX_PENALTY: f64 = 0.5;
42const SAME_FILE_FAR_SCORE: f64 = 0.3;
43const CROSS_FILE_LOCATION_SCORE: f64 = 0.7;
44
45// ============================================================================
46// Public output types
47// ============================================================================
48
49/// Kind of change detected between the two snapshots.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
51pub enum ChangeType {
52    /// Node is present in the new snapshot but not the old.
53    Added,
54    /// Node is present in the old snapshot but not the new.
55    Removed,
56    /// Node exists on both sides but its body / location changed while the
57    /// signature stayed identical.
58    Modified,
59    /// Node was matched heuristically as a renamed version of a node that
60    /// disappeared (>= 90% confidence via signature + location scoring).
61    Renamed,
62    /// Node exists on both sides and its signature changed.
63    SignatureChanged,
64    /// Node exists unchanged on both sides. Callers opt into emitting these
65    /// via their own filter logic — [`compute_diff`] never emits this variant
66    /// directly.
67    Unchanged,
68}
69
70impl ChangeType {
71    /// Returns the stable wire-format string for this change type. The
72    /// mapping matches the pre-DB20 MCP and CLI outputs exactly.
73    #[must_use]
74    pub fn as_str(&self) -> &'static str {
75        match self {
76            ChangeType::Added => "added",
77            ChangeType::Removed => "removed",
78            ChangeType::Modified => "modified",
79            ChangeType::Renamed => "renamed",
80            ChangeType::SignatureChanged => "signature_changed",
81            ChangeType::Unchanged => "unchanged",
82        }
83    }
84}
85
86/// Location of a single changed node, reported in sqry-db-owned terms.
87///
88/// `file_path` is either absolute (when the caller supplied worktree roots
89/// via [`DiffOptions`]) or workspace-relative (when no worktree root was
90/// supplied — tests, CLI callers). Lines are 1-indexed.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct NodeLocation {
93    /// File path; prefixed with the matching worktree root if the caller
94    /// supplied one, otherwise the path stored in the graph.
95    pub file_path: PathBuf,
96    /// Language id reported by the graph's file registry (e.g. `"rust"`,
97    /// `"python"`). `"unknown"` if the registry had no mapping.
98    pub language: String,
99    /// Start line (1-indexed, as stored in the graph node arena).
100    pub start_line: u32,
101    /// End line (1-indexed).
102    pub end_line: u32,
103    /// Start column (0-indexed).
104    pub start_column: u32,
105    /// End column (0-indexed).
106    pub end_column: u32,
107}
108
109/// A single change record.
110#[derive(Debug, Clone)]
111pub struct NodeChange {
112    /// Short symbol name (e.g. `foo`).
113    pub symbol_name: String,
114    /// Display-form qualified name, run through
115    /// [`display_graph_qualified_name`] when the language is known.
116    pub qualified_name: String,
117    /// Lowercase node kind string (`"function"`, `"method"`, …). Matches the
118    /// pre-DB20 MCP string taxonomy used by `filters.symbol_kinds`.
119    pub kind: String,
120    /// What kind of change was detected.
121    pub change_type: ChangeType,
122    /// Location in the "old" snapshot (populated for `Removed`, `Modified`,
123    /// `Renamed`, `SignatureChanged`).
124    pub base_location: Option<NodeLocation>,
125    /// Location in the "new" snapshot (populated for `Added`, `Modified`,
126    /// `Renamed`, `SignatureChanged`).
127    pub target_location: Option<NodeLocation>,
128    /// Signature string in the "old" snapshot, if any.
129    pub signature_before: Option<String>,
130    /// Signature string in the "new" snapshot, if any.
131    pub signature_after: Option<String>,
132}
133
134/// Summary counts for a diff.
135#[derive(Debug, Clone, Default, PartialEq, Eq)]
136pub struct DiffSummary {
137    /// Count of [`ChangeType::Added`] records.
138    pub added: u64,
139    /// Count of [`ChangeType::Removed`] records.
140    pub removed: u64,
141    /// Count of [`ChangeType::Modified`] records.
142    pub modified: u64,
143    /// Count of [`ChangeType::Renamed`] records.
144    pub renamed: u64,
145    /// Count of [`ChangeType::SignatureChanged`] records.
146    pub signature_changed: u64,
147    /// Count of [`ChangeType::Unchanged`] records (set by callers that
148    /// post-process the diff to include unchanged nodes).
149    pub unchanged: u64,
150}
151
152impl DiffSummary {
153    /// Recomputes a summary from an existing slice of changes. Useful for
154    /// callers that filter `compute_diff`'s output before rendering.
155    #[must_use]
156    pub fn from_changes(changes: &[NodeChange]) -> Self {
157        let mut summary = Self::default();
158        for change in changes {
159            match change.change_type {
160                ChangeType::Added => summary.added += 1,
161                ChangeType::Removed => summary.removed += 1,
162                ChangeType::Modified => summary.modified += 1,
163                ChangeType::Renamed => summary.renamed += 1,
164                ChangeType::SignatureChanged => summary.signature_changed += 1,
165                ChangeType::Unchanged => summary.unchanged += 1,
166            }
167        }
168        summary
169    }
170}
171
172/// Output of [`compute_diff`] / [`super::ComparativeQueryDb::diff`].
173#[derive(Debug, Clone, Default)]
174pub struct DiffOutput {
175    /// All detected changes, in the order the underlying HashMap iteration
176    /// produces (caller-visible ordering should sort/paginate as needed).
177    pub changes: Vec<NodeChange>,
178    /// Pre-filter summary (matches `changes` bucket counts before any
179    /// caller-side filter is applied).
180    pub summary: DiffSummary,
181}
182
183/// Options controlling the diff computation.
184///
185/// All fields default to empty; callers that need worktree translation
186/// populate `old_worktree_path` / `new_worktree_path` with the absolute root
187/// of each git worktree. See `sqry_mcp::execution::git_worktree::WorktreeManager`
188/// for an example producer.
189#[derive(Debug, Clone, Default)]
190pub struct DiffOptions {
191    /// Absolute path of the worktree backing the "old" snapshot. When
192    /// non-empty, every resolved per-node file path is joined onto this
193    /// root so downstream callers can turn it into a `file://` URI.
194    pub old_worktree_path: PathBuf,
195    /// Absolute path of the worktree backing the "new" snapshot.
196    pub new_worktree_path: PathBuf,
197}
198
199// ============================================================================
200// Implementation
201// ============================================================================
202
203/// Internal snapshot of a single node used during comparison.
204#[derive(Clone)]
205struct NodeSnap {
206    name: String,
207    qualified_name: String,
208    kind: NodeKind,
209    kind_str: String,
210    is_static: bool,
211    signature: Option<String>,
212    file_path: PathBuf,
213    language: String,
214    start_line: u32,
215    end_line: u32,
216    start_column: u32,
217    end_column: u32,
218}
219
220impl NodeSnap {
221    fn display_qualified_name(&self) -> String {
222        Language::from_id(&self.language).map_or_else(
223            || self.qualified_name.clone(),
224            |language| {
225                display_graph_qualified_name(
226                    language,
227                    &self.qualified_name,
228                    self.kind,
229                    self.is_static,
230                )
231            },
232        )
233    }
234
235    fn into_location(self) -> NodeLocation {
236        NodeLocation {
237            file_path: self.file_path,
238            language: self.language,
239            start_line: self.start_line,
240            end_line: self.end_line,
241            start_column: self.start_column,
242            end_column: self.end_column,
243        }
244    }
245
246    fn to_location(&self) -> NodeLocation {
247        NodeLocation {
248            file_path: self.file_path.clone(),
249            language: self.language.clone(),
250            start_line: self.start_line,
251            end_line: self.end_line,
252            start_column: self.start_column,
253            end_column: self.end_column,
254        }
255    }
256}
257
258/// Entry point: computes the semantic diff between `old` and `new`.
259///
260/// The algorithm is the same one the pre-DB20 MCP `GraphComparator` used:
261///
262/// 1. Build two `qualified_name -> NodeSnap` maps, skipping nodes without
263///    qualified names (call sites, imports, etc.).
264/// 2. Walk the "new" map: nodes absent from "old" are added candidates;
265///    nodes present on both sides are checked for signature / body changes
266///    (producing `SignatureChanged` or `Modified`).
267/// 3. Walk the "old" map: nodes absent from "new" are removed candidates.
268/// 4. Run heuristic rename detection between the remaining removed and
269///    added sets (same kind, signature similarity >= 0.7, location scoring,
270///    confidence threshold 0.9). Matched pairs emit `Renamed` records and
271///    are removed from the added/removed pools.
272/// 5. Emit the remaining `Added` and `Removed` records.
273///
274/// Output is pre-filter; callers apply their own `include_unchanged` /
275/// `change_types` / `symbol_kinds` filters.
276#[must_use]
277pub fn compute_diff(old: &GraphSnapshot, new: &GraphSnapshot, opts: &DiffOptions) -> DiffOutput {
278    let base_map = build_node_map(old, &opts.old_worktree_path);
279    let target_map = build_node_map(new, &opts.new_worktree_path);
280
281    let (added_nodes, modified_changes) = collect_added_and_modified(&base_map, &target_map, opts);
282    let removed_nodes = collect_removed_nodes(&base_map, &target_map);
283
284    let mut changes = modified_changes;
285
286    let (rename_changes, renamed_qnames) = collect_renames(&removed_nodes, &added_nodes, opts);
287    changes.extend(rename_changes);
288
289    append_removed_changes(&mut changes, &removed_nodes, &renamed_qnames);
290    append_added_changes(&mut changes, &added_nodes, &renamed_qnames);
291
292    let summary = DiffSummary::from_changes(&changes);
293    DiffOutput { changes, summary }
294}
295
296/// Builds a `qualified_name -> NodeSnap` map from a snapshot, joining each
297/// node's stored file path onto `worktree_path` when the latter is set.
298fn build_node_map(snapshot: &GraphSnapshot, worktree_path: &Path) -> HashMap<String, NodeSnap> {
299    let strings = snapshot.strings();
300    let files = snapshot.files();
301    let mut map = HashMap::new();
302
303    for (_node_id, entry) in snapshot.iter_nodes() {
304        // Gate 0d iter-2 fix: skip unified losers from
305        // `semantic_diff` node map. Losers have no name /
306        // qualified_name / signature post-merge; the explicit guard
307        // makes that contract visible to readers.
308        // See `NodeEntry::is_unified_loser`.
309        if entry.is_unified_loser() {
310            continue;
311        }
312        let name = strings
313            .resolve(entry.name)
314            .map(|s| s.to_string())
315            .unwrap_or_default();
316
317        let qualified_name = entry
318            .qualified_name
319            .and_then(|sid| strings.resolve(sid))
320            .map_or_else(|| name.clone(), |s| s.to_string());
321
322        // Skip nodes without qualified names (call sites, imports, etc.).
323        if qualified_name.is_empty() {
324            continue;
325        }
326
327        let signature = entry
328            .signature
329            .and_then(|sid| strings.resolve(sid))
330            .map(|s| s.to_string());
331
332        let file_path = files
333            .resolve(entry.file)
334            .map(|p| {
335                if worktree_path.as_os_str().is_empty() {
336                    PathBuf::from(p.as_ref())
337                } else {
338                    worktree_path.join(p.as_ref())
339                }
340            })
341            .unwrap_or_default();
342
343        let language = files
344            .language_for_file(entry.file)
345            .map_or_else(|| "unknown".to_string(), |l| l.to_string());
346
347        let snap = NodeSnap {
348            name,
349            qualified_name: qualified_name.clone(),
350            kind: entry.kind,
351            kind_str: node_kind_to_string(entry.kind),
352            is_static: entry.is_static,
353            signature,
354            file_path,
355            language,
356            start_line: entry.start_line,
357            end_line: entry.end_line,
358            start_column: entry.start_column,
359            end_column: entry.end_column,
360        };
361
362        map.insert(qualified_name, snap);
363    }
364
365    map
366}
367
368fn collect_added_and_modified(
369    base_map: &HashMap<String, NodeSnap>,
370    target_map: &HashMap<String, NodeSnap>,
371    opts: &DiffOptions,
372) -> (Vec<NodeSnap>, Vec<NodeChange>) {
373    let mut added = Vec::new();
374    let mut changes = Vec::new();
375
376    for (qname, target_snap) in target_map {
377        match base_map.get(qname) {
378            None => added.push(target_snap.clone()),
379            Some(base_snap) => {
380                if let Some(change) = detect_modification(base_snap, target_snap, opts) {
381                    changes.push(change);
382                }
383            }
384        }
385    }
386
387    (added, changes)
388}
389
390fn collect_removed_nodes(
391    base_map: &HashMap<String, NodeSnap>,
392    target_map: &HashMap<String, NodeSnap>,
393) -> Vec<NodeSnap> {
394    base_map
395        .iter()
396        .filter(|(qname, _)| !target_map.contains_key(*qname))
397        .map(|(_, snap)| snap.clone())
398        .collect()
399}
400
401fn detect_modification(
402    base_snap: &NodeSnap,
403    target_snap: &NodeSnap,
404    opts: &DiffOptions,
405) -> Option<NodeChange> {
406    let signature_changed = base_snap.signature != target_snap.signature;
407
408    // Normalise file paths so "same path in different worktrees" does not
409    // register as a body change.
410    let base_rel = strip_worktree_prefix(&base_snap.file_path, opts);
411    let target_rel = strip_worktree_prefix(&target_snap.file_path, opts);
412
413    let body_changed = base_snap.start_line != target_snap.start_line
414        || base_snap.end_line != target_snap.end_line
415        || base_rel != target_rel;
416
417    if signature_changed {
418        Some(NodeChange {
419            symbol_name: target_snap.name.clone(),
420            qualified_name: target_snap.display_qualified_name(),
421            kind: target_snap.kind_str.clone(),
422            change_type: ChangeType::SignatureChanged,
423            base_location: Some(base_snap.to_location()),
424            target_location: Some(target_snap.to_location()),
425            signature_before: base_snap.signature.clone(),
426            signature_after: target_snap.signature.clone(),
427        })
428    } else if body_changed {
429        Some(NodeChange {
430            symbol_name: target_snap.name.clone(),
431            qualified_name: target_snap.display_qualified_name(),
432            kind: target_snap.kind_str.clone(),
433            change_type: ChangeType::Modified,
434            base_location: Some(base_snap.to_location()),
435            target_location: Some(target_snap.to_location()),
436            signature_before: base_snap.signature.clone(),
437            signature_after: target_snap.signature.clone(),
438        })
439    } else {
440        None
441    }
442}
443
444fn collect_renames(
445    removed: &[NodeSnap],
446    added: &[NodeSnap],
447    opts: &DiffOptions,
448) -> (Vec<NodeChange>, HashSet<String>) {
449    let renames = detect_renames(removed, added, opts);
450    let mut rename_changes = Vec::new();
451    let mut renamed_qnames = HashSet::new();
452
453    for (base_snap, target_snap) in &renames {
454        renamed_qnames.insert(base_snap.qualified_name.clone());
455        renamed_qnames.insert(target_snap.qualified_name.clone());
456        rename_changes.push(create_renamed_change(base_snap, target_snap));
457    }
458
459    (rename_changes, renamed_qnames)
460}
461
462fn detect_renames(
463    removed: &[NodeSnap],
464    added: &[NodeSnap],
465    opts: &DiffOptions,
466) -> Vec<(NodeSnap, NodeSnap)> {
467    let mut renames = Vec::new();
468    let mut matched_added: HashSet<usize> = HashSet::new();
469
470    for removed_snap in removed {
471        let mut best_match: Option<(usize, f64)> = None;
472
473        for (idx, added_snap) in added.iter().enumerate() {
474            if matched_added.contains(&idx) {
475                continue;
476            }
477            let Some(score) = is_likely_rename(removed_snap, added_snap, opts) else {
478                continue;
479            };
480            let is_better = match best_match {
481                Some((_, best_score)) => score > best_score,
482                None => true,
483            };
484            if is_better {
485                best_match = Some((idx, score));
486            }
487        }
488
489        if let Some((idx, score)) = best_match
490            && score >= RENAME_CONFIDENCE_THRESHOLD
491        {
492            matched_added.insert(idx);
493            renames.push((removed_snap.clone(), added[idx].clone()));
494        }
495    }
496
497    renames
498}
499
500fn is_likely_rename(base: &NodeSnap, target: &NodeSnap, opts: &DiffOptions) -> Option<f64> {
501    // Criterion 1: same node kind.
502    if base.kind != target.kind {
503        return None;
504    }
505
506    // Criterion 2: signature similarity (70% weight).
507    let sig_score = match (&base.signature, &target.signature) {
508        (Some(base_sig), Some(target_sig)) => {
509            if base_sig == target_sig {
510                1.0
511            } else {
512                levenshtein_similarity(base_sig, target_sig)
513            }
514        }
515        (None, None) => 1.0,
516        _ => return None,
517    };
518    if sig_score < SIGNATURE_MIN_SCORE {
519        return None;
520    }
521    let mut confidence = sig_score * SIGNATURE_WEIGHT;
522
523    // Criterion 3: location proximity (30% weight).
524    let base_rel = strip_worktree_prefix(&base.file_path, opts);
525    let target_rel = strip_worktree_prefix(&target.file_path, opts);
526    let location_score = if base_rel == target_rel {
527        let base_line: i32 = base.start_line.try_into().unwrap_or(i32::MAX);
528        let target_line: i32 = target.start_line.try_into().unwrap_or(i32::MAX);
529        let line_diff = (base_line - target_line).abs();
530        if line_diff <= SAME_FILE_LINE_WINDOW {
531            1.0 - (f64::from(line_diff) / SAME_FILE_LINE_NORMALIZER).min(SAME_FILE_MAX_PENALTY)
532        } else {
533            SAME_FILE_FAR_SCORE
534        }
535    } else {
536        CROSS_FILE_LOCATION_SCORE
537    };
538    confidence += location_score * LOCATION_WEIGHT;
539
540    Some(confidence)
541}
542
543fn create_renamed_change(base: &NodeSnap, target: &NodeSnap) -> NodeChange {
544    NodeChange {
545        symbol_name: target.name.clone(),
546        qualified_name: target.display_qualified_name(),
547        kind: target.kind_str.clone(),
548        change_type: ChangeType::Renamed,
549        base_location: Some(base.to_location()),
550        target_location: Some(target.to_location()),
551        signature_before: base.signature.clone(),
552        signature_after: target.signature.clone(),
553    }
554}
555
556fn append_removed_changes(
557    changes: &mut Vec<NodeChange>,
558    removed: &[NodeSnap],
559    renamed_qnames: &HashSet<String>,
560) {
561    for snap in removed {
562        if !renamed_qnames.contains(&snap.qualified_name) {
563            changes.push(NodeChange {
564                symbol_name: snap.name.clone(),
565                qualified_name: snap.display_qualified_name(),
566                kind: snap.kind_str.clone(),
567                change_type: ChangeType::Removed,
568                base_location: Some(snap.clone().into_location()),
569                target_location: None,
570                signature_before: snap.signature.clone(),
571                signature_after: None,
572            });
573        }
574    }
575}
576
577fn append_added_changes(
578    changes: &mut Vec<NodeChange>,
579    added: &[NodeSnap],
580    renamed_qnames: &HashSet<String>,
581) {
582    for snap in added {
583        if !renamed_qnames.contains(&snap.qualified_name) {
584            changes.push(NodeChange {
585                symbol_name: snap.name.clone(),
586                qualified_name: snap.display_qualified_name(),
587                kind: snap.kind_str.clone(),
588                change_type: ChangeType::Added,
589                base_location: None,
590                target_location: Some(snap.clone().into_location()),
591                signature_before: None,
592                signature_after: snap.signature.clone(),
593            });
594        }
595    }
596}
597
598/// Strips whichever of the two worktree prefixes from `path` matches.
599fn strip_worktree_prefix(path: &Path, opts: &DiffOptions) -> PathBuf {
600    if !opts.old_worktree_path.as_os_str().is_empty()
601        && let Ok(relative) = path.strip_prefix(&opts.old_worktree_path)
602    {
603        return relative.to_path_buf();
604    }
605    if !opts.new_worktree_path.as_os_str().is_empty()
606        && let Ok(relative) = path.strip_prefix(&opts.new_worktree_path)
607    {
608        return relative.to_path_buf();
609    }
610    path.to_path_buf()
611}
612
613/// Normalised Levenshtein similarity in `[0.0, 1.0]`.
614fn levenshtein_similarity(a: &str, b: &str) -> f64 {
615    let distance = strsim::levenshtein(a, b);
616    let max_len = a.len().max(b.len());
617    if max_len == 0 {
618        return 1.0;
619    }
620    let distance = f64::from(u32::try_from(distance).unwrap_or(u32::MAX));
621    let max_len = f64::from(u32::try_from(max_len).unwrap_or(u32::MAX));
622    1.0 - (distance / max_len)
623}
624
625/// Lowercase node-kind strings matching the pre-DB20 MCP taxonomy. Any kind
626/// not explicitly listed collapses to `"other"` — this matches the legacy
627/// behaviour so `filters.symbol_kinds` keeps its existing acceptance set.
628fn node_kind_to_string(kind: NodeKind) -> String {
629    match kind {
630        NodeKind::Function => "function",
631        NodeKind::Method => "method",
632        NodeKind::Class => "class",
633        NodeKind::Interface => "interface",
634        NodeKind::Trait => "trait",
635        NodeKind::Module => "module",
636        NodeKind::Variable => "variable",
637        NodeKind::Constant => "constant",
638        NodeKind::Type => "type",
639        NodeKind::Struct => "struct",
640        NodeKind::Enum => "enum",
641        NodeKind::EnumVariant => "enum_variant",
642        NodeKind::Macro => "macro",
643        NodeKind::Parameter => "parameter",
644        NodeKind::Property => "property",
645        NodeKind::Import => "import",
646        NodeKind::Export => "export",
647        NodeKind::Component => "component",
648        NodeKind::Service => "service",
649        NodeKind::Resource => "resource",
650        NodeKind::Endpoint => "endpoint",
651        NodeKind::Test => "test",
652        _ => "other",
653    }
654    .to_string()
655}
656
657// ============================================================================
658// Unit tests
659// ============================================================================
660
661#[cfg(test)]
662mod tests {
663    use super::*;
664
665    #[test]
666    fn levenshtein_similarity_bounds() {
667        assert!((levenshtein_similarity("hello", "hello") - 1.0).abs() < 1e-10);
668        assert!((levenshtein_similarity("", "") - 1.0).abs() < 1e-10);
669        assert!(levenshtein_similarity("hello", "hallo") > 0.7);
670        assert!(levenshtein_similarity("hello", "world") < 0.5);
671    }
672
673    #[test]
674    fn change_type_wire_strings_match_pre_db20() {
675        assert_eq!(ChangeType::Added.as_str(), "added");
676        assert_eq!(ChangeType::Removed.as_str(), "removed");
677        assert_eq!(ChangeType::Modified.as_str(), "modified");
678        assert_eq!(ChangeType::Renamed.as_str(), "renamed");
679        assert_eq!(ChangeType::SignatureChanged.as_str(), "signature_changed");
680        assert_eq!(ChangeType::Unchanged.as_str(), "unchanged");
681    }
682
683    #[test]
684    fn diff_summary_from_changes_tallies_each_bucket() {
685        let changes = vec![
686            NodeChange {
687                symbol_name: "a".into(),
688                qualified_name: "a".into(),
689                kind: "function".into(),
690                change_type: ChangeType::Added,
691                base_location: None,
692                target_location: None,
693                signature_before: None,
694                signature_after: None,
695            },
696            NodeChange {
697                symbol_name: "b".into(),
698                qualified_name: "b".into(),
699                kind: "function".into(),
700                change_type: ChangeType::Removed,
701                base_location: None,
702                target_location: None,
703                signature_before: None,
704                signature_after: None,
705            },
706            NodeChange {
707                symbol_name: "c".into(),
708                qualified_name: "c".into(),
709                kind: "function".into(),
710                change_type: ChangeType::SignatureChanged,
711                base_location: None,
712                target_location: None,
713                signature_before: None,
714                signature_after: None,
715            },
716        ];
717        let summary = DiffSummary::from_changes(&changes);
718        assert_eq!(summary.added, 1);
719        assert_eq!(summary.removed, 1);
720        assert_eq!(summary.signature_changed, 1);
721        assert_eq!(summary.modified, 0);
722        assert_eq!(summary.renamed, 0);
723        assert_eq!(summary.unchanged, 0);
724    }
725
726    #[test]
727    fn empty_snapshots_produce_empty_diff() {
728        use std::sync::Arc;
729
730        use sqry_core::graph::unified::concurrent::CodeGraph;
731
732        let old = Arc::new(CodeGraph::new().snapshot());
733        let new = Arc::new(CodeGraph::new().snapshot());
734
735        let cmp = super::super::ComparativeQueryDb::new(old, new);
736        let out = cmp.diff_default();
737        assert!(out.changes.is_empty());
738        assert_eq!(out.summary, DiffSummary::default());
739    }
740
741    #[test]
742    fn strip_worktree_prefix_falls_back_when_empty() {
743        let p = PathBuf::from("/tmp/foo/bar.rs");
744        let out = strip_worktree_prefix(&p, &DiffOptions::default());
745        // Default opts have empty paths → strip is a no-op.
746        assert_eq!(out, p);
747    }
748
749    #[test]
750    fn strip_worktree_prefix_strips_old_root() {
751        let opts = DiffOptions {
752            old_worktree_path: PathBuf::from("/tmp/old"),
753            new_worktree_path: PathBuf::from("/tmp/new"),
754        };
755        let p = PathBuf::from("/tmp/old/src/foo.rs");
756        let out = strip_worktree_prefix(&p, &opts);
757        assert_eq!(out, PathBuf::from("src/foo.rs"));
758    }
759}