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