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 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 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 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 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 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 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 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 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
498fn 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 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 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}