Skip to main content

gobby_code/commands/
search.rs

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    // Fetch generously for RRF. Total is a best-effort estimate bounded by fetch_limit
27    // per source — exact counts aren't feasible because RRF merges results from BM25,
28    // Qdrant, and FalkorDB with deduplication, so source counts aren't additive.
29    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    // Source 1: BM25 (with LIKE fallback)
43    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    // Source 2: Semantic search (Qdrant + embeddings)
66    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    // Source 3: Graph boost (FalkorDB callers + usages of the resolved query symbol)
70    let graph_ids = if options.with_graph {
71        graph_boost::graph_boost(ctx, query)
72    } else {
73        Vec::new()
74    };
75
76    // Source 4: Graph expand — seed from top BM25+semantic results, expand neighborhood
77    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    // Build RRF sources (only include non-empty sources)
85    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    // Build symbol cache from exact and BM25 results.
103    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    // Resolve ALL results first so total reflects resolvable symbols only
112    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
428/// Extract unique symbol IDs from the top BM25 and semantic results for graph expansion.
429fn 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    // Top N from BM25 (already have Symbol structs with IDs)
438    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    // Top N from semantic (already canonical symbol IDs)
445    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,
516                    r.line_start,
517                    r.line_end,
518                    compact_snippet(&r.snippet)
519                );
520            }
521            if total > offset + results.len() {
522                print_pagination_hint(total, offset, results.len());
523            }
524            Ok(())
525        }
526    }
527}
528
529fn exact_tier(query: &str, symbol: &Symbol) -> u8 {
530    if symbol.name == query || symbol.qualified_name == query {
531        0
532    } else if symbol.name.eq_ignore_ascii_case(query)
533        || symbol.qualified_name.eq_ignore_ascii_case(query)
534    {
535        1
536    } else {
537        2
538    }
539}
540
541fn exact_tier_score(query: &str, symbol: &Symbol) -> f64 {
542    match exact_tier(query, symbol) {
543        0 => 1.0,
544        1 => 0.9,
545        _ => 0.5,
546    }
547}
548
549fn final_rank_score(query: &str, symbol: &Symbol, rrf_score: f64) -> f64 {
550    exact_tier_score(query, symbol) + rrf_score
551}
552
553fn fetch_symbol_by_id(conn: &mut postgres::Client, symbol_id: &str) -> Option<Symbol> {
554    let columns = db::symbol_select_columns("");
555    conn.query_opt(
556        &format!("SELECT {columns} FROM code_symbols WHERE id = $1"),
557        &[&symbol_id],
558    )
559    .ok()
560    .flatten()
561    .and_then(|row| Symbol::from_row(&row).ok())
562}
563
564fn symbol_matches_filters(
565    conn: &mut postgres::Client,
566    ctx: &Context,
567    symbol: &Symbol,
568    kind: Option<&str>,
569    language: Option<&str>,
570    path_patterns: &[glob::Pattern],
571) -> bool {
572    kind.is_none_or(|k| symbol.kind == k)
573        && language.is_none_or(|lang| symbol.language == lang)
574        && path_matches_filters(path_patterns, &symbol.file_path)
575        && scope::current_indexed_path_is_valid(conn, ctx, &symbol.file_path)
576}
577
578fn search_result_matches_filters(
579    conn: &mut postgres::Client,
580    ctx: &Context,
581    result: &SearchResult,
582    language: Option<&str>,
583    path_patterns: &[glob::Pattern],
584) -> bool {
585    language.is_none_or(|lang| result.language == lang)
586        && path_matches_filters(path_patterns, &result.file_path)
587        && scope::current_indexed_path_is_valid(conn, ctx, &result.file_path)
588}
589
590fn path_matches_filters(path_patterns: &[glob::Pattern], file_path: &str) -> bool {
591    path_patterns.is_empty() || path_patterns.iter().any(|pat| pat.matches(file_path))
592}
593
594fn filtered_fetch_cap_hint() -> String {
595    format!(
596        "Path-filtered search hit the fetch cap of {}; refine the query or paths for complete totals.",
597        fts::FILTERED_FETCH_CAP
598    )
599}
600
601fn path_filter_fallback_hint() -> String {
602    "Some path filters cannot be pushed into SQL; results were post-filtered after a broader fetch."
603        .to_string()
604}
605
606fn combine_hints(first: Option<String>, second: Option<String>) -> Option<String> {
607    match (first, second) {
608        (Some(first), Some(second)) => Some(format!("{first} {second}")),
609        (Some(first), None) => Some(first),
610        (None, Some(second)) => Some(second),
611        (None, None) => None,
612    }
613}
614
615fn print_search_warning(ctx: &Context, hint: Option<&str>) {
616    if let Some(hint) = hint
617        && !ctx.quiet
618    {
619        eprintln!("warning: {hint}");
620    }
621}
622
623fn format_symbol_lookup_text(symbol: &Symbol) -> String {
624    let mut line = format!(
625        "{}:{}-{} [{}] {} id={}",
626        symbol.file_path,
627        symbol.line_start,
628        symbol.line_end,
629        symbol.kind,
630        symbol.qualified_name,
631        symbol.id
632    );
633    if let Some(sig) = symbol.signature.as_deref().filter(|sig| !sig.is_empty()) {
634        line.push_str(" sig=");
635        line.push_str(sig);
636    }
637    line
638}
639
640fn compact_snippet(snippet: &str) -> String {
641    snippet.split_whitespace().collect::<Vec<_>>().join(" ")
642}
643
644fn print_empty_diagnostic(ctx: &Context, is_empty: bool, offset: usize, total: usize) {
645    if !is_empty || ctx.quiet {
646        return;
647    }
648    if offset == 0 && !crate::project::has_identity_file(&ctx.project_root) {
649        eprintln!("No index found for this project. Run `gcode index` first.");
650    } else if offset > 0 {
651        eprintln!("No results at offset {offset} (total {total})");
652    } else {
653        eprintln!("No results.");
654    }
655}
656
657fn print_pagination_hint(total: usize, offset: usize, result_count: usize) {
658    if total > offset + result_count {
659        eprintln!(
660            "-- {} of {} results (use --offset {} for more)",
661            result_count,
662            total,
663            offset + result_count
664        );
665    }
666}
667
668#[cfg(test)]
669mod tests {
670    use super::*;
671
672    fn symbol(file_path: &str, kind: &str, language: &str) -> Symbol {
673        Symbol {
674            id: "sym-1".to_string(),
675            project_id: "proj".to_string(),
676            file_path: file_path.to_string(),
677            name: "outline".to_string(),
678            qualified_name: "outline".to_string(),
679            kind: kind.to_string(),
680            language: language.to_string(),
681            byte_start: 0,
682            byte_end: 10,
683            line_start: 1,
684            line_end: 2,
685            signature: None,
686            docstring: None,
687            parent_symbol_id: None,
688            content_hash: String::new(),
689            summary: None,
690            created_at: String::new(),
691            updated_at: String::new(),
692        }
693    }
694
695    #[test]
696    fn symbol_filter_rejects_language_kind_path_and_missing_disk_file() {
697        let tmp = tempfile::tempdir().expect("tempdir");
698        let src = tmp.path().join("src");
699        std::fs::create_dir_all(&src).expect("create src");
700        std::fs::write(src.join("lib.rs"), "fn outline() {}").expect("write file");
701        let pattern = glob::Pattern::new("src/*.rs").expect("glob");
702        let sym = symbol("src/lib.rs", "function", "rust");
703
704        assert!(Some("function").is_none_or(|k| sym.kind == k));
705        assert!(Some("rust").is_none_or(|lang| sym.language == lang));
706        assert!(Some(&pattern).is_none_or(|pat| pat.matches(&sym.file_path)));
707    }
708
709    #[test]
710    fn exact_tier_prefers_case_sensitive_match() {
711        assert_eq!(
712            exact_tier("outline", &symbol("src/lib.rs", "function", "rust")),
713            0
714        );
715
716        let mut case_variant = symbol("src/lib.rs", "function", "rust");
717        case_variant.name = "Outline".to_string();
718        case_variant.qualified_name = "Outline".to_string();
719        assert_eq!(exact_tier("outline", &case_variant), 1);
720
721        case_variant.name = "outline_helper".to_string();
722        case_variant.qualified_name = "outline_helper".to_string();
723        assert_eq!(exact_tier("outline", &case_variant), 2);
724    }
725
726    #[test]
727    fn final_score_preserves_display_tier_before_rrf_score() {
728        let exact = symbol("src/lib.rs", "function", "rust");
729        let mut fuzzy = symbol("src/other.rs", "function", "rust");
730        fuzzy.name = "outline_helper".to_string();
731        fuzzy.qualified_name = "outline_helper".to_string();
732
733        assert!(
734            final_rank_score("outline", &exact, 0.01) > final_rank_score("outline", &fuzzy, 0.08)
735        );
736    }
737
738    #[test]
739    fn combines_fetch_cap_and_path_fallback_hints() {
740        let hint = combine_hints(
741            Some(filtered_fetch_cap_hint()),
742            Some(path_filter_fallback_hint()),
743        )
744        .expect("hint");
745
746        assert!(hint.contains("fetch cap"));
747        assert!(hint.contains("post-filtered"));
748    }
749
750    #[test]
751    fn content_snippet_compaction_collapses_whitespace() {
752        assert_eq!(
753            compact_snippet("  first line\n    second\tline\r\nthird  "),
754            "first line second line third"
755        );
756    }
757}