Skip to main content

sqry_cli/commands/
similar.rs

1//! Similar command implementation
2//!
3//! Provides CLI interface for finding similar symbols using fuzzy matching.
4
5use crate::args::Cli;
6use crate::commands::graph::loader::{GraphLoadConfig, load_unified_graph_for_cli, no_op_reporter};
7use crate::index_discovery::find_nearest_index;
8use crate::output::OutputStreams;
9use anyhow::{Context, Result, anyhow};
10use serde::Serialize;
11
12/// Similar symbols output
13#[derive(Debug, Serialize)]
14struct SimilarOutput {
15    /// Reference symbol
16    reference: NodeRef,
17    /// Similar symbols found
18    similar: Vec<SimilarSymbol>,
19    /// Statistics
20    stats: SimilarStats,
21}
22
23#[derive(Debug, Serialize)]
24struct NodeRef {
25    name: String,
26    qualified_name: String,
27    kind: String,
28    file: String,
29    line: u32,
30}
31
32#[derive(Debug, Serialize)]
33struct SimilarSymbol {
34    name: String,
35    qualified_name: String,
36    kind: String,
37    file: String,
38    line: u32,
39    /// Similarity score (0.0 - 1.0)
40    similarity: f64,
41}
42
43#[derive(Debug, Serialize)]
44struct SimilarStats {
45    total_found: usize,
46    threshold: f64,
47}
48
49/// Run the similar command.
50///
51/// # Errors
52/// Returns an error if the graph cannot be loaded or symbol cannot be found.
53// The CLI flow is linear and readability outweighs splitting into helpers.
54#[allow(clippy::too_many_lines)]
55pub fn run_similar(
56    cli: &Cli,
57    file_path: &str,
58    symbol_name: &str,
59    path: Option<&str>,
60    threshold: f64,
61    max_results: usize,
62) -> Result<()> {
63    let mut streams = OutputStreams::new();
64
65    // Find index
66    let search_path = path.map_or_else(
67        || std::env::current_dir().unwrap_or_default(),
68        std::path::PathBuf::from,
69    );
70
71    let index_location = find_nearest_index(&search_path);
72    let Some(ref loc) = index_location else {
73        streams
74            .write_diagnostic("No .sqry-index found. Run 'sqry index' first to build the index.")?;
75        return Ok(());
76    };
77
78    // Load unified graph
79    let config = GraphLoadConfig::default();
80    let graph = load_unified_graph_for_cli(&loc.index_root, &config, cli, no_op_reporter())
81        .context("Failed to load graph. Run 'sqry index' to build the graph.")?;
82
83    let strings = graph.strings();
84    let files_registry = graph.files();
85    let target_file = std::path::Path::new(file_path);
86
87    // Find the reference symbol in the unified graph
88    let (ref_node_id, ref_entry) = graph
89        .nodes()
90        .iter()
91        .find(|(_, entry)| {
92            // Check if file matches
93            let sym_file = files_registry.resolve(entry.file);
94            let file_matches = sym_file
95                .as_ref()
96                .is_some_and(|p| p.as_ref() == target_file || p.ends_with(file_path));
97
98            if !file_matches {
99                return false;
100            }
101
102            // Check if name matches
103            let name = strings.resolve(entry.name);
104            let qname = entry.qualified_name.and_then(|id| strings.resolve(id));
105
106            name.is_some_and(|n| n.as_ref() == symbol_name)
107                || qname.is_some_and(|q| q.as_ref() == symbol_name)
108        })
109        .ok_or_else(|| anyhow!("Symbol '{symbol_name}' not found in '{file_path}'"))?;
110
111    let ref_name = strings
112        .resolve(ref_entry.name)
113        .map(|s| s.to_string())
114        .unwrap_or_default();
115
116    let ref_qualified_name = ref_entry
117        .qualified_name
118        .and_then(|id| strings.resolve(id))
119        .map_or_else(|| ref_name.clone(), |s| s.to_string());
120
121    let ref_file_path = files_registry
122        .resolve(ref_entry.file)
123        .map(|p| p.display().to_string())
124        .unwrap_or_default();
125
126    let reference = NodeRef {
127        name: ref_name.clone(),
128        qualified_name: ref_qualified_name.clone(),
129        kind: format!("{:?}", ref_entry.kind),
130        file: ref_file_path,
131        line: ref_entry.start_line,
132    };
133
134    // Find similar symbols (same kind, similar name)
135    let mut similar_symbols: Vec<_> = graph
136        .nodes()
137        .iter()
138        .filter(|(node_id, entry)| {
139            // Skip the reference symbol itself
140            if *node_id == ref_node_id {
141                return false;
142            }
143            // Must be same kind
144            if entry.kind != ref_entry.kind {
145                return false;
146            }
147            true
148        })
149        .filter_map(|(_, entry)| {
150            let name = strings.resolve(entry.name)?;
151            let similarity = compute_similarity(&ref_name, &name);
152
153            if similarity >= threshold {
154                let file_path = files_registry
155                    .resolve(entry.file)
156                    .map(|p| p.display().to_string())
157                    .unwrap_or_default();
158
159                let qualified_name = entry
160                    .qualified_name
161                    .and_then(|id| strings.resolve(id))
162                    .map_or_else(|| name.to_string(), |s| s.to_string());
163
164                Some(SimilarSymbol {
165                    name: name.to_string(),
166                    qualified_name,
167                    kind: format!("{:?}", entry.kind),
168                    file: file_path,
169                    line: entry.start_line,
170                    similarity,
171                })
172            } else {
173                None
174            }
175        })
176        .collect();
177
178    // Sort by similarity (descending)
179    similar_symbols.sort_by(|a, b| {
180        b.similarity
181            .partial_cmp(&a.similarity)
182            .unwrap_or(std::cmp::Ordering::Equal)
183    });
184    similar_symbols.truncate(max_results);
185
186    let stats = SimilarStats {
187        total_found: similar_symbols.len(),
188        threshold,
189    };
190
191    let output = SimilarOutput {
192        reference,
193        similar: similar_symbols,
194        stats,
195    };
196
197    // Output
198    if cli.json {
199        let json = serde_json::to_string_pretty(&output).context("Failed to serialize to JSON")?;
200        streams.write_result(&json)?;
201    } else {
202        let text = format_similar_text(&output);
203        streams.write_result(&text)?;
204    }
205
206    Ok(())
207}
208
209/// Compute similarity between two strings using Levenshtein distance.
210fn compute_similarity(a: &str, b: &str) -> f64 {
211    let a_lower = a.to_lowercase();
212    let b_lower = b.to_lowercase();
213
214    if a_lower == b_lower {
215        return 1.0;
216    }
217
218    let distance = levenshtein_distance(&a_lower, &b_lower);
219    let max_len = a_lower.len().max(b_lower.len());
220
221    if max_len == 0 {
222        return 1.0;
223    }
224
225    let distance_f = f64::from(u32::try_from(distance).unwrap_or(u32::MAX));
226    let max_len_f = f64::from(u32::try_from(max_len).unwrap_or(u32::MAX));
227    1.0 - (distance_f / max_len_f)
228}
229
230/// Calculate Levenshtein distance between two strings.
231fn levenshtein_distance(a: &str, b: &str) -> usize {
232    let a_chars: Vec<char> = a.chars().collect();
233    let b_chars: Vec<char> = b.chars().collect();
234    let a_len = a_chars.len();
235    let b_len = b_chars.len();
236
237    if a_len == 0 {
238        return b_len;
239    }
240    if b_len == 0 {
241        return a_len;
242    }
243
244    let mut matrix = vec![vec![0usize; b_len + 1]; a_len + 1];
245
246    for (i, row) in matrix.iter_mut().enumerate().take(a_len + 1) {
247        row[0] = i;
248    }
249    for (j, val) in matrix[0].iter_mut().enumerate().take(b_len + 1) {
250        *val = j;
251    }
252
253    for i in 1..=a_len {
254        for j in 1..=b_len {
255            let cost = usize::from(a_chars[i - 1] != b_chars[j - 1]);
256            matrix[i][j] = (matrix[i - 1][j] + 1)
257                .min(matrix[i][j - 1] + 1)
258                .min(matrix[i - 1][j - 1] + cost);
259        }
260    }
261
262    matrix[a_len][b_len]
263}
264
265fn format_similar_text(output: &SimilarOutput) -> String {
266    let mut lines = Vec::new();
267
268    lines.push(format!(
269        "Finding symbols similar to: {} [{}]",
270        output.reference.qualified_name, output.reference.kind
271    ));
272    lines.push(format!(
273        "Threshold: {:.0}%, Found: {}",
274        output.stats.threshold * 100.0,
275        output.stats.total_found
276    ));
277    lines.push(String::new());
278
279    if output.similar.is_empty() {
280        lines.push("No similar symbols found.".to_string());
281    } else {
282        lines.push("Similar symbols:".to_string());
283        for sym in &output.similar {
284            lines.push(format!(
285                "  {} ({:.0}% similar)",
286                sym.qualified_name,
287                sym.similarity * 100.0
288            ));
289            lines.push(format!("    {}:{}", sym.file, sym.line));
290        }
291    }
292
293    lines.join("\n")
294}