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