Skip to main content

gobby_code/commands/
search.rs

1use std::collections::HashMap;
2use std::collections::HashSet;
3
4use crate::commands::scope;
5use crate::config::Context;
6use crate::db;
7use crate::models::{PagedResponse, SearchResult, Symbol};
8use crate::output::{self, Format};
9use crate::search::{fts, graph_boost, rrf};
10use crate::vector::code_symbols;
11use crate::visibility;
12
13pub struct SearchOptions<'a> {
14    pub limit: usize,
15    pub offset: usize,
16    pub kind: Option<&'a str>,
17    pub language: Option<&'a str>,
18    pub paths: &'a [String],
19    pub format: Format,
20    pub with_graph: bool,
21}
22
23const LITERAL_QUERY_HINT: &str = "`gcode search` is hybrid/fuzzy concept search. For exact strings, call sites, dotted config keys, quoted strings, or paths, use `gcode grep \"pattern\" [PATH...] -m 50`; for ranked file-content matches, use `gcode search-content \"query\" [PATH...]`.";
24
25pub fn search(ctx: &Context, query: &str, options: SearchOptions<'_>) -> anyhow::Result<()> {
26    let mut conn = db::connect_readonly(&ctx.database_url)?;
27    let expanded_paths = fts::expand_paths(options.paths);
28    let path_patterns = fts::compile_patterns(&expanded_paths)?;
29
30    // Fetch generously for RRF. Total is a best-effort estimate bounded by fetch_limit
31    // per source — exact counts aren't feasible because RRF merges results from BM25,
32    // Qdrant, and FalkorDB with deduplication, so source counts aren't additive.
33    let fetch_limit = ((options.offset + options.limit) * 3).max(200);
34
35    let exact_outcome = fts::search_symbols_exact_first_visible(
36        &mut conn,
37        query,
38        ctx,
39        options.kind,
40        options.language,
41        &expanded_paths,
42        fetch_limit,
43    );
44    let mut visible_search_degraded = exact_outcome.degraded;
45    let exact_results = exact_outcome.results;
46    let exact_ids: Vec<String> = exact_results.iter().map(|s| s.id.clone()).collect();
47
48    // Source 1: BM25 via required pg_search indexes.
49    let mut fts_outcome = fts::search_symbols_fts_visible(
50        &mut conn,
51        query,
52        ctx,
53        options.kind,
54        options.language,
55        &expanded_paths,
56        fetch_limit,
57    );
58    visible_search_degraded |= fts_outcome.degraded;
59    let mut fts_results = fts_outcome.results;
60    if fts_results.is_empty() {
61        fts_outcome = fts::search_symbols_by_name_visible(
62            &mut conn,
63            query,
64            ctx,
65            options.kind,
66            options.language,
67            &expanded_paths,
68            fetch_limit,
69        );
70        visible_search_degraded |= fts_outcome.degraded;
71        fts_results = fts_outcome.results;
72    }
73    let fts_ids: Vec<String> = fts_results.iter().map(|s| s.id.clone()).collect();
74
75    // Source 2: Semantic search (Qdrant + embeddings)
76    let semantic_results = code_symbols::semantic_search(ctx, query, fetch_limit);
77    let semantic_ids: Vec<String> = semantic_results.iter().map(|(id, _)| id.clone()).collect();
78
79    // Source 3: Graph boost (FalkorDB callers + usages of the resolved query symbol)
80    let graph_ids = if options.with_graph {
81        graph_boost::graph_boost(ctx, Some(&mut conn), query)
82    } else {
83        Vec::new()
84    };
85
86    // Source 4: Graph expand — seed from top BM25+semantic results, expand neighborhood
87    let seed_ids = extract_seed_ids(&fts_results, &semantic_ids, 5);
88    let expand_ids = if options.with_graph {
89        graph_boost::graph_expand(ctx, Some(&mut conn), &seed_ids)
90    } else {
91        Vec::new()
92    };
93
94    // Build RRF sources (only include non-empty sources)
95    let mut sources: Vec<(&str, Vec<String>)> = Vec::new();
96    if !exact_ids.is_empty() {
97        sources.push(("exact", exact_ids));
98    }
99    sources.push(("fts", fts_ids));
100    if !semantic_ids.is_empty() {
101        sources.push(("semantic", semantic_ids));
102    }
103    if !graph_ids.is_empty() {
104        sources.push(("graph", graph_ids));
105    }
106    if !expand_ids.is_empty() {
107        sources.push(("graph_expand", expand_ids));
108    }
109
110    let merged = rrf::merge(sources);
111
112    // Build symbol cache from exact and BM25 results.
113    let mut symbol_cache: HashMap<String, Symbol> = HashMap::new();
114    for sym in exact_results {
115        symbol_cache.insert(sym.id.clone(), sym);
116    }
117    for sym in fts_results {
118        symbol_cache.insert(sym.id.clone(), sym);
119    }
120
121    // Resolve ALL results first so total reflects resolvable symbols only
122    let mut all_resolved: Vec<(Symbol, f64, Vec<String>)> = Vec::new();
123    for (sym_id, score, source_names) in &merged {
124        let sym = match symbol_cache.get(sym_id).cloned() {
125            Some(symbol) => Some(symbol),
126            None => visibility::visible_symbol_by_id(&mut conn, ctx, sym_id)?,
127        };
128
129        if let Some(s) = sym
130            && symbol_matches_filters(
131                &mut conn,
132                ctx,
133                &s,
134                options.kind,
135                options.language,
136                &path_patterns,
137            )
138        {
139            all_resolved.push((s, *score, source_names.clone()));
140        }
141    }
142
143    all_resolved.sort_by(|a, b| {
144        exact_tier(query, &a.0)
145            .cmp(&exact_tier(query, &b.0))
146            .then_with(|| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal))
147            .then_with(|| a.0.file_path.cmp(&b.0.file_path))
148            .then_with(|| a.0.line_start.cmp(&b.0.line_start))
149    });
150
151    let total = all_resolved.len();
152    let results: Vec<_> = all_resolved
153        .into_iter()
154        .skip(options.offset)
155        .take(options.limit)
156        .map(|(s, rrf_score, sources)| {
157            let mut result = s.to_brief();
158            result.score = final_rank_score(query, &s, rrf_score);
159            result.rrf_score = Some(rrf_score);
160            result.sources = Some(sources);
161            result
162        })
163        .collect();
164
165    print_empty_diagnostic(ctx, results.is_empty(), options.offset, total);
166    let literal_hint = literal_query_hint(query);
167    let path_hint =
168        fts::path_filter_requires_post_filter(&expanded_paths).then(path_filter_post_filter_hint);
169    let visibility_hint = visible_search_degraded.then(visible_search_degraded_hint);
170    let hint = combine_hints(combine_hints(literal_hint, path_hint), visibility_hint);
171
172    match options.format {
173        Format::Json => output::print_json(&PagedResponse {
174            project_id: ctx.project_id.clone(),
175            total,
176            offset: options.offset,
177            limit: options.limit,
178            results,
179            hint,
180        }),
181        Format::Text => {
182            print_search_warning(ctx, hint.as_deref());
183            let lines = results
184                .iter()
185                .map(|r| {
186                    let sources = r.sources.as_ref().map(|s| s.join("+")).unwrap_or_default();
187                    format!(
188                        "{}:{} [{}] {} (score: {:.4}, via: {})",
189                        r.file_path, r.line_start, r.kind, r.qualified_name, r.score, sources
190                    )
191                })
192                .collect::<Vec<_>>();
193            if !lines.is_empty() {
194                output::print_text(&lines.join("\n"))?;
195            }
196            print_pagination_hint(total, options.offset, results.len());
197            Ok(())
198        }
199    }
200}
201
202pub fn search_symbol(ctx: &Context, query: &str, options: SearchOptions<'_>) -> anyhow::Result<()> {
203    let mut conn = db::connect_readonly(&ctx.database_url)?;
204    let expanded_paths = fts::expand_paths(options.paths);
205    let path_patterns = fts::compile_patterns(&expanded_paths)?;
206    let fetch_limit = ((options.offset + options.limit) * 3).max(200);
207    let exact_outcome = fts::search_symbols_exact_first_visible(
208        &mut conn,
209        query,
210        ctx,
211        options.kind,
212        options.language,
213        &expanded_paths,
214        fetch_limit,
215    );
216    let visible_search_degraded = exact_outcome.degraded;
217    let exact_results = exact_outcome.results;
218
219    if options.with_graph {
220        return search_symbol_with_graph(
221            ctx,
222            query,
223            options,
224            exact_results,
225            SymbolGraphSearchContext {
226                conn: &mut conn,
227                path_patterns: &path_patterns,
228                expanded_paths: &expanded_paths,
229                visible_search_degraded,
230            },
231        );
232    }
233
234    let all_results: Vec<_> = exact_results
235        .into_iter()
236        .filter(|s| {
237            symbol_matches_filters(
238                &mut conn,
239                ctx,
240                s,
241                options.kind,
242                options.language,
243                &path_patterns,
244            )
245        })
246        .collect();
247    let total = all_results.len();
248    let results: Vec<_> = all_results
249        .into_iter()
250        .skip(options.offset)
251        .take(options.limit)
252        .collect();
253
254    print_empty_diagnostic(ctx, results.is_empty(), options.offset, total);
255    let hint = combine_hints(
256        fts::path_filter_requires_post_filter(&expanded_paths).then(path_filter_post_filter_hint),
257        visible_search_degraded.then(visible_search_degraded_hint),
258    );
259
260    match options.format {
261        Format::Json => {
262            let results: Vec<SearchResult> = results
263                .iter()
264                .map(|s| {
265                    let mut result = s.to_brief();
266                    result.score = exact_tier_score(query, s);
267                    result
268                })
269                .collect();
270            output::print_json(&PagedResponse {
271                project_id: ctx.project_id.clone(),
272                total,
273                offset: options.offset,
274                limit: options.limit,
275                results,
276                hint,
277            })
278        }
279        Format::Text => {
280            print_search_warning(ctx, hint.as_deref());
281            let lines = results
282                .iter()
283                .map(format_symbol_lookup_text)
284                .collect::<Vec<_>>();
285            if !lines.is_empty() {
286                output::print_text(&lines.join("\n"))?;
287            }
288            print_pagination_hint(total, options.offset, results.len());
289            Ok(())
290        }
291    }
292}
293
294struct SymbolGraphSearchContext<'a> {
295    conn: &'a mut postgres::Client,
296    path_patterns: &'a [glob::Pattern],
297    expanded_paths: &'a [String],
298    visible_search_degraded: bool,
299}
300
301fn search_symbol_with_graph(
302    ctx: &Context,
303    query: &str,
304    options: SearchOptions<'_>,
305    exact_results: Vec<Symbol>,
306    graph_context: SymbolGraphSearchContext<'_>,
307) -> anyhow::Result<()> {
308    let SymbolGraphSearchContext {
309        conn,
310        path_patterns,
311        expanded_paths,
312        visible_search_degraded,
313    } = graph_context;
314    let exact_ids: Vec<String> = exact_results.iter().map(|s| s.id.clone()).collect();
315    let seed_ids: Vec<String> = exact_ids.iter().take(5).cloned().collect();
316    let graph_ids = graph_boost::graph_boost(ctx, Some(&mut *conn), query);
317    let expand_ids = graph_boost::graph_expand(ctx, Some(&mut *conn), &seed_ids);
318
319    let mut sources: Vec<(&str, Vec<String>)> = Vec::new();
320    if !exact_ids.is_empty() {
321        sources.push(("exact", exact_ids));
322    }
323    if !graph_ids.is_empty() {
324        sources.push(("graph", graph_ids));
325    }
326    if !expand_ids.is_empty() {
327        sources.push(("graph_expand", expand_ids));
328    }
329
330    let merged = rrf::merge(sources);
331    let mut symbol_cache: HashMap<String, Symbol> = exact_results
332        .into_iter()
333        .map(|sym| (sym.id.clone(), sym))
334        .collect();
335    let mut all_resolved: Vec<(Symbol, f64, Vec<String>)> = Vec::new();
336    for (sym_id, rrf_score, source_names) in &merged {
337        let sym = match symbol_cache.remove(sym_id) {
338            Some(symbol) => Some(symbol),
339            None => visibility::visible_symbol_by_id(conn, ctx, sym_id)?,
340        };
341
342        if let Some(s) = sym
343            && symbol_matches_filters(conn, ctx, &s, options.kind, options.language, path_patterns)
344        {
345            all_resolved.push((s, *rrf_score, source_names.clone()));
346        }
347    }
348
349    all_resolved.sort_by(|a, b| {
350        exact_tier(query, &a.0)
351            .cmp(&exact_tier(query, &b.0))
352            .then_with(|| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal))
353            .then_with(|| a.0.file_path.cmp(&b.0.file_path))
354            .then_with(|| a.0.line_start.cmp(&b.0.line_start))
355    });
356
357    let total = all_resolved.len();
358    let results: Vec<_> = all_resolved
359        .into_iter()
360        .skip(options.offset)
361        .take(options.limit)
362        .map(|(s, rrf_score, sources)| {
363            let mut result = s.to_brief();
364            result.score = final_rank_score(query, &s, rrf_score);
365            result.rrf_score = Some(rrf_score);
366            result.sources = Some(sources);
367            result
368        })
369        .collect();
370
371    print_empty_diagnostic(ctx, results.is_empty(), options.offset, total);
372    let hint = combine_hints(
373        fts::path_filter_requires_post_filter(expanded_paths).then(path_filter_post_filter_hint),
374        visible_search_degraded.then(visible_search_degraded_hint),
375    );
376
377    match options.format {
378        Format::Json => output::print_json(&PagedResponse {
379            project_id: ctx.project_id.clone(),
380            total,
381            offset: options.offset,
382            limit: options.limit,
383            results,
384            hint,
385        }),
386        Format::Text => {
387            print_search_warning(ctx, hint.as_deref());
388            let lines = results
389                .iter()
390                .map(|r| {
391                    let sources = r.sources.as_ref().map(|s| s.join("+")).unwrap_or_default();
392                    format!(
393                        "{}:{} [{}] {} (score: {:.4}, via: {})",
394                        r.file_path, r.line_start, r.kind, r.qualified_name, r.score, sources
395                    )
396                })
397                .collect::<Vec<_>>();
398            if !lines.is_empty() {
399                output::print_text(&lines.join("\n"))?;
400            }
401            print_pagination_hint(total, options.offset, results.len());
402            Ok(())
403        }
404    }
405}
406
407pub fn search_text(
408    ctx: &Context,
409    query: &str,
410    limit: usize,
411    offset: usize,
412    language: Option<&str>,
413    paths: &[String],
414    format: Format,
415) -> anyhow::Result<()> {
416    let mut conn = db::connect_readonly(&ctx.database_url)?;
417    let expanded_paths = fts::expand_paths(paths);
418    let path_patterns = fts::compile_patterns(&expanded_paths)?;
419    let has_path_filters = !expanded_paths.is_empty();
420    let fetch_limit = if has_path_filters {
421        fts::FILTERED_FETCH_CAP
422    } else {
423        ((offset + limit) * 3).max(200)
424    };
425    let all_results = fts::search_text_visible(
426        &mut conn,
427        query,
428        ctx,
429        language,
430        &expanded_paths,
431        fetch_limit,
432    );
433    let visible_search_degraded = all_results.degraded;
434    let all_results = all_results.results;
435    let cap_hint = (has_path_filters && all_results.len() >= fts::FILTERED_FETCH_CAP)
436        .then(filtered_fetch_cap_hint);
437    let path_hint =
438        fts::path_filter_requires_post_filter(&expanded_paths).then(path_filter_post_filter_hint);
439    let hint = combine_hints(
440        combine_hints(cap_hint, path_hint),
441        visible_search_degraded.then(visible_search_degraded_hint),
442    );
443    let all_results: Vec<_> = all_results
444        .into_iter()
445        .filter(|r| search_result_matches_filters(&mut conn, ctx, r, language, &path_patterns))
446        .collect();
447    let total = if has_path_filters {
448        all_results.len()
449    } else {
450        fts::count_text_visible(&mut conn, query, ctx, language, &expanded_paths)
451    };
452    let results: Vec<_> = all_results.into_iter().skip(offset).take(limit).collect();
453
454    print_empty_diagnostic(ctx, results.is_empty(), offset, total);
455
456    match format {
457        Format::Json => output::print_json(&PagedResponse {
458            project_id: ctx.project_id.clone(),
459            total,
460            offset,
461            limit,
462            results,
463            hint,
464        }),
465        Format::Text => {
466            print_search_warning(ctx, hint.as_deref());
467            let lines = results
468                .iter()
469                .map(|r| {
470                    format!(
471                        "{}:{} [{}] {}",
472                        r.file_path, r.line_start, r.kind, r.qualified_name
473                    )
474                })
475                .collect::<Vec<_>>();
476            if !lines.is_empty() {
477                output::print_text(&lines.join("\n"))?;
478            }
479            if total > offset + results.len() {
480                print_pagination_hint(total, offset, results.len());
481            }
482            Ok(())
483        }
484    }
485}
486
487/// Extract unique symbol IDs from the top BM25 and semantic results for graph expansion.
488fn extract_seed_ids(
489    fts_results: &[Symbol],
490    semantic_ids: &[String],
491    per_source: usize,
492) -> Vec<String> {
493    let mut ids = Vec::new();
494    let mut seen = HashSet::new();
495
496    // Top N from BM25 (already have Symbol structs with IDs)
497    for sym in fts_results.iter().take(per_source) {
498        if !sym.id.is_empty() && seen.insert(sym.id.clone()) {
499            ids.push(sym.id.clone());
500        }
501    }
502
503    // Top N from semantic (already canonical symbol IDs)
504    for id in semantic_ids.iter().take(per_source) {
505        if !id.is_empty() && seen.insert(id.clone()) {
506            ids.push(id.clone());
507        }
508    }
509
510    ids
511}
512
513pub fn search_content(
514    ctx: &Context,
515    query: &str,
516    limit: usize,
517    offset: usize,
518    language: Option<&str>,
519    paths: &[String],
520    format: Format,
521) -> anyhow::Result<()> {
522    let mut conn = db::connect_readonly(&ctx.database_url)?;
523    let expanded_paths = fts::expand_paths(paths);
524    let path_patterns = fts::compile_patterns(&expanded_paths)?;
525    let has_path_filters = !expanded_paths.is_empty();
526    let fetch_limit = if has_path_filters {
527        fts::FILTERED_FETCH_CAP
528    } else {
529        ((offset + limit) * 3).max(200)
530    };
531    let all_results = fts::search_content_visible(
532        &mut conn,
533        query,
534        ctx,
535        language,
536        &expanded_paths,
537        fetch_limit,
538    );
539    let cap_hint = (has_path_filters && all_results.len() >= fts::FILTERED_FETCH_CAP)
540        .then(filtered_fetch_cap_hint);
541    let path_hint =
542        fts::path_filter_requires_post_filter(&expanded_paths).then(path_filter_post_filter_hint);
543    let hint = combine_hints(cap_hint, path_hint);
544    let all_results: Vec<_> = all_results
545        .into_iter()
546        .filter(|r| {
547            language.is_none_or(|lang| r.language.as_deref() == Some(lang))
548                && path_matches_filters(&path_patterns, &r.file_path)
549                && scope::current_indexed_path_is_valid(&mut conn, ctx, &r.file_path)
550        })
551        .collect();
552    let total = if has_path_filters {
553        all_results.len()
554    } else {
555        fts::count_content_visible(&mut conn, query, ctx, language, &expanded_paths)
556    };
557    let results: Vec<_> = all_results.into_iter().skip(offset).take(limit).collect();
558
559    print_empty_diagnostic(ctx, results.is_empty(), offset, total);
560
561    match format {
562        Format::Json => output::print_json(&PagedResponse {
563            project_id: ctx.project_id.clone(),
564            total,
565            offset,
566            limit,
567            results,
568            hint,
569        }),
570        Format::Text => {
571            print_search_warning(ctx, hint.as_deref());
572            let lines = results
573                .iter()
574                .map(|r| {
575                    format!(
576                        "{}:{}-{} {}",
577                        r.file_path,
578                        r.line_start,
579                        r.line_end,
580                        compact_snippet(&r.snippet)
581                    )
582                })
583                .collect::<Vec<_>>();
584            if !lines.is_empty() {
585                output::print_text(&lines.join("\n"))?;
586            }
587            if total > offset + results.len() {
588                print_pagination_hint(total, offset, results.len());
589            }
590            Ok(())
591        }
592    }
593}
594
595fn exact_tier(query: &str, symbol: &Symbol) -> u8 {
596    if symbol.name == query || symbol.qualified_name == query {
597        0
598    } else if symbol.name.eq_ignore_ascii_case(query)
599        || symbol.qualified_name.eq_ignore_ascii_case(query)
600    {
601        1
602    } else {
603        2
604    }
605}
606
607fn exact_tier_score(query: &str, symbol: &Symbol) -> f64 {
608    match exact_tier(query, symbol) {
609        0 => 1.0,
610        1 => 0.9,
611        _ => 0.5,
612    }
613}
614
615fn final_rank_score(query: &str, symbol: &Symbol, rrf_score: f64) -> f64 {
616    exact_tier_score(query, symbol) + rrf_score
617}
618
619fn symbol_matches_filters(
620    conn: &mut postgres::Client,
621    ctx: &Context,
622    symbol: &Symbol,
623    kind: Option<&str>,
624    language: Option<&str>,
625    path_patterns: &[glob::Pattern],
626) -> bool {
627    kind.is_none_or(|k| symbol.kind == k)
628        && language.is_none_or(|lang| symbol.language == lang)
629        && path_matches_filters(path_patterns, &symbol.file_path)
630        && scope::current_indexed_path_is_valid(conn, ctx, &symbol.file_path)
631}
632
633fn search_result_matches_filters(
634    conn: &mut postgres::Client,
635    ctx: &Context,
636    result: &SearchResult,
637    language: Option<&str>,
638    path_patterns: &[glob::Pattern],
639) -> bool {
640    language.is_none_or(|lang| result.language == lang)
641        && path_matches_filters(path_patterns, &result.file_path)
642        && scope::current_indexed_path_is_valid(conn, ctx, &result.file_path)
643}
644
645fn path_matches_filters(path_patterns: &[glob::Pattern], file_path: &str) -> bool {
646    path_patterns.is_empty() || path_patterns.iter().any(|pat| pat.matches(file_path))
647}
648
649fn filtered_fetch_cap_hint() -> String {
650    format!(
651        "Path-filtered search hit the fetch cap of {}; refine the query or paths for complete totals.",
652        fts::FILTERED_FETCH_CAP
653    )
654}
655
656fn path_filter_post_filter_hint() -> String {
657    "Some path filters cannot be pushed into SQL; results were post-filtered after a broader fetch."
658        .to_string()
659}
660
661fn visible_search_degraded_hint() -> String {
662    "Visible-project filtering failed; results may be incomplete.".to_string()
663}
664
665fn literal_query_hint(query: &str) -> Option<String> {
666    literal_like_query(query).then(|| LITERAL_QUERY_HINT.to_string())
667}
668
669fn literal_like_query(query: &str) -> bool {
670    let query = query.trim();
671    if query.is_empty() {
672        return false;
673    }
674
675    contains_quoted_literal(query)
676        || contains_call_site_syntax(query)
677        || contains_path_separator(query)
678        || is_dotted_literal(query)
679}
680
681fn contains_quoted_literal(query: &str) -> bool {
682    query.contains('"')
683        || query.contains('`')
684        || (query.starts_with('\'') && query.ends_with('\'') && query.len() > 1)
685}
686
687fn contains_call_site_syntax(query: &str) -> bool {
688    query.char_indices().any(|(idx, ch)| {
689        if ch != '(' || idx == 0 {
690            return false;
691        }
692
693        query[..idx]
694            .chars()
695            .next_back()
696            .is_some_and(|prev| prev.is_ascii_alphanumeric() || matches!(prev, '_' | '.' | ':'))
697    })
698}
699
700fn contains_path_separator(query: &str) -> bool {
701    query.contains('/') || query.contains('\\')
702}
703
704fn is_dotted_literal(query: &str) -> bool {
705    if query.chars().any(char::is_whitespace) || !query.contains('.') {
706        return false;
707    }
708
709    query
710        .split('.')
711        .all(|part| !part.is_empty() && part.chars().all(is_dotted_literal_char))
712}
713
714fn is_dotted_literal_char(ch: char) -> bool {
715    ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-')
716}
717
718fn combine_hints(first: Option<String>, second: Option<String>) -> Option<String> {
719    match (first, second) {
720        (Some(first), Some(second)) => Some(format!("{first} {second}")),
721        (Some(first), None) => Some(first),
722        (None, Some(second)) => Some(second),
723        (None, None) => None,
724    }
725}
726
727fn print_search_warning(ctx: &Context, hint: Option<&str>) {
728    if let Some(hint) = hint
729        && !ctx.quiet
730    {
731        eprintln!("warning: {hint}");
732    }
733}
734
735fn format_symbol_lookup_text(symbol: &Symbol) -> String {
736    let mut line = format!(
737        "{}:{}-{} [{}] {} id={}",
738        symbol.file_path,
739        symbol.line_start,
740        symbol.line_end,
741        symbol.kind,
742        symbol.qualified_name,
743        symbol.id
744    );
745    if let Some(sig) = symbol.signature.as_deref().filter(|sig| !sig.is_empty()) {
746        line.push_str(" sig=");
747        line.push_str(sig);
748    }
749    line
750}
751
752fn compact_snippet(snippet: &str) -> String {
753    snippet.split_whitespace().collect::<Vec<_>>().join(" ")
754}
755
756fn print_empty_diagnostic(ctx: &Context, is_empty: bool, offset: usize, total: usize) {
757    if !is_empty || ctx.quiet {
758        return;
759    }
760    if offset == 0 && !crate::project::has_identity_file(&ctx.project_root) {
761        eprintln!("No index found for this project. Run `gcode index` first.");
762    } else if offset > 0 {
763        eprintln!("No results at offset {offset} (total {total})");
764    } else {
765        eprintln!("No results.");
766    }
767}
768
769fn print_pagination_hint(total: usize, offset: usize, result_count: usize) {
770    if total > offset + result_count {
771        eprintln!(
772            "-- {} of {} results (use --offset {} for more)",
773            result_count,
774            total,
775            offset + result_count
776        );
777    }
778}
779
780#[cfg(test)]
781mod tests {
782    use super::*;
783
784    fn symbol(file_path: &str, kind: &str, language: &str) -> Symbol {
785        Symbol {
786            id: "sym-1".to_string(),
787            project_id: "proj".to_string(),
788            file_path: file_path.to_string(),
789            name: "outline".to_string(),
790            qualified_name: "outline".to_string(),
791            kind: kind.to_string(),
792            language: language.to_string(),
793            byte_start: 0,
794            byte_end: 10,
795            line_start: 1,
796            line_end: 2,
797            signature: None,
798            docstring: None,
799            parent_symbol_id: None,
800            content_hash: String::new(),
801            summary: None,
802            created_at: String::new(),
803            updated_at: String::new(),
804        }
805    }
806
807    #[test]
808    fn symbol_filter_rejects_language_kind_path_and_missing_disk_file() {
809        let tmp = tempfile::tempdir().expect("tempdir");
810        let src = tmp.path().join("src");
811        std::fs::create_dir_all(&src).expect("create src");
812        std::fs::write(src.join("lib.rs"), "fn outline() {}").expect("write file");
813        let pattern = glob::Pattern::new("src/*.rs").expect("glob");
814        let sym = symbol("src/lib.rs", "function", "rust");
815
816        assert!(Some("function").is_none_or(|k| sym.kind == k));
817        assert!(Some("rust").is_none_or(|lang| sym.language == lang));
818        assert!(Some(&pattern).is_none_or(|pat| pat.matches(&sym.file_path)));
819    }
820
821    #[test]
822    fn exact_tier_prefers_case_sensitive_match() {
823        assert_eq!(
824            exact_tier("outline", &symbol("src/lib.rs", "function", "rust")),
825            0
826        );
827
828        let mut case_variant = symbol("src/lib.rs", "function", "rust");
829        case_variant.name = "Outline".to_string();
830        case_variant.qualified_name = "Outline".to_string();
831        assert_eq!(exact_tier("outline", &case_variant), 1);
832
833        case_variant.name = "outline_helper".to_string();
834        case_variant.qualified_name = "outline_helper".to_string();
835        assert_eq!(exact_tier("outline", &case_variant), 2);
836    }
837
838    #[test]
839    fn final_score_preserves_display_tier_before_rrf_score() {
840        let exact = symbol("src/lib.rs", "function", "rust");
841        let mut fuzzy = symbol("src/other.rs", "function", "rust");
842        fuzzy.name = "outline_helper".to_string();
843        fuzzy.qualified_name = "outline_helper".to_string();
844
845        assert!(
846            final_rank_score("outline", &exact, 0.01) > final_rank_score("outline", &fuzzy, 0.08)
847        );
848    }
849
850    #[test]
851    fn combines_fetch_cap_and_path_post_filter_hints() {
852        let hint = combine_hints(
853            Some(filtered_fetch_cap_hint()),
854            Some(path_filter_post_filter_hint()),
855        )
856        .expect("hint");
857
858        assert!(hint.contains("fetch cap"));
859        assert!(hint.contains("post-filtered"));
860    }
861
862    #[test]
863    fn literal_query_hint_detects_literal_like_queries() {
864        for query in [
865            "spawn_ui_server(",
866            "config.ui.mode",
867            "\"quoted string\"",
868            "src/foo.rs",
869        ] {
870            let hint = literal_query_hint(query).expect("literal hint");
871            assert!(hint.contains("gcode grep"));
872            assert!(hint.contains("search-content"));
873        }
874    }
875
876    #[test]
877    fn literal_query_hint_skips_natural_language_queries() {
878        assert!(literal_query_hint("database connection pool").is_none());
879    }
880
881    #[test]
882    fn content_snippet_compaction_collapses_whitespace() {
883        assert_eq!(
884            compact_snippet("  first line\n    second\tline\r\nthird  "),
885            "first line second line third"
886        );
887    }
888}