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, 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}