Skip to main content

sqry_cli/commands/
search.rs

1//! Symbol search command implementation
2
3use crate::args::Cli;
4use crate::commands::graph::loader::{GraphLoadConfig, load_unified_graph_for_cli};
5use crate::index_discovery::find_nearest_index;
6use crate::output::{
7    DisplaySymbol, FormatterMetadata, JsonSymbol, OutputStreams, create_formatter,
8};
9use anyhow::{Context, Result};
10use regex::RegexBuilder;
11use sqry_core::graph::unified::concurrent::CodeGraph;
12use sqry_core::graph::unified::node::NodeKind;
13use sqry_core::json_response::{Filters, FuzzyFilters, Stats, StreamEvent};
14use sqry_core::search::fuzzy::{CandidateGenerator, FuzzyConfig};
15use sqry_core::search::matcher::{FuzzyMatcher, MatchAlgorithm, MatchConfig};
16use sqry_core::search::trigram::TrigramIndex;
17use std::collections::HashMap;
18use std::path::{Path, PathBuf};
19use std::sync::Arc;
20use std::time::Instant;
21
22/// A symbol paired with its fuzzy match score.
23type ScoredSymbol = (DisplaySymbol, f64);
24
25/// Apply kind and language filters to symbols.
26fn apply_search_filters(cli: &Cli, symbols: &mut Vec<DisplaySymbol>) {
27    // Filter by symbol type if specified
28    if let Some(kind) = cli.kind {
29        let target_type_str = kind.to_string().to_lowercase();
30        symbols.retain(|s| s.kind.to_lowercase() == target_type_str);
31    }
32
33    // Filter by language if specified
34    if let Some(ref lang) = cli.lang {
35        symbols.retain(|s| {
36            s.file_path
37                .extension()
38                .and_then(|ext| ext.to_str())
39                .is_some_and(|ext| matches_language(ext, lang))
40        });
41    }
42}
43
44/// Build search metadata for output formatting.
45fn build_search_metadata(
46    cli: &Cli,
47    pattern: &str,
48    scope_info: Option<&FuzzySearchScopeInfo>,
49    index_age_seconds: Option<u64>,
50    total_matches: usize,
51    execution_time: std::time::Duration,
52) -> FormatterMetadata {
53    let (used_ancestor_index, filtered_to) = if let Some(scope) = scope_info {
54        // Include scope info when any filtering was applied
55        let used_ancestor = if scope.used_ancestor_index || scope.filtered_to.is_some() {
56            Some(scope.used_ancestor_index)
57        } else {
58            None
59        };
60        (used_ancestor, scope.filtered_to.clone())
61    } else {
62        (None, None)
63    };
64
65    FormatterMetadata {
66        pattern: Some(pattern.to_string()),
67        total_matches,
68        execution_time,
69        filters: build_filters(cli),
70        index_age_seconds,
71        used_ancestor_index,
72        filtered_to,
73    }
74}
75
76/// Run symbol search command.
77/// P2-3 Step 2e: Language filtering uses `file_path()` without index context - allowed
78///
79/// # Errors
80/// Returns an error if search execution fails or output cannot be written.
81pub fn run_search(cli: &Cli, pattern: &str, search_path: &str) -> Result<()> {
82    // Handle JSON streaming mode separately (fuzzy only, enforced by clap)
83    if cli.json_stream {
84        return run_json_stream_search(cli, pattern, search_path);
85    }
86
87    let start_time = Instant::now();
88
89    // Branch based on search mode, capturing index age and scope info if available
90    let (mut all_symbols, index_age_seconds, scope_info) = if cli.fuzzy {
91        let (scored_symbols, age, scope) = run_fuzzy_search(cli, pattern, search_path)?;
92        let symbols = scored_symbols.into_iter().map(|(s, _)| s).collect();
93        (symbols, Some(age), Some(scope))
94    } else {
95        (run_regular_search(cli, pattern, search_path)?, None, None)
96    };
97
98    apply_search_filters(cli, &mut all_symbols);
99
100    // Handle count-only mode
101    if cli.count {
102        println!("{} matches found", all_symbols.len());
103        return Ok(());
104    }
105
106    // Apply limit if specified
107    let total_matches = all_symbols.len();
108
109    // Optional sorting (opt-in)
110    if let Some(sort_field) = cli.sort {
111        crate::commands::sort::sort_symbols(&mut all_symbols, sort_field);
112    }
113
114    let limit = cli.limit.unwrap_or(if cli.fuzzy { 50 } else { 100 });
115    let symbols_to_output = if all_symbols.len() > limit {
116        all_symbols.truncate(limit);
117        all_symbols
118    } else {
119        all_symbols
120    };
121
122    let execution_time = start_time.elapsed();
123
124    let metadata = build_search_metadata(
125        cli,
126        pattern,
127        scope_info.as_ref(),
128        index_age_seconds,
129        total_matches,
130        execution_time,
131    );
132
133    let formatter = create_formatter(cli);
134
135    // Output results using streams with optional pager support
136    let mut streams = OutputStreams::with_pager(cli.pager_config());
137    formatter.format(&symbols_to_output, Some(&metadata), &mut streams)?;
138
139    // If truncated and not JSON, inform user
140    if !cli.json && total_matches > limit {
141        eprintln!("\nShowing {limit} of {total_matches} matches (use --limit to adjust)");
142    }
143
144    // Finalize pager (flushes buffer, waits for pager if spawned)
145    // This propagates non-zero pager exit codes to the CLI exit code
146    streams.finish_checked()
147}
148
149/// Build filters metadata from CLI flags
150fn build_filters(cli: &Cli) -> Filters {
151    Filters {
152        kind: cli.kind.map(|k| k.to_string()),
153        lang: cli.lang.clone(),
154        ignore_case: cli.ignore_case,
155        exact: cli.exact,
156        fuzzy: if cli.fuzzy {
157            Some(FuzzyFilters {
158                algorithm: cli.fuzzy_algorithm.clone(),
159                threshold: cli.fuzzy_threshold,
160                max_candidates: Some(cli.fuzzy_max_candidates),
161            })
162        } else {
163            None
164        },
165    }
166}
167
168fn language_from_path(path: &Path) -> &'static str {
169    path.extension()
170        .and_then(|ext| ext.to_str())
171        .map_or("unknown", |ext| match ext.to_lowercase().as_str() {
172            "rs" => "rust",
173            "js" | "mjs" | "cjs" => "javascript",
174            "ts" | "mts" | "cts" => "typescript",
175            "jsx" => "javascriptreact",
176            "tsx" => "typescriptreact",
177            "py" | "pyw" => "python",
178            "rb" => "ruby",
179            "go" => "go",
180            "java" => "java",
181            "kt" | "kts" => "kotlin",
182            "scala" | "sc" => "scala",
183            "c" | "h" => "c",
184            "cpp" | "cc" | "cxx" | "hpp" | "hxx" => "cpp",
185            "cs" => "csharp",
186            "php" => "php",
187            "swift" => "swift",
188            "sql" => "sql",
189            "dart" => "dart",
190            "lua" => "lua",
191            "sh" | "bash" | "zsh" => "shell",
192            "pl" | "pm" => "perl",
193            "groovy" | "gvy" => "groovy",
194            "ex" | "exs" => "elixir",
195            "r" | "R" => "r",
196            "hs" | "lhs" => "haskell",
197            "svelte" => "svelte",
198            "vue" => "vue",
199            "zig" => "zig",
200            "css" | "scss" | "sass" | "less" => "css",
201            "html" | "htm" => "html",
202            "tf" | "tfvars" => "terraform",
203            "pp" => "puppet",
204            "pls" | "plb" | "pck" => "plsql",
205            "cls" | "trigger" => "apex",
206            "abap" => "abap",
207            _ => "unknown",
208        })
209}
210
211/// Check if file extension matches language
212fn matches_language(ext: &str, lang: &str) -> bool {
213    let ext_lower = ext.to_lowercase();
214    let lang_lower = lang.to_lowercase();
215
216    match lang_lower.as_str() {
217        // Tier 0 languages (original core set)
218        "rust" | "rs" => ext_lower == "rs",
219        "javascript" | "js" => matches!(ext_lower.as_str(), "js" | "jsx" | "mjs" | "cjs"),
220        "typescript" | "ts" => matches!(ext_lower.as_str(), "ts" | "tsx"),
221        "python" | "py" => matches!(ext_lower.as_str(), "py" | "pyi" | "pyw"),
222        "go" => ext_lower == "go",
223        "java" => ext_lower == "java",
224
225        // Tier 1 languages
226        "swift" => ext_lower == "swift",
227        "c" => matches!(ext_lower.as_str(), "c" | "h"),
228        "cpp" | "c++" | "cxx" => {
229            matches!(
230                ext_lower.as_str(),
231                "cpp" | "cc" | "cxx" | "hpp" | "hh" | "hxx" | "h"
232            )
233        }
234        "csharp" | "c#" | "cs" => matches!(ext_lower.as_str(), "cs" | "csx"),
235        "dart" => ext_lower == "dart",
236        "kotlin" | "kt" => matches!(ext_lower.as_str(), "kt" | "kts"),
237        "ruby" | "rb" => matches!(ext_lower.as_str(), "rb" | "rake" | "gemspec"),
238        "scala" => matches!(ext_lower.as_str(), "scala" | "sc"),
239        "php" => ext_lower == "php",
240
241        // Tier 2 languages
242        "lua" => ext_lower == "lua",
243        "elixir" | "ex" => matches!(ext_lower.as_str(), "ex" | "exs"),
244        "haskell" | "hs" => matches!(ext_lower.as_str(), "hs" | "lhs"),
245        "perl" | "pl" => matches!(ext_lower.as_str(), "pl" | "pm"),
246        "r" => ext_lower == "r",
247        "shell" | "sh" | "bash" => matches!(ext_lower.as_str(), "sh" | "bash" | "zsh"),
248        "zig" => ext_lower == "zig",
249        "groovy" => matches!(ext_lower.as_str(), "groovy" | "gvy" | "gy" | "gsh"),
250
251        // Frontend / markup
252        "vue" => ext_lower == "vue",
253        "svelte" => ext_lower == "svelte",
254        "html" => matches!(ext_lower.as_str(), "html" | "htm"),
255        "css" => matches!(ext_lower.as_str(), "css" | "scss" | "sass" | "less"),
256
257        // IaC languages
258        "terraform" | "tf" | "hcl" => {
259            matches!(ext_lower.as_str(), "tf" | "tfvars" | "hcl")
260        }
261        "puppet" | "pp" => ext_lower == "pp",
262
263        // Data / platform-specific languages
264        "sql" => ext_lower == "sql",
265        "servicenow" | "servicenow-xanadu" | "servicenow-xanadu-js" | "snjs" => ext_lower == "snjs",
266        "apex" | "salesforce" => matches!(ext_lower.as_str(), "cls" | "trigger"),
267        "abap" => ext_lower == "abap",
268        "plsql" | "oracle-plsql" => matches!(ext_lower.as_str(), "pks" | "pkb" | "pls"),
269
270        // Default: try exact match
271        _ => ext_lower == lang_lower,
272    }
273}
274
275/// Run regular (non-fuzzy) symbol search
276fn run_regular_search(cli: &Cli, pattern: &str, search_path: &str) -> Result<Vec<DisplaySymbol>> {
277    // Load unified graph
278    let search_path_path = Path::new(search_path);
279    let index_location = find_nearest_index(search_path_path);
280    let index_root = index_location
281        .as_ref()
282        .map_or(search_path_path, |loc| loc.index_root.as_path());
283
284    let config = GraphLoadConfig::default();
285    let graph = load_unified_graph_for_cli(index_root, &config, cli)
286        .context("Failed to load graph. Run 'sqry index' to build the graph.")?;
287
288    // Build regex for pattern matching if regex mode
289    let pattern_regex = build_pattern_regex(cli, pattern)?;
290
291    // Find matching nodes
292    let mut matches = Vec::new();
293    let strings = graph.strings();
294    let indices = graph.indices();
295
296    if let Some(regex) = pattern_regex {
297        // Regex search: scan all interned strings
298        for (str_id, s) in strings.iter() {
299            if regex.is_match(s) {
300                // If matches, get all nodes with this name
301                matches.extend_from_slice(indices.by_qualified_name(str_id));
302                matches.extend_from_slice(indices.by_name(str_id));
303            }
304        }
305    } else {
306        // Substring search using optimized graph method
307        let node_ids = graph.snapshot().find_by_pattern(pattern);
308        matches.extend(node_ids);
309    }
310
311    // Deduplicate node IDs
312    matches.sort_unstable();
313    matches.dedup();
314
315    // Convert to DisplaySymbols
316    let mut all_symbols = Vec::with_capacity(matches.len());
317
318    for node_id in matches {
319        if let Some(symbol) = convert_node_to_display_symbol(&graph, node_id) {
320            all_symbols.push(symbol);
321        }
322    }
323
324    Ok(all_symbols)
325}
326
327fn build_pattern_regex(cli: &Cli, pattern: &str) -> Result<Option<regex::Regex>> {
328    if cli.exact {
329        return Ok(None);
330    }
331
332    let regex = RegexBuilder::new(pattern)
333        .case_insensitive(cli.ignore_case)
334        .build()
335        .context("Invalid regex pattern")?;
336    Ok(Some(regex))
337}
338
339// Helper to convert CodeGraph node to DisplaySymbol
340fn convert_node_to_display_symbol(
341    graph: &CodeGraph,
342    node_id: sqry_core::graph::unified::node::NodeId,
343) -> Option<DisplaySymbol> {
344    let entry = graph.nodes().get(node_id)?;
345    let strings = graph.strings();
346    let files = graph.files();
347
348    let name = strings
349        .resolve(entry.name)
350        .map(|s| s.to_string())
351        .unwrap_or_default();
352
353    let file_path = files
354        .resolve(entry.file)
355        .map(|s| PathBuf::from(s.as_ref()))
356        .unwrap_or_default();
357
358    let language = language_from_path(&file_path).to_string();
359
360    let mut metadata = HashMap::new();
361    metadata.insert(
362        "__raw_file_path".to_string(),
363        file_path.to_string_lossy().to_string(),
364    );
365    metadata.insert("__raw_language".to_string(), language.clone());
366
367    let qualified_name = entry
368        .qualified_name
369        .and_then(|id| strings.resolve(id))
370        .map_or_else(|| name.clone(), |s| s.to_string());
371
372    Some(DisplaySymbol {
373        name,
374        qualified_name,
375        kind: node_kind_to_string(entry.kind).to_string(),
376        file_path,
377        start_line: entry.start_line as usize,
378        start_column: entry.start_column as usize,
379        end_line: entry.end_line as usize,
380        end_column: entry.end_column as usize,
381        metadata,
382        caller_identity: None,
383        callee_identity: None,
384    })
385}
386
387/// Convert `NodeKind` to lowercase string for display.
388fn node_kind_to_string(kind: NodeKind) -> &'static str {
389    match kind {
390        NodeKind::Function => "function",
391        NodeKind::Method => "method",
392        NodeKind::Class => "class",
393        NodeKind::Interface => "interface",
394        NodeKind::Trait => "trait",
395        NodeKind::Module => "module",
396        NodeKind::Variable => "variable",
397        NodeKind::Constant => "constant",
398        NodeKind::Type => "type",
399        NodeKind::Struct => "struct",
400        NodeKind::Enum => "enum",
401        NodeKind::EnumVariant => "enum_variant",
402        NodeKind::Macro => "macro",
403        NodeKind::Parameter => "parameter",
404        NodeKind::Property => "property",
405        NodeKind::Import => "import",
406        NodeKind::Export => "export",
407        NodeKind::Component => "component",
408        NodeKind::Service => "service",
409        NodeKind::Resource => "resource",
410        NodeKind::Endpoint => "endpoint",
411        NodeKind::Test => "test",
412        NodeKind::CallSite => "call_site",
413        NodeKind::StyleRule => "style_rule",
414        NodeKind::StyleAtRule => "style_at_rule",
415        NodeKind::StyleVariable => "style_variable",
416        NodeKind::Lifetime => "lifetime",
417        NodeKind::TypeParameter => "type_parameter",
418        NodeKind::Annotation => "annotation",
419        NodeKind::AnnotationValue => "annotation_value",
420        NodeKind::LambdaTarget => "lambda_target",
421        NodeKind::JavaModule => "java_module",
422        NodeKind::EnumConstant => "enum_constant",
423        NodeKind::Other => "other",
424    }
425}
426
427/// Scope info returned from fuzzy search for JSON output
428struct FuzzySearchScopeInfo {
429    used_ancestor_index: bool,
430    filtered_to: Option<String>,
431}
432
433/// Resolved index location for fuzzy search.
434struct FuzzyIndexResolution {
435    index_root: PathBuf,
436    scope_filter: Option<PathBuf>,
437    is_file_query: bool,
438    scope_info: FuzzySearchScopeInfo,
439}
440
441/// Resolve index location and scope filter for fuzzy search.
442fn resolve_fuzzy_index(search_path: &Path) -> FuzzyIndexResolution {
443    let index_location = find_nearest_index(search_path);
444
445    if let Some(ref loc) = index_location {
446        let scope = if loc.requires_scope_filter {
447            loc.relative_scope()
448        } else {
449            None
450        };
451        let info = FuzzySearchScopeInfo {
452            used_ancestor_index: loc.is_ancestor,
453            filtered_to: scope.as_ref().map(|p| {
454                if loc.is_file_query {
455                    p.to_string_lossy().into_owned()
456                } else {
457                    format!("{}/**", p.display())
458                }
459            }),
460        };
461        FuzzyIndexResolution {
462            index_root: loc.index_root.clone(),
463            scope_filter: scope,
464            is_file_query: loc.is_file_query,
465            scope_info: info,
466        }
467    } else {
468        FuzzyIndexResolution {
469            index_root: search_path.to_path_buf(),
470            scope_filter: None,
471            is_file_query: false,
472            scope_info: FuzzySearchScopeInfo {
473                used_ancestor_index: false,
474                filtered_to: None,
475            },
476        }
477    }
478}
479
480/// Build a `TrigramIndex` from all interned strings in the graph.
481fn build_trigram_index_from_graph(graph: &CodeGraph) -> Arc<TrigramIndex> {
482    let mut trigram_index = TrigramIndex::new();
483    for (str_id, s) in graph.strings().iter() {
484        trigram_index.add_symbol(str_id.index() as usize, s);
485    }
486    Arc::new(trigram_index)
487}
488
489/// Run fuzzy symbol search using index.
490/// Returns (scored symbols, `index_age_seconds`, `scope_info`).
491fn run_fuzzy_search(
492    cli: &Cli,
493    pattern: &str,
494    search_path: &str,
495) -> Result<(Vec<ScoredSymbol>, u64, FuzzySearchScopeInfo)> {
496    let search_path_path = Path::new(search_path);
497
498    // Index ancestor discovery
499    let resolution = resolve_fuzzy_index(search_path_path);
500    let FuzzyIndexResolution {
501        index_root,
502        scope_filter,
503        is_file_query,
504        scope_info,
505    } = resolution;
506
507    let config = GraphLoadConfig::default();
508    let graph = load_unified_graph_for_cli(&index_root, &config, cli)
509        .context("Failed to load graph. Run 'sqry index' to build the graph.")?;
510
511    // Age of graph (approximate, since we don't have file metadata here easily, return 0 for now)
512    let age_seconds = 0;
513
514    // Build TrigramIndex from graph strings on the fly
515    let trigram_index_arc = build_trigram_index_from_graph(&graph);
516
517    let algorithm = parse_fuzzy_algorithm(&cli.fuzzy_algorithm)?;
518    let fuzzy_config = build_fuzzy_config(cli, 0.1);
519    let match_config = build_match_config(cli, algorithm);
520
521    // Create candidate generator
522    let generator = CandidateGenerator::with_config(trigram_index_arc, fuzzy_config);
523
524    maybe_log_fuzzy_config(cli, algorithm);
525
526    // Generate candidates (StringIds as usize)
527    let candidate_ids = generator.generate(pattern);
528
529    if candidate_ids.is_empty() {
530        return Ok((Vec::new(), age_seconds, scope_info));
531    }
532
533    // Match and score
534    let matcher = FuzzyMatcher::with_config(match_config.clone());
535
536    // Pre-resolve strings to manage lifetimes
537    let resolved_candidates: Vec<(usize, Arc<str>)> = candidate_ids
538        .iter()
539        .filter_map(|&id| {
540            let str_id = u32::try_from(id).ok()?;
541            let str_id = sqry_core::graph::unified::string::StringId::new(str_id);
542            graph.strings().resolve(str_id).map(|s| (id, s))
543        })
544        .collect();
545
546    let candidate_targets = resolved_candidates.iter().map(|(id, s)| (*id, s.as_ref()));
547
548    // Score candidates
549    let match_results = matcher.match_many(pattern, candidate_targets);
550
551    // Convert to DisplaySymbols
552    let mut symbols = Vec::new();
553    let indices = graph.indices();
554
555    for result in match_results {
556        let Ok(str_id) = u32::try_from(result.entry_id) else {
557            continue;
558        };
559        let str_id = sqry_core::graph::unified::string::StringId::new(str_id);
560
561        // Find nodes with this name
562        // We check both qualified and simple names because TrigramIndex was built from all strings.
563        // A string might be a qualified name or a simple name.
564        // If it's a qualified name, `by_qualified_name` will find it.
565        // If it's a simple name, `by_name` will find it.
566
567        let mut node_ids = Vec::new();
568        node_ids.extend_from_slice(indices.by_qualified_name(str_id));
569        node_ids.extend_from_slice(indices.by_name(str_id));
570        node_ids.sort_unstable();
571        node_ids.dedup();
572
573        for node_id in node_ids {
574            if let Some(symbol) = convert_node_to_display_symbol(&graph, node_id) {
575                // We need to keep the score to sort.
576                // We return (DisplaySymbol, score) internally then sort.
577                symbols.push((symbol, result.score));
578            }
579        }
580    }
581
582    // Sort by score descending
583    symbols.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
584
585    maybe_log_fuzzy_results(symbols.len());
586
587    let mut final_symbols = symbols;
588
589    // Post-filter results to query scope if using ancestor index
590    if let Some(ref scope) = scope_filter {
591        filter_fuzzy_results_by_scope(&mut final_symbols, scope, is_file_query);
592    }
593
594    Ok((final_symbols, age_seconds, scope_info))
595}
596
597/// Filter fuzzy search results to only include symbols within the given scope.
598fn filter_fuzzy_results_by_scope(
599    symbols: &mut Vec<ScoredSymbol>,
600    scope: &Path,
601    is_file_query: bool,
602) {
603    symbols.retain(|(symbol, _)| {
604        if is_file_query {
605            symbol.file_path == scope
606        } else {
607            symbol.file_path.starts_with(scope)
608        }
609    });
610}
611
612fn run_json_stream_search(cli: &Cli, pattern: &str, search_path: &str) -> Result<()> {
613    let (mut symbols, age_seconds, scope_info) = run_fuzzy_search(cli, pattern, search_path)?;
614
615    // Apply kind/language filters (same semantics as the non-streaming path).
616    apply_scored_search_filters(cli, &mut symbols);
617
618    let limit = cli.limit.unwrap_or(50);
619    let mut count = 0;
620
621    for (symbol, score) in symbols.iter().take(limit) {
622        let json_symbol = JsonSymbol::from(symbol);
623        let event = StreamEvent::PartialResult {
624            result: json_symbol,
625            score: *score,
626        };
627        let json = serde_json::to_string(&event)?;
628        println!("{json}");
629        count += 1;
630    }
631
632    emit_stream_summary(symbols.len(), count, age_seconds, Some(&scope_info))?;
633
634    Ok(())
635}
636
637/// Apply kind and language filters to scored symbols.
638fn apply_scored_search_filters(cli: &Cli, symbols: &mut Vec<ScoredSymbol>) {
639    if let Some(kind) = cli.kind {
640        let target_type_str = kind.to_string().to_lowercase();
641        symbols.retain(|(s, _)| s.kind.to_lowercase() == target_type_str);
642    }
643
644    if let Some(ref lang) = cli.lang {
645        symbols.retain(|(s, _)| {
646            s.file_path
647                .extension()
648                .and_then(|ext| ext.to_str())
649                .is_some_and(|ext| matches_language(ext, lang))
650        });
651    }
652}
653
654fn parse_fuzzy_algorithm(algorithm: &str) -> Result<MatchAlgorithm> {
655    match algorithm.to_lowercase().as_str() {
656        "levenshtein" => Ok(MatchAlgorithm::Levenshtein),
657        "jaro-winkler" | "jaro_winkler" => Ok(MatchAlgorithm::JaroWinkler),
658        _ => anyhow::bail!(
659            "Unknown fuzzy algorithm '{algorithm}'. Use 'levenshtein' or 'jaro-winkler'."
660        ),
661    }
662}
663
664fn build_fuzzy_config(cli: &Cli, min_similarity: f64) -> FuzzyConfig {
665    FuzzyConfig {
666        max_candidates: cli.fuzzy_max_candidates,
667        min_similarity,
668    }
669}
670
671fn build_match_config(cli: &Cli, algorithm: MatchAlgorithm) -> MatchConfig {
672    MatchConfig {
673        algorithm,
674        min_score: cli.fuzzy_threshold,
675        case_sensitive: !cli.ignore_case,
676    }
677}
678
679fn maybe_log_fuzzy_config(cli: &Cli, algorithm: MatchAlgorithm) {
680    if std::env::var("RUST_LOG").is_ok() {
681        eprintln!("[DEBUG] Using fuzzy algorithm: {algorithm:?}");
682        eprintln!("[DEBUG] Min score threshold: {}", cli.fuzzy_threshold);
683    }
684}
685
686fn maybe_log_fuzzy_results(count: usize) {
687    if std::env::var("RUST_LOG").is_ok() {
688        eprintln!("[DEBUG] Found {count} fuzzy matches");
689    }
690}
691
692fn emit_stream_summary(
693    final_count: usize,
694    total_streamed: usize,
695    age_seconds: u64,
696    scope_info: Option<&FuzzySearchScopeInfo>,
697) -> Result<()> {
698    let mut stats = Stats::new(final_count, total_streamed).with_index_age(age_seconds);
699    // Add scope info if filtering was applied
700    if let Some(scope) = scope_info
701        && (scope.used_ancestor_index || scope.filtered_to.is_some())
702    {
703        stats = stats.with_scope_info(scope.used_ancestor_index, scope.filtered_to.clone());
704    }
705    let summary = StreamEvent::<JsonSymbol>::FinalSummary { stats };
706    let json = serde_json::to_string(&summary)?;
707    println!("{json}");
708    Ok(())
709}
710
711#[cfg(test)]
712mod tests {
713    use super::*;
714
715    #[test]
716    fn test_matches_language_rust() {
717        assert!(matches_language("rs", "rust"));
718        assert!(matches_language("rs", "Rust"));
719        assert!(matches_language("rs", "rs"));
720        assert!(!matches_language("js", "rust"));
721    }
722
723    #[test]
724    fn test_matches_language_javascript() {
725        assert!(matches_language("js", "javascript"));
726        assert!(matches_language("jsx", "javascript"));
727        assert!(matches_language("js", "js"));
728        assert!(!matches_language("ts", "javascript"));
729    }
730
731    #[test]
732    fn test_matches_language_typescript() {
733        assert!(matches_language("ts", "typescript"));
734        assert!(matches_language("tsx", "typescript"));
735        assert!(matches_language("ts", "ts"));
736        assert!(!matches_language("js", "typescript"));
737    }
738
739    #[test]
740    fn test_matches_language_swift() {
741        assert!(matches_language("swift", "swift"));
742        assert!(matches_language("swift", "Swift"));
743        assert!(!matches_language("c", "swift"));
744    }
745
746    #[test]
747    fn test_matches_language_c() {
748        assert!(matches_language("c", "c"));
749        assert!(matches_language("h", "c"));
750        assert!(matches_language("C", "c"));
751        assert!(!matches_language("cpp", "c"));
752    }
753
754    #[test]
755    fn test_matches_language_cpp() {
756        assert!(matches_language("cpp", "cpp"));
757        assert!(matches_language("cc", "cpp"));
758        assert!(matches_language("cxx", "cpp"));
759        assert!(matches_language("hpp", "cpp"));
760        assert!(matches_language("hh", "cpp"));
761        assert!(matches_language("hxx", "cpp"));
762        assert!(matches_language("h", "cpp")); // Headers can be C++
763        assert!(matches_language("cpp", "c++")); // Alternative name
764        assert!(!matches_language("c", "cpp"));
765    }
766
767    #[test]
768    fn test_matches_language_csharp() {
769        assert!(matches_language("cs", "csharp"));
770        assert!(matches_language("cs", "c#"));
771        assert!(matches_language("csx", "csharp"));
772        assert!(matches_language("cs", "CSharp"));
773        assert!(!matches_language("cpp", "csharp"));
774    }
775
776    #[test]
777    fn test_matches_language_dart() {
778        assert!(matches_language("dart", "dart"));
779        assert!(matches_language("dart", "Dart"));
780        assert!(!matches_language("d", "dart"));
781    }
782
783    #[test]
784    fn test_matches_language_sql() {
785        assert!(matches_language("sql", "sql"));
786        assert!(matches_language("sql", "SQL"));
787        assert!(!matches_language("rs", "sql"));
788    }
789
790    #[test]
791    fn test_matches_language_servicenow() {
792        assert!(matches_language("snjs", "servicenow"));
793        assert!(matches_language("snjs", "ServiceNow-Xanadu"));
794        assert!(matches_language("snjs", "servicenow-xanadu-js"));
795        assert!(!matches_language("js", "servicenow"));
796    }
797}