Skip to main content

reflex/cli/
query.rs

1use crate::cache::CacheManager;
2use crate::models::Language;
3use crate::query::{QueryEngine, QueryFilter};
4use anyhow::Result;
5use owo_colors::OwoColorize;
6use std::time::Instant;
7
8/// Smart truncate preview to reduce token usage
9/// Truncates at word boundary if possible, adds ellipsis if truncated
10pub fn truncate_preview(preview: &str, max_length: usize) -> String {
11    if preview.len() <= max_length {
12        return preview.to_string();
13    }
14
15    // Find a good break point (prefer word boundary)
16    let truncate_at = preview
17        .char_indices()
18        .take(max_length)
19        .filter(|(_, c)| c.is_whitespace())
20        .last()
21        .map(|(i, _)| i)
22        // No whitespace in the first `max_length` chars — i.e. minified code. The
23        // fallback used to be a raw BYTE index, and slicing there panics whenever it
24        // lands mid-codepoint. Minified bundles carry emoji and CJK in embedded i18n
25        // tables, so this was a live crash in `rfx query` and the MCP server.
26        .unwrap_or_else(|| {
27            let cap = max_length.min(preview.len());
28            (0..=cap)
29                .rev()
30                .find(|&i| preview.is_char_boundary(i))
31                .unwrap_or(0)
32        });
33
34    let mut truncated = preview[..truncate_at].to_string();
35    truncated.push('…');
36    truncated
37}
38
39/// Handle the `query` subcommand
40#[allow(clippy::too_many_arguments)]
41pub(super) fn handle_query(
42    pattern: String,
43    symbols_flag: bool,
44    lang: Option<String>,
45    kind_str: Option<String>,
46    use_ast: bool,
47    use_regex: bool,
48    as_json: bool,
49    pretty_json: bool,
50    timing: bool,
51    ai_mode: bool,
52    limit: Option<usize>,
53    offset: Option<usize>,
54    expand: bool,
55    file_pattern: Option<String>,
56    exact: bool,
57    use_contains: bool,
58    ignore_case: bool,
59    include_locks: bool,
60    include_generated: bool,
61    count_only: bool,
62    timeout_secs: u64,
63    plain: bool,
64    glob_patterns: Vec<String>,
65    exclude_patterns: Vec<String>,
66    paths_only: bool,
67    no_truncate: bool,
68    context_arg: Option<usize>,
69    all: bool,
70    force: bool,
71    include_dependencies: bool,
72) -> Result<()> {
73    log::info!("Starting query command");
74
75    // AI mode implies JSON output
76    let as_json = as_json || ai_mode;
77
78    let cache = CacheManager::new(".");
79    let engine = QueryEngine::new(cache);
80
81    // Parse and validate language filter
82    let language = if let Some(lang_str) = lang.as_deref() {
83        match Language::from_name(lang_str) {
84            Some(l) => Some(l),
85            None => anyhow::bail!(
86                "Unknown language: '{}'\n\nSupported languages:\n  {}\n\nExample: rfx query \"pattern\" --lang rust",
87                lang_str,
88                Language::supported_names_help()
89            ),
90        }
91    } else {
92        None
93    };
94
95    // Warn when Swift is requested — symbol queries will return no results
96    if language == Some(Language::Swift) {
97        eprintln!(
98            "{}: Swift symbol extraction is temporarily disabled (tree-sitter-swift 0.7.x grammar incompatibility). \
99Full-text search will still work, but --symbols queries will return no results.",
100            "Warning".yellow().bold()
101        );
102    }
103
104    // Warn when --dependencies is used with a non-Rust language filter (REF-171)
105    if include_dependencies && matches!(language, Some(l) if l != Language::Rust) {
106        eprintln!(
107            "{}: --dependencies is currently only supported for Rust files. \
108No dependency data will be included for {} files.",
109            "Warning".yellow().bold(),
110            lang.as_deref().unwrap_or("non-Rust")
111        );
112    }
113
114    // Parse and validate symbol kind — error on unrecognised values (REF-60)
115    let kind = if let Some(s) = kind_str.as_deref() {
116        let capitalized = {
117            let mut chars = s.chars();
118            match chars.next() {
119                None => String::new(),
120                Some(first) => first
121                    .to_uppercase()
122                    .chain(chars.flat_map(|c| c.to_lowercase()))
123                    .collect(),
124            }
125        };
126        let parsed = capitalized
127            .parse::<crate::models::SymbolKind>()
128            .unwrap_or(crate::models::SymbolKind::Unknown(s.to_string()));
129        if let crate::models::SymbolKind::Unknown(_) = &parsed {
130            anyhow::bail!(
131                "Unknown symbol kind: '{}'\n\nSupported kinds:\n  function, class, struct, enum, interface, trait, \
132                constant, variable, method, module, namespace, type, macro, property, event, import, export, attribute\n\n\
133                Example: rfx query \"parse\" --kind function",
134                s
135            );
136        }
137        Some(parsed)
138    } else {
139        None
140    };
141
142    // Smart behavior: --kind implies --symbols
143    let symbols_mode = symbols_flag || kind.is_some();
144
145    // --limit 0 is rejected; use --all for unlimited results
146    if limit == Some(0) {
147        anyhow::bail!(
148            "--limit 0 is not valid. To return all results use --all (or -a).\n\
149             To return exactly 0 results is meaningless; omit --limit to use the default of 100."
150        );
151    }
152
153    // Smart limit handling:
154    // 1. If --count is set: no limit (count should always show total)
155    // 2. If --all is set: no limit (None)
156    // 3. If --paths is set and user didn't specify --limit: no limit (None)
157    // 4. If user specified --limit: use that value
158    // 5. Otherwise: use default limit of 100
159    let final_limit = if count_only || all || (paths_only && limit.is_none()) {
160        None // --count, --all, and --paths (without explicit --limit) all remove the result limit
161    } else if let Some(user_limit) = limit {
162        Some(user_limit) // Use user-specified limit
163    } else {
164        Some(100) // Default: limit to 100 results for token efficiency
165    };
166
167    // Validate AST query requirements
168    if use_ast && language.is_none() {
169        anyhow::bail!(
170            "AST pattern matching requires a language to be specified.\n\
171             \n\
172             Use --lang to specify the language for tree-sitter parsing.\n\
173             \n\
174             Supported languages for AST queries:\n\
175             • rust, python, go, java, c, c++, c#, php, ruby, kotlin, zig, typescript, javascript\n\
176             \n\
177             Note: Vue and Svelte use line-based parsing and do not support AST queries.\n\
178             \n\
179             WARNING: AST queries are SLOW (500ms-2s+). Use --symbols instead for 95% of cases.\n\
180             \n\
181             Examples:\n\
182             • rfx query \"(function_definition) @fn\" --ast --lang python\n\
183             • rfx query \"(class_declaration) @class\" --ast --lang typescript --glob \"src/**/*.ts\""
184        );
185    }
186
187    // VALIDATION: Check for conflicting or problematic flag combinations
188    // Only show warnings/errors in non-JSON mode (avoid breaking parsers)
189    if !as_json {
190        let mut has_errors = false;
191
192        // ERROR: Mutually exclusive pattern matching modes
193        if use_regex && use_contains {
194            eprintln!(
195                "{}",
196                "ERROR: Cannot use --regex and --contains together."
197                    .red()
198                    .bold()
199            );
200            eprintln!(
201                "  {} --regex for pattern matching (alternation, wildcards, etc.)",
202                "•".dimmed()
203            );
204            eprintln!(
205                "  {} --contains for substring matching (expansive search)",
206                "•".dimmed()
207            );
208            eprintln!(
209                "\n  {} Choose one based on your needs:",
210                "Tip:".cyan().bold()
211            );
212            eprintln!("    {} for OR logic: --regex", "pattern1|pattern2".yellow());
213            eprintln!("    {} for substring: --contains", "partial_text".yellow());
214            has_errors = true;
215        }
216
217        // ERROR: Contradictory matching requirements
218        if exact && use_contains {
219            eprintln!(
220                "{}",
221                "ERROR: Cannot use --exact and --contains together (contradictory)."
222                    .red()
223                    .bold()
224            );
225            eprintln!(
226                "  {} --exact requires exact symbol name match",
227                "•".dimmed()
228            );
229            eprintln!("  {} --contains allows substring matching", "•".dimmed());
230            has_errors = true;
231        }
232
233        // WARNING: Redundant file filtering
234        if file_pattern.is_some() && !glob_patterns.is_empty() {
235            eprintln!(
236                "{}",
237                "WARNING: Both --file and --glob specified.".yellow().bold()
238            );
239            eprintln!(
240                "  {} --file does substring matching on file paths",
241                "•".dimmed()
242            );
243            eprintln!(
244                "  {} --glob does pattern matching with wildcards",
245                "•".dimmed()
246            );
247            eprintln!(
248                "  {} Both filters will apply (AND condition)",
249                "Note:".dimmed()
250            );
251            eprintln!("\n  {} Usually you only need one:", "Tip:".cyan().bold());
252            eprintln!("    {} for simple matching", "--file User.php".yellow());
253            eprintln!(
254                "    {} for pattern matching",
255                "--glob src/**/*.php".yellow()
256            );
257        }
258
259        // INFO: Detect potentially problematic glob patterns
260        for pattern in &glob_patterns {
261            // Check for literal quotes in pattern
262            if (pattern.starts_with('\'') && pattern.ends_with('\''))
263                || (pattern.starts_with('"') && pattern.ends_with('"'))
264            {
265                eprintln!(
266                    "{}",
267                    format!("WARNING: Glob pattern contains quotes: {}", pattern)
268                        .yellow()
269                        .bold()
270                );
271                eprintln!(
272                    "  {} Shell quotes should not be part of the pattern",
273                    "Note:".dimmed()
274                );
275                eprintln!("  {} --glob src/**/*.rs", "Correct:".green());
276                eprintln!("  {} --glob 'src/**/*.rs'", "Wrong:".red().dimmed());
277            }
278
279            // Suggest using ** instead of * for recursive matching
280            if pattern.contains("*/") && !pattern.contains("**/") {
281                eprintln!(
282                    "{}",
283                    format!(
284                        "INFO: Glob '{}' uses * (matches one directory level)",
285                        pattern
286                    )
287                    .cyan()
288                );
289                eprintln!(
290                    "  {} Use ** for recursive matching across subdirectories",
291                    "Tip:".cyan().bold()
292                );
293                eprintln!(
294                    "    {} → matches files in Models/ only",
295                    "app/Models/*.php".yellow()
296                );
297                eprintln!(
298                    "    {} → matches files in Models/ and subdirs",
299                    "app/Models/**/*.php".green()
300                );
301            }
302        }
303
304        if has_errors {
305            anyhow::bail!("Invalid flag combination. Fix the errors above and try again.");
306        }
307
308        // REF-58: Warn when --file looks like a concrete path that doesn't exist on disk
309        if let Some(ref fp) = file_pattern {
310            // Only warn when it looks like a literal path (no glob wildcards, has extension or slash)
311            let looks_like_path =
312                !fp.contains('*') && !fp.contains('?') && (fp.contains('/') || fp.contains('.'));
313            if looks_like_path && !std::path::Path::new(fp).exists() {
314                eprintln!(
315                    "{}",
316                    format!("[warn] --file path not found on disk: {}", fp).yellow()
317                );
318                eprintln!(
319                    "  Continuing with substring match — results will be empty if no indexed path contains '{}'.",
320                    fp
321                );
322            }
323        }
324    }
325
326    // Clamp context lines to a sane max (10) to avoid huge output
327    let context_lines = context_arg.map(|n| n.min(10)).unwrap_or(0);
328
329    let filter = QueryFilter {
330        language,
331        kind,
332        use_ast,
333        use_regex,
334        limit: final_limit,
335        symbols_mode,
336        expand,
337        file_pattern,
338        exact,
339        use_contains,
340        ignore_case,
341        include_locks,
342        include_generated,
343        count_only,
344        timeout_secs,
345        glob_patterns: glob_patterns.clone(),
346        exclude_patterns,
347        paths_only,
348        offset,
349        force,
350        suppress_output: as_json, // Suppress warnings in JSON mode
351        include_dependencies,
352        context_lines,
353        collect_timings: timing,
354        ..Default::default()
355    };
356
357    // Measure query time
358    let start = Instant::now();
359
360    // Execute query and get pagination metadata
361    // Handle errors specially for JSON output mode
362    let (query_response, mut flat_results, total_results, has_more) = if use_ast {
363        // AST query: pattern is the S-expression, scan all files
364        match engine.search_ast_all_files(&pattern, filter.clone()) {
365            Ok(ast_results) => {
366                let count = ast_results.len();
367                (None, ast_results, Some(count), false)
368            }
369            Err(e) => {
370                if as_json {
371                    // Output error as JSON
372                    let error_response = serde_json::json!({
373                        "error": e.to_string(),
374                        "query_too_broad": e.to_string().contains("Query too broad")
375                    });
376                    let json_output = if pretty_json {
377                        serde_json::to_string_pretty(&error_response)?
378                    } else {
379                        serde_json::to_string(&error_response)?
380                    };
381                    println!("{}", json_output);
382                    std::process::exit(1);
383                } else {
384                    return Err(e);
385                }
386            }
387        }
388    } else {
389        // Use metadata-aware search for all queries (to get pagination info)
390        match engine.search_with_metadata(&pattern, filter.clone()) {
391            Ok(response) => {
392                // `None` when verification stopped early; the plain summary and the
393                // count object must not print the verified-so-far number as a total.
394                let total = response.pagination.exact_total();
395                let has_more = response.pagination.has_more;
396
397                // Flatten grouped results to SearchResult vec for plain text formatting
398                let flat = response
399                    .results
400                    .iter()
401                    .flat_map(|file_group| {
402                        file_group.matches.iter().map(move |m| {
403                            crate::models::SearchResult {
404                                path: file_group.path.clone(),
405                                lang: crate::models::Language::Unknown, // Will be set by formatter if needed
406                                kind: m.kind.clone(),
407                                symbol: m.symbol.clone(),
408                                span: m.span.clone(),
409                                preview: m.preview.clone(),
410                                dependencies: file_group.dependencies.clone(),
411                            }
412                        })
413                    })
414                    .collect();
415
416                (Some(response), flat, total, has_more)
417            }
418            Err(e) => {
419                if as_json {
420                    // Output error as JSON
421                    let error_response = serde_json::json!({
422                        "error": e.to_string(),
423                        "query_too_broad": e.to_string().contains("Query too broad")
424                    });
425                    let json_output = if pretty_json {
426                        serde_json::to_string_pretty(&error_response)?
427                    } else {
428                        serde_json::to_string(&error_response)?
429                    };
430                    println!("{}", json_output);
431                    std::process::exit(1);
432                } else {
433                    return Err(e);
434                }
435            }
436        }
437    };
438
439    // What the engine wants the user to know: a bracket rewrite (`warnings`) or a
440    // zero explained by substring matches (`hint`). Plain mode prints them to stderr;
441    // JSON modes carry them as fields where the shape allows, else stderr.
442    let engine_warnings: Vec<String> = query_response
443        .as_ref()
444        .map(|r| r.warnings.clone())
445        .unwrap_or_default();
446    let engine_hint: Option<String> = query_response.as_ref().and_then(|r| r.hint.clone());
447    // For AI-instruction thresholds only: exact total, else the estimate, else the
448    // end of this page. Never printed as a total.
449    let best_total: usize = query_response
450        .as_ref()
451        .map(|r| r.pagination.best_total())
452        .or(total_results)
453        .unwrap_or(0);
454    let notes_to_stderr = || {
455        for w in &engine_warnings {
456            eprintln!("{}: {}", "Warning".yellow().bold(), w);
457        }
458        if let Some(h) = &engine_hint {
459            eprintln!("{}: {}", "Hint".cyan().bold(), h);
460        }
461    };
462
463    // Apply preview truncation unless --no-truncate is set
464    if !no_truncate {
465        const MAX_PREVIEW_LENGTH: usize = 100;
466        for result in &mut flat_results {
467            result.preview = truncate_preview(&result.preview, MAX_PREVIEW_LENGTH);
468        }
469    }
470
471    let elapsed = start.elapsed();
472
473    if timing {
474        let ms = |us: u64| us as f64 / 1000.0;
475        match query_response.as_ref().and_then(|r| r.timings.as_ref()) {
476            Some(t) => eprintln!(
477                "timing: open {:.2}ms | candidates {:.2}ms | verify {:.2}ms | status wait {:.2}ms (compute {:.2}ms) | group {:.2}ms | engine {:.2}ms | wall {:.2}ms",
478                ms(t.open_us),
479                ms(t.candidates_us),
480                ms(t.verify_us),
481                ms(t.status_us),
482                ms(t.status_compute_us),
483                ms(t.group_us),
484                ms(t.total_us),
485                elapsed.as_secs_f64() * 1000.0
486            ),
487            None => eprintln!("timing: wall {:.2}ms", elapsed.as_secs_f64() * 1000.0),
488        }
489    }
490
491    // Format timing string
492    let timing_str = if elapsed.as_millis() < 1 {
493        format!("{:.1}ms", elapsed.as_secs_f64() * 1000.0)
494    } else {
495        format!("{}ms", elapsed.as_millis())
496    };
497
498    if as_json {
499        if count_only {
500            // Count-only JSON mode: output simple count object
501            // `--count` runs without a limit, so the total is exact; the fallback
502            // only guards the type.
503            let mut count_response = serde_json::json!({
504                "count": total_results.unwrap_or(flat_results.len()),
505                "timing_ms": elapsed.as_millis()
506            });
507            if !engine_warnings.is_empty() {
508                count_response["warnings"] = serde_json::json!(engine_warnings);
509            }
510            if let Some(h) = &engine_hint {
511                count_response["hint"] = serde_json::json!(h);
512            }
513            if let Some(reason) = query_response.as_ref().and_then(|r| r.excluded_reason) {
514                count_response["excluded_reason"] = serde_json::json!(reason);
515            }
516            let json_output = if pretty_json {
517                serde_json::to_string_pretty(&count_response)?
518            } else {
519                serde_json::to_string(&count_response)?
520            };
521            println!("{}", json_output);
522        } else if paths_only {
523            // Paths-only JSON mode: output deduplicated array of path strings (REF-62)
524            let mut seen = std::collections::HashSet::new();
525            let unique_paths: Vec<String> = flat_results
526                .iter()
527                .filter_map(|r| {
528                    if seen.insert(r.path.clone()) {
529                        Some(r.path.clone())
530                    } else {
531                        None
532                    }
533                })
534                .collect();
535
536            let json_output = if ai_mode {
537                // REF-59: wrap with ai_instruction when --ai flag is used
538                let ai_instruction = crate::query::generate_ai_instruction(
539                    unique_paths.len(),
540                    best_total,
541                    has_more,
542                    symbols_mode,
543                    true,
544                    use_ast,
545                    use_regex,
546                    language.is_some(),
547                    !glob_patterns.is_empty(),
548                    exact,
549                );
550                let wrapper = serde_json::json!({
551                    "ai_instruction": ai_instruction,
552                    "count": unique_paths.len(),
553                    "results": unique_paths,
554                });
555                if pretty_json {
556                    serde_json::to_string_pretty(&wrapper)?
557                } else {
558                    serde_json::to_string(&wrapper)?
559                }
560            } else {
561                if pretty_json {
562                    serde_json::to_string_pretty(&unique_paths)?
563                } else {
564                    serde_json::to_string(&unique_paths)?
565                }
566            };
567            println!("{}", json_output);
568            // A bare array has no room for the notes; stderr keeps stdout pure JSON.
569            notes_to_stderr();
570        } else {
571            // Get or build QueryResponse for JSON output
572            let mut response = if let Some(resp) = query_response {
573                // We already have a response from search_with_metadata
574                // Apply truncation to the response (the flat_results were already truncated)
575                let mut resp = resp;
576
577                // Apply truncation to results
578                if !no_truncate {
579                    const MAX_PREVIEW_LENGTH: usize = 100;
580                    for file_group in resp.results.iter_mut() {
581                        for m in file_group.matches.iter_mut() {
582                            m.preview = truncate_preview(&m.preview, MAX_PREVIEW_LENGTH);
583                        }
584                    }
585                }
586
587                resp
588            } else {
589                // For AST queries, build a response with minimal metadata
590                // Group flat results by file path
591                use crate::models::{FileGroupedResult, IndexStatus, MatchResult, PaginationInfo};
592                use std::collections::HashMap;
593
594                let mut grouped: HashMap<String, Vec<crate::models::SearchResult>> = HashMap::new();
595                for result in &flat_results {
596                    grouped
597                        .entry(result.path.clone())
598                        .or_default()
599                        .push(result.clone());
600                }
601
602                // Load ContentReader for extracting context lines
603                use crate::content_store::ContentReader;
604                let local_cache = CacheManager::new(".");
605                let content_path = local_cache.path().join("content.bin");
606                let content_reader_opt = ContentReader::open(&content_path).ok();
607
608                let mut file_results: Vec<FileGroupedResult> = grouped
609                    .into_iter()
610                    .map(|(path, file_matches)| {
611                        // Get file_id for context extraction
612                        // Note: We use ContentReader's get_file_id_by_path() which returns array indices,
613                        // not database file_ids (which are AUTO INCREMENT values)
614                        let normalized_path = path.strip_prefix("./").unwrap_or(&path);
615                        let file_id_for_context = if let Some(reader) = &content_reader_opt {
616                            reader.get_file_id_by_path(normalized_path)
617                        } else {
618                            None
619                        };
620
621                        let language = file_matches.first().map(|r| r.lang).unwrap_or_default();
622                        let matches: Vec<MatchResult> = file_matches
623                            .into_iter()
624                            .map(|r| {
625                                // Extract context lines (default: 3 lines before and after)
626                                let (context_before, context_after) =
627                                    if let (Some(reader), Some(fid)) =
628                                        (&content_reader_opt, file_id_for_context)
629                                    {
630                                        reader
631                                            .get_context_by_line(fid, r.span.start_line, 3)
632                                            .unwrap_or_else(|_| (vec![], vec![]))
633                                    } else {
634                                        (vec![], vec![])
635                                    };
636
637                                MatchResult {
638                                    kind: r.kind,
639                                    symbol: r.symbol,
640                                    span: r.span,
641                                    preview: r.preview,
642                                    context_before,
643                                    context_after,
644                                }
645                            })
646                            .collect();
647                        FileGroupedResult {
648                            path,
649                            language,
650                            dependencies: None,
651                            matches,
652                        }
653                    })
654                    .collect();
655
656                // Sort by path for deterministic output
657                file_results.sort_by(|a, b| a.path.cmp(&b.path));
658
659                crate::models::QueryResponse {
660                    ai_instruction: None, // Will be populated below if ai_mode is true
661                    status: IndexStatus::Fresh,
662                    can_trust_results: true,
663                    warning: None,
664                    pagination: PaginationInfo {
665                        total: Some(flat_results.len()),
666                        count: flat_results.len(),
667                        offset: offset.unwrap_or(0),
668                        limit,
669                        has_more: false, // AST already applied pagination
670                        total_is_exact: true,
671                        approx_total: None,
672                    },
673                    results: file_results,
674                    substring_hint_count: None,
675                    excluded_reason: None,
676                    excluded_by_default: None,
677                    file_count: None,
678                    warnings: Vec::new(),
679                    hint: None,
680                    timings: None,
681                }
682            };
683
684            // Generate AI instruction if in AI mode
685            if ai_mode {
686                let result_count: usize = response.results.iter().map(|fg| fg.matches.len()).sum();
687
688                response.ai_instruction = crate::query::generate_ai_instruction(
689                    result_count,
690                    response.pagination.best_total(),
691                    response.pagination.has_more,
692                    symbols_mode,
693                    paths_only,
694                    use_ast,
695                    use_regex,
696                    language.is_some(),
697                    !glob_patterns.is_empty(),
698                    exact,
699                );
700            }
701
702            let json_output = if pretty_json {
703                serde_json::to_string_pretty(&response)?
704            } else {
705                serde_json::to_string(&response)?
706            };
707            println!("{}", json_output);
708
709            let result_count: usize = response.results.iter().map(|fg| fg.matches.len()).sum();
710            eprintln!(
711                "Found {} result{} in {}",
712                result_count,
713                if result_count == 1 { "" } else { "s" },
714                timing_str
715            );
716        }
717    } else {
718        // Standard output with formatting
719        notes_to_stderr();
720        if count_only {
721            // `--count` runs without a limit, so the total is exact and, in
722            // count-only mode, nothing was materialised to count by hand.
723            let n = total_results.unwrap_or(flat_results.len());
724            println!(
725                "Found {} result{} in {}",
726                n,
727                if n == 1 { "" } else { "s" },
728                timing_str
729            );
730            return Ok(());
731        }
732
733        if paths_only {
734            // Paths-only plain text mode: output one path per line
735            if flat_results.is_empty() {
736                eprintln!("No results found (searched in {}).", timing_str);
737            } else {
738                for result in &flat_results {
739                    println!("{}", result.path);
740                }
741                let n = flat_results.len();
742                eprintln!(
743                    "Found {} unique file{} in {}",
744                    n,
745                    if n == 1 { "" } else { "s" },
746                    timing_str
747                );
748            }
749        } else {
750            // Standard result formatting
751            if flat_results.is_empty() {
752                println!("No results found (searched in {}).", timing_str);
753            } else {
754                // Use formatter for pretty output
755                let formatter = crate::formatter::OutputFormatter::new(plain);
756                formatter.format_results(&flat_results, &pattern)?;
757
758                // Print summary at the bottom with pagination details
759                let n = flat_results.len();
760                let plural = if n == 1 { "" } else { "s" };
761                let approx = query_response
762                    .as_ref()
763                    .and_then(|r| r.pagination.approx_total);
764                match total_results {
765                    // Verification stopped once the page was full. The verified-so-far
766                    // number is not a total and is never printed as one.
767                    None => {
768                        match approx {
769                            Some(est) => println!(
770                                "\nFound {} result{} (~{} total, estimated) in {}",
771                                n, plural, est, timing_str
772                            ),
773                            None => println!(
774                                "\nFound {} result{} (more available, total unknown) in {}",
775                                n, plural, timing_str
776                            ),
777                        }
778                        println!(
779                            "Use --limit/--offset to paginate, or --count for the exact total"
780                        );
781                    }
782                    Some(total) if total > n => {
783                        println!(
784                            "\nFound {} result{} ({} total) in {}",
785                            n, plural, total, timing_str
786                        );
787                        if has_more {
788                            println!("Use --limit and --offset to paginate");
789                        }
790                    }
791                    Some(_) => {
792                        // All results shown - simple count
793                        println!("\nFound {} result{} in {}", n, plural, timing_str);
794                    }
795                }
796            }
797        }
798    }
799
800    Ok(())
801}
802
803/// Handle interactive mode (default when no command is given)
804pub(super) fn handle_interactive() -> Result<()> {
805    log::info!("Launching interactive mode");
806    crate::interactive::run_interactive()
807}
808
809#[cfg(test)]
810mod truncate_tests {
811    use super::truncate_preview;
812
813    /// Regression: the whitespace-search fallback returned a raw BYTE index, and
814    /// `preview[..byte]` panics mid-codepoint. It fires exactly on minified code —
815    /// no whitespace in the first `max_length` chars — which is also where non-ASCII
816    /// i18n tables live. This panicked `rfx query` and the MCP server.
817    #[test]
818    fn a_whitespace_free_multibyte_line_does_not_panic() {
819        for filler in ["日", "😀", "é", "\u{a0}"] {
820            let line = filler.repeat(500);
821            let out = truncate_preview(&line, 100);
822            assert!(out.ends_with('…'), "{filler}: {out}");
823            // Round-tripping proves the slice is well-formed UTF-8.
824            assert_eq!(String::from_utf8(out.clone().into_bytes()).unwrap(), out);
825        }
826    }
827
828    #[test]
829    fn short_previews_are_returned_verbatim() {
830        assert_eq!(truncate_preview("fn main() {}", 100), "fn main() {}");
831    }
832
833    #[test]
834    fn a_word_boundary_is_still_preferred_when_one_exists() {
835        let out = truncate_preview("alpha beta gamma delta epsilon zeta", 20);
836        assert!(out.ends_with('…'));
837        assert!(!out.contains("epsilon"), "should cut early: {out}");
838    }
839}