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};
10
11pub struct SearchOptions<'a> {
12 pub limit: usize,
13 pub offset: usize,
14 pub kind: Option<&'a str>,
15 pub language: Option<&'a str>,
16 pub paths: &'a [String],
17 pub format: Format,
18 pub with_graph: bool,
19}
20
21pub fn search(ctx: &Context, query: &str, options: SearchOptions<'_>) -> anyhow::Result<()> {
22 let mut conn = db::connect_readonly(&ctx.database_url)?;
23 let expanded_paths = fts::expand_paths(options.paths);
24 let path_patterns = fts::compile_patterns(&expanded_paths)?;
25
26 let fetch_limit = ((options.offset + options.limit) * 3).max(200);
30
31 let exact_results = fts::search_symbols_exact_first(
32 &mut conn,
33 query,
34 &ctx.project_id,
35 options.kind,
36 options.language,
37 &expanded_paths,
38 fetch_limit,
39 );
40 let exact_ids: Vec<String> = exact_results.iter().map(|s| s.id.clone()).collect();
41
42 let mut fts_results = fts::search_symbols_fts(
44 &mut conn,
45 query,
46 &ctx.project_id,
47 options.kind,
48 options.language,
49 &expanded_paths,
50 fetch_limit,
51 );
52 if fts_results.is_empty() {
53 fts_results = fts::search_symbols_by_name(
54 &mut conn,
55 query,
56 &ctx.project_id,
57 options.kind,
58 options.language,
59 &expanded_paths,
60 fetch_limit,
61 );
62 }
63 let fts_ids: Vec<String> = fts_results.iter().map(|s| s.id.clone()).collect();
64
65 let semantic_results = semantic::semantic_search(ctx, query, fetch_limit);
67 let semantic_ids: Vec<String> = semantic_results.iter().map(|(id, _)| id.clone()).collect();
68
69 let graph_ids = if options.with_graph {
71 graph_boost::graph_boost(ctx, query)
72 } else {
73 Vec::new()
74 };
75
76 let seed_ids = extract_seed_ids(&fts_results, &semantic_ids, 5);
78 let expand_ids = if options.with_graph {
79 graph_boost::graph_expand(ctx, &seed_ids)
80 } else {
81 Vec::new()
82 };
83
84 let mut sources: Vec<(&str, Vec<String>)> = Vec::new();
86 if !exact_ids.is_empty() {
87 sources.push(("exact", exact_ids));
88 }
89 sources.push(("fts", fts_ids));
90 if !semantic_ids.is_empty() {
91 sources.push(("semantic", semantic_ids));
92 }
93 if !graph_ids.is_empty() {
94 sources.push(("graph", graph_ids));
95 }
96 if !expand_ids.is_empty() {
97 sources.push(("graph_expand", expand_ids));
98 }
99
100 let merged = rrf::merge(sources);
101
102 let mut symbol_cache: HashMap<String, Symbol> = HashMap::new();
104 for sym in exact_results {
105 symbol_cache.insert(sym.id.clone(), sym);
106 }
107 for sym in fts_results {
108 symbol_cache.insert(sym.id.clone(), sym);
109 }
110
111 let mut all_resolved: Vec<(Symbol, f64, Vec<String>)> = Vec::new();
113 for (sym_id, score, source_names) in &merged {
114 let sym = symbol_cache.get(sym_id).cloned().or_else(|| {
115 let columns = db::symbol_select_columns("");
116 conn.query_opt(
117 &format!("SELECT {columns} FROM code_symbols WHERE id = $1"),
118 &[sym_id],
119 )
120 .ok()
121 .flatten()
122 .and_then(|row| Symbol::from_row(&row).ok())
123 });
124
125 if let Some(s) = sym
126 && symbol_matches_filters(
127 &mut conn,
128 ctx,
129 &s,
130 options.kind,
131 options.language,
132 &path_patterns,
133 )
134 {
135 all_resolved.push((s, *score, source_names.clone()));
136 }
137 }
138
139 all_resolved.sort_by(|a, b| {
140 exact_tier(query, &a.0)
141 .cmp(&exact_tier(query, &b.0))
142 .then_with(|| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal))
143 .then_with(|| a.0.file_path.cmp(&b.0.file_path))
144 .then_with(|| a.0.line_start.cmp(&b.0.line_start))
145 });
146
147 let total = all_resolved.len();
148 let results: Vec<_> = all_resolved
149 .into_iter()
150 .skip(options.offset)
151 .take(options.limit)
152 .map(|(s, rrf_score, sources)| {
153 let mut result = s.to_brief();
154 result.score = final_rank_score(query, &s, rrf_score);
155 result.rrf_score = Some(rrf_score);
156 result.sources = Some(sources);
157 result
158 })
159 .collect();
160
161 print_empty_diagnostic(ctx, results.is_empty(), options.offset, total);
162 let hint = fts::path_filter_falls_back(&expanded_paths).then(path_filter_fallback_hint);
163
164 match options.format {
165 Format::Json => output::print_json(&PagedResponse {
166 project_id: ctx.project_id.clone(),
167 total,
168 offset: options.offset,
169 limit: options.limit,
170 results,
171 hint,
172 }),
173 Format::Text => {
174 print_search_warning(ctx, hint.as_deref());
175 for r in &results {
176 let sources = r.sources.as_ref().map(|s| s.join("+")).unwrap_or_default();
177 println!(
178 "{}:{} [{}] {} (score: {:.4}, via: {})",
179 r.file_path, r.line_start, r.kind, r.qualified_name, r.score, sources
180 );
181 }
182 print_pagination_hint(total, options.offset, results.len());
183 Ok(())
184 }
185 }
186}
187
188pub fn search_symbol(ctx: &Context, query: &str, options: SearchOptions<'_>) -> anyhow::Result<()> {
189 let mut conn = db::connect_readonly(&ctx.database_url)?;
190 let expanded_paths = fts::expand_paths(options.paths);
191 let path_patterns = fts::compile_patterns(&expanded_paths)?;
192 let fetch_limit = ((options.offset + options.limit) * 3).max(200);
193 let exact_results = fts::search_symbols_exact_first(
194 &mut conn,
195 query,
196 &ctx.project_id,
197 options.kind,
198 options.language,
199 &expanded_paths,
200 fetch_limit,
201 );
202
203 if options.with_graph {
204 return search_symbol_with_graph(
205 ctx,
206 query,
207 options,
208 &mut conn,
209 &path_patterns,
210 &expanded_paths,
211 exact_results,
212 );
213 }
214
215 let all_results: Vec<_> = exact_results
216 .into_iter()
217 .filter(|s| {
218 symbol_matches_filters(
219 &mut conn,
220 ctx,
221 s,
222 options.kind,
223 options.language,
224 &path_patterns,
225 )
226 })
227 .collect();
228 let total = all_results.len();
229 let results: Vec<_> = all_results
230 .into_iter()
231 .skip(options.offset)
232 .take(options.limit)
233 .collect();
234
235 print_empty_diagnostic(ctx, results.is_empty(), options.offset, total);
236 let hint = fts::path_filter_falls_back(&expanded_paths).then(path_filter_fallback_hint);
237
238 match options.format {
239 Format::Json => {
240 let results: Vec<SearchResult> = results
241 .iter()
242 .map(|s| {
243 let mut result = s.to_brief();
244 result.score = exact_tier_score(query, s);
245 result
246 })
247 .collect();
248 output::print_json(&PagedResponse {
249 project_id: ctx.project_id.clone(),
250 total,
251 offset: options.offset,
252 limit: options.limit,
253 results,
254 hint,
255 })
256 }
257 Format::Text => {
258 print_search_warning(ctx, hint.as_deref());
259 for s in &results {
260 println!("{}", format_symbol_lookup_text(s));
261 }
262 print_pagination_hint(total, options.offset, results.len());
263 Ok(())
264 }
265 }
266}
267
268fn search_symbol_with_graph(
269 ctx: &Context,
270 query: &str,
271 options: SearchOptions<'_>,
272 conn: &mut postgres::Client,
273 path_patterns: &[glob::Pattern],
274 expanded_paths: &[String],
275 exact_results: Vec<Symbol>,
276) -> anyhow::Result<()> {
277 let exact_ids: Vec<String> = exact_results.iter().map(|s| s.id.clone()).collect();
278 let seed_ids: Vec<String> = exact_ids.iter().take(5).cloned().collect();
279 let graph_ids = graph_boost::graph_boost(ctx, query);
280 let expand_ids = graph_boost::graph_expand(ctx, &seed_ids);
281
282 let mut sources: Vec<(&str, Vec<String>)> = Vec::new();
283 if !exact_ids.is_empty() {
284 sources.push(("exact", exact_ids));
285 }
286 if !graph_ids.is_empty() {
287 sources.push(("graph", graph_ids));
288 }
289 if !expand_ids.is_empty() {
290 sources.push(("graph_expand", expand_ids));
291 }
292
293 let merged = rrf::merge(sources);
294 let mut symbol_cache: HashMap<String, Symbol> = exact_results
295 .into_iter()
296 .map(|sym| (sym.id.clone(), sym))
297 .collect();
298 let mut all_resolved: Vec<(Symbol, f64, Vec<String>)> = Vec::new();
299 for (sym_id, rrf_score, source_names) in &merged {
300 let sym = symbol_cache
301 .remove(sym_id)
302 .or_else(|| fetch_symbol_by_id(conn, sym_id));
303
304 if let Some(s) = sym
305 && symbol_matches_filters(conn, ctx, &s, options.kind, options.language, path_patterns)
306 {
307 all_resolved.push((s, *rrf_score, source_names.clone()));
308 }
309 }
310
311 all_resolved.sort_by(|a, b| {
312 exact_tier(query, &a.0)
313 .cmp(&exact_tier(query, &b.0))
314 .then_with(|| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal))
315 .then_with(|| a.0.file_path.cmp(&b.0.file_path))
316 .then_with(|| a.0.line_start.cmp(&b.0.line_start))
317 });
318
319 let total = all_resolved.len();
320 let results: Vec<_> = all_resolved
321 .into_iter()
322 .skip(options.offset)
323 .take(options.limit)
324 .map(|(s, rrf_score, sources)| {
325 let mut result = s.to_brief();
326 result.score = final_rank_score(query, &s, rrf_score);
327 result.rrf_score = Some(rrf_score);
328 result.sources = Some(sources);
329 result
330 })
331 .collect();
332
333 print_empty_diagnostic(ctx, results.is_empty(), options.offset, total);
334 let hint = fts::path_filter_falls_back(expanded_paths).then(path_filter_fallback_hint);
335
336 match options.format {
337 Format::Json => output::print_json(&PagedResponse {
338 project_id: ctx.project_id.clone(),
339 total,
340 offset: options.offset,
341 limit: options.limit,
342 results,
343 hint,
344 }),
345 Format::Text => {
346 print_search_warning(ctx, hint.as_deref());
347 for r in &results {
348 let sources = r.sources.as_ref().map(|s| s.join("+")).unwrap_or_default();
349 println!(
350 "{}:{} [{}] {} (score: {:.4}, via: {})",
351 r.file_path, r.line_start, r.kind, r.qualified_name, r.score, sources
352 );
353 }
354 print_pagination_hint(total, options.offset, results.len());
355 Ok(())
356 }
357 }
358}
359
360pub fn search_text(
361 ctx: &Context,
362 query: &str,
363 limit: usize,
364 offset: usize,
365 language: Option<&str>,
366 paths: &[String],
367 format: Format,
368) -> anyhow::Result<()> {
369 let mut conn = db::connect_readonly(&ctx.database_url)?;
370 let expanded_paths = fts::expand_paths(paths);
371 let path_patterns = fts::compile_patterns(&expanded_paths)?;
372 let has_path_filters = !expanded_paths.is_empty();
373 let fetch_limit = if has_path_filters {
374 fts::FILTERED_FETCH_CAP
375 } else {
376 ((offset + limit) * 3).max(200)
377 };
378 let all_results = fts::search_text(
379 &mut conn,
380 query,
381 &ctx.project_id,
382 language,
383 &expanded_paths,
384 fetch_limit,
385 );
386 let cap_hint = (has_path_filters && all_results.len() >= fts::FILTERED_FETCH_CAP)
387 .then(filtered_fetch_cap_hint);
388 let path_hint = fts::path_filter_falls_back(&expanded_paths).then(path_filter_fallback_hint);
389 let hint = combine_hints(cap_hint, path_hint);
390 let all_results: Vec<_> = all_results
391 .into_iter()
392 .filter(|r| search_result_matches_filters(&mut conn, ctx, r, language, &path_patterns))
393 .collect();
394 let total = if has_path_filters {
395 all_results.len()
396 } else {
397 fts::count_text(&mut conn, query, &ctx.project_id, language, &expanded_paths)
398 };
399 let results: Vec<_> = all_results.into_iter().skip(offset).take(limit).collect();
400
401 print_empty_diagnostic(ctx, results.is_empty(), offset, total);
402
403 match format {
404 Format::Json => output::print_json(&PagedResponse {
405 project_id: ctx.project_id.clone(),
406 total,
407 offset,
408 limit,
409 results,
410 hint,
411 }),
412 Format::Text => {
413 print_search_warning(ctx, hint.as_deref());
414 for r in &results {
415 println!(
416 "{}:{} [{}] {}",
417 r.file_path, r.line_start, r.kind, r.qualified_name
418 );
419 }
420 if total > offset + results.len() {
421 print_pagination_hint(total, offset, results.len());
422 }
423 Ok(())
424 }
425 }
426}
427
428fn extract_seed_ids(
430 fts_results: &[Symbol],
431 semantic_ids: &[String],
432 per_source: usize,
433) -> Vec<String> {
434 let mut ids = Vec::new();
435 let mut seen = HashSet::new();
436
437 for sym in fts_results.iter().take(per_source) {
439 if !sym.id.is_empty() && seen.insert(sym.id.clone()) {
440 ids.push(sym.id.clone());
441 }
442 }
443
444 for id in semantic_ids.iter().take(per_source) {
446 if !id.is_empty() && seen.insert(id.clone()) {
447 ids.push(id.clone());
448 }
449 }
450
451 ids
452}
453
454pub fn search_content(
455 ctx: &Context,
456 query: &str,
457 limit: usize,
458 offset: usize,
459 language: Option<&str>,
460 paths: &[String],
461 format: Format,
462) -> anyhow::Result<()> {
463 let mut conn = db::connect_readonly(&ctx.database_url)?;
464 let expanded_paths = fts::expand_paths(paths);
465 let path_patterns = fts::compile_patterns(&expanded_paths)?;
466 let has_path_filters = !expanded_paths.is_empty();
467 let fetch_limit = if has_path_filters {
468 fts::FILTERED_FETCH_CAP
469 } else {
470 ((offset + limit) * 3).max(200)
471 };
472 let all_results = fts::search_content(
473 &mut conn,
474 query,
475 &ctx.project_id,
476 language,
477 &expanded_paths,
478 fetch_limit,
479 );
480 let cap_hint = (has_path_filters && all_results.len() >= fts::FILTERED_FETCH_CAP)
481 .then(filtered_fetch_cap_hint);
482 let path_hint = fts::path_filter_falls_back(&expanded_paths).then(path_filter_fallback_hint);
483 let hint = combine_hints(cap_hint, path_hint);
484 let all_results: Vec<_> = all_results
485 .into_iter()
486 .filter(|r| {
487 language.is_none_or(|lang| r.language.as_deref() == Some(lang))
488 && path_matches_filters(&path_patterns, &r.file_path)
489 && scope::current_indexed_path_is_valid(&mut conn, ctx, &r.file_path)
490 })
491 .collect();
492 let total = if has_path_filters {
493 all_results.len()
494 } else {
495 fts::count_content(&mut conn, query, &ctx.project_id, language, &expanded_paths)
496 };
497 let results: Vec<_> = all_results.into_iter().skip(offset).take(limit).collect();
498
499 print_empty_diagnostic(ctx, results.is_empty(), offset, total);
500
501 match format {
502 Format::Json => output::print_json(&PagedResponse {
503 project_id: ctx.project_id.clone(),
504 total,
505 offset,
506 limit,
507 results,
508 hint,
509 }),
510 Format::Text => {
511 print_search_warning(ctx, hint.as_deref());
512 for r in &results {
513 println!(
514 "{}:{}-{} {}",
515 r.file_path, r.line_start, r.line_end, r.snippet
516 );
517 }
518 if total > offset + results.len() {
519 print_pagination_hint(total, offset, results.len());
520 }
521 Ok(())
522 }
523 }
524}
525
526fn exact_tier(query: &str, symbol: &Symbol) -> u8 {
527 if symbol.name == query || symbol.qualified_name == query {
528 0
529 } else if symbol.name.eq_ignore_ascii_case(query)
530 || symbol.qualified_name.eq_ignore_ascii_case(query)
531 {
532 1
533 } else {
534 2
535 }
536}
537
538fn exact_tier_score(query: &str, symbol: &Symbol) -> f64 {
539 match exact_tier(query, symbol) {
540 0 => 1.0,
541 1 => 0.9,
542 _ => 0.5,
543 }
544}
545
546fn final_rank_score(query: &str, symbol: &Symbol, rrf_score: f64) -> f64 {
547 exact_tier_score(query, symbol) + rrf_score
548}
549
550fn fetch_symbol_by_id(conn: &mut postgres::Client, symbol_id: &str) -> Option<Symbol> {
551 let columns = db::symbol_select_columns("");
552 conn.query_opt(
553 &format!("SELECT {columns} FROM code_symbols WHERE id = $1"),
554 &[&symbol_id],
555 )
556 .ok()
557 .flatten()
558 .and_then(|row| Symbol::from_row(&row).ok())
559}
560
561fn symbol_matches_filters(
562 conn: &mut postgres::Client,
563 ctx: &Context,
564 symbol: &Symbol,
565 kind: Option<&str>,
566 language: Option<&str>,
567 path_patterns: &[glob::Pattern],
568) -> bool {
569 kind.is_none_or(|k| symbol.kind == k)
570 && language.is_none_or(|lang| symbol.language == lang)
571 && path_matches_filters(path_patterns, &symbol.file_path)
572 && scope::current_indexed_path_is_valid(conn, ctx, &symbol.file_path)
573}
574
575fn search_result_matches_filters(
576 conn: &mut postgres::Client,
577 ctx: &Context,
578 result: &SearchResult,
579 language: Option<&str>,
580 path_patterns: &[glob::Pattern],
581) -> bool {
582 language.is_none_or(|lang| result.language == lang)
583 && path_matches_filters(path_patterns, &result.file_path)
584 && scope::current_indexed_path_is_valid(conn, ctx, &result.file_path)
585}
586
587fn path_matches_filters(path_patterns: &[glob::Pattern], file_path: &str) -> bool {
588 path_patterns.is_empty() || path_patterns.iter().any(|pat| pat.matches(file_path))
589}
590
591fn filtered_fetch_cap_hint() -> String {
592 format!(
593 "Path-filtered search hit the fetch cap of {}; refine the query or paths for complete totals.",
594 fts::FILTERED_FETCH_CAP
595 )
596}
597
598fn path_filter_fallback_hint() -> String {
599 "Some path filters cannot be pushed into SQL; results were post-filtered after a broader fetch."
600 .to_string()
601}
602
603fn combine_hints(first: Option<String>, second: Option<String>) -> Option<String> {
604 match (first, second) {
605 (Some(first), Some(second)) => Some(format!("{first} {second}")),
606 (Some(first), None) => Some(first),
607 (None, Some(second)) => Some(second),
608 (None, None) => None,
609 }
610}
611
612fn print_search_warning(ctx: &Context, hint: Option<&str>) {
613 if let Some(hint) = hint
614 && !ctx.quiet
615 {
616 eprintln!("warning: {hint}");
617 }
618}
619
620fn format_symbol_lookup_text(symbol: &Symbol) -> String {
621 let mut line = format!(
622 "{}:{}-{} [{}] {} id={}",
623 symbol.file_path,
624 symbol.line_start,
625 symbol.line_end,
626 symbol.kind,
627 symbol.qualified_name,
628 symbol.id
629 );
630 if let Some(sig) = symbol.signature.as_deref().filter(|sig| !sig.is_empty()) {
631 line.push_str(" sig=");
632 line.push_str(sig);
633 }
634 line
635}
636
637fn print_empty_diagnostic(ctx: &Context, is_empty: bool, offset: usize, total: usize) {
638 if !is_empty || ctx.quiet {
639 return;
640 }
641 if offset == 0 && !crate::project::has_identity_file(&ctx.project_root) {
642 eprintln!("No index found for this project. Run `gcode index` first.");
643 } else if offset > 0 {
644 eprintln!("No results at offset {offset} (total {total})");
645 } else {
646 eprintln!("No results.");
647 }
648}
649
650fn print_pagination_hint(total: usize, offset: usize, result_count: usize) {
651 if total > offset + result_count {
652 eprintln!(
653 "-- {} of {} results (use --offset {} for more)",
654 result_count,
655 total,
656 offset + result_count
657 );
658 }
659}
660
661#[cfg(test)]
662mod tests {
663 use super::*;
664
665 fn symbol(file_path: &str, kind: &str, language: &str) -> Symbol {
666 Symbol {
667 id: "sym-1".to_string(),
668 project_id: "proj".to_string(),
669 file_path: file_path.to_string(),
670 name: "outline".to_string(),
671 qualified_name: "outline".to_string(),
672 kind: kind.to_string(),
673 language: language.to_string(),
674 byte_start: 0,
675 byte_end: 10,
676 line_start: 1,
677 line_end: 2,
678 signature: None,
679 docstring: None,
680 parent_symbol_id: None,
681 content_hash: String::new(),
682 summary: None,
683 created_at: String::new(),
684 updated_at: String::new(),
685 }
686 }
687
688 #[test]
689 fn symbol_filter_rejects_language_kind_path_and_missing_disk_file() {
690 let tmp = tempfile::tempdir().expect("tempdir");
691 let src = tmp.path().join("src");
692 std::fs::create_dir_all(&src).expect("create src");
693 std::fs::write(src.join("lib.rs"), "fn outline() {}").expect("write file");
694 let pattern = glob::Pattern::new("src/*.rs").expect("glob");
695 let sym = symbol("src/lib.rs", "function", "rust");
696
697 assert!(Some("function").is_none_or(|k| sym.kind == k));
698 assert!(Some("rust").is_none_or(|lang| sym.language == lang));
699 assert!(Some(&pattern).is_none_or(|pat| pat.matches(&sym.file_path)));
700 }
701
702 #[test]
703 fn exact_tier_prefers_case_sensitive_match() {
704 assert_eq!(
705 exact_tier("outline", &symbol("src/lib.rs", "function", "rust")),
706 0
707 );
708
709 let mut case_variant = symbol("src/lib.rs", "function", "rust");
710 case_variant.name = "Outline".to_string();
711 case_variant.qualified_name = "Outline".to_string();
712 assert_eq!(exact_tier("outline", &case_variant), 1);
713
714 case_variant.name = "outline_helper".to_string();
715 case_variant.qualified_name = "outline_helper".to_string();
716 assert_eq!(exact_tier("outline", &case_variant), 2);
717 }
718
719 #[test]
720 fn final_score_preserves_display_tier_before_rrf_score() {
721 let exact = symbol("src/lib.rs", "function", "rust");
722 let mut fuzzy = symbol("src/other.rs", "function", "rust");
723 fuzzy.name = "outline_helper".to_string();
724 fuzzy.qualified_name = "outline_helper".to_string();
725
726 assert!(
727 final_rank_score("outline", &exact, 0.01) > final_rank_score("outline", &fuzzy, 0.08)
728 );
729 }
730
731 #[test]
732 fn combines_fetch_cap_and_path_fallback_hints() {
733 let hint = combine_hints(
734 Some(filtered_fetch_cap_hint()),
735 Some(path_filter_fallback_hint()),
736 )
737 .expect("hint");
738
739 assert!(hint.contains("fetch cap"));
740 assert!(hint.contains("post-filtered"));
741 }
742}