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