Skip to main content

gobby_code/commands/
search.rs

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