Skip to main content

reference_query/search/
mod.rs

1//! Search — the staged ranking pipeline.
2//!
3//! Layers 1–3 (exact/prefix, abbreviation-aware fuzzy, path) over the index,
4//! scored by an additive, `--explain`-able scorer. Layers 4–5 (live scan,
5//! opportunistic extraction) and true streaming/early-exit arrive in phase 2;
6//! for now the candidate set is gathered once and ranked.
7
8mod score;
9
10pub use score::{Boosts, Feature, Scored, confidence, match_positions, match_quality, path_stem};
11
12use std::collections::{HashMap, HashSet};
13use std::path::Path;
14use std::time::{Instant, SystemTime, UNIX_EPOCH};
15
16use crate::store::{Store, SymbolRow};
17
18/// Per-layer cap on candidates pulled from the store before ranking. Exact and
19/// prefix matches are guaranteed in full (see `Store::search_candidates`); this
20/// only bounds the broad first-char-anchor and trigram-fuzzy recall layers.
21/// Scoring is linear and cheap, so this sits well under the latency budget.
22const CANDIDATE_LIMIT: usize = 8000;
23
24/// Sentinel repository id for live-scan (Layer 4) results — distinct from any
25/// real row id, and treated as "the current repo" so the boost applies.
26const LIVE_REPO_ID: i64 = -1;
27
28/// Boost for a symbol whose file you're actively changing on this branch.
29const BRANCH_FILE_BOOST: f64 = 180.0;
30/// Smaller boost for a symbol in a directory you're changing (a neighbor).
31const BRANCH_DIR_BOOST: f64 = 60.0;
32
33/// Files you're working on this branch — those that differ from the trunk —
34/// plus the directories holding them. Symbols in those files (or their
35/// directory neighbors) get a branch boost. Empty on the trunk / outside git.
36#[derive(Debug, Default, Clone)]
37pub struct ActiveFiles {
38    files: HashSet<String>,
39    dirs: HashSet<String>,
40}
41
42impl ActiveFiles {
43    /// Build from a list of repo-relative paths changed on the branch.
44    pub fn new<I: IntoIterator<Item = String>>(paths: I) -> Self {
45        let files: HashSet<String> = paths.into_iter().collect();
46        let dirs = files
47            .iter()
48            .filter_map(|f| parent_dir(f))
49            .map(str::to_string)
50            .collect();
51        ActiveFiles { files, dirs }
52    }
53
54    fn is_empty(&self) -> bool {
55        self.files.is_empty()
56    }
57
58    /// The branch boost for a candidate's file: full if the file itself is
59    /// changing, smaller if a sibling in the same directory is.
60    fn boost(&self, path: &str) -> f64 {
61        if self.files.contains(path) {
62            BRANCH_FILE_BOOST
63        } else if parent_dir(path).is_some_and(|d| self.dirs.contains(d)) {
64            BRANCH_DIR_BOOST
65        } else {
66            0.0
67        }
68    }
69}
70
71/// The directory portion of a repo-relative path (`app/models/user.rb` →
72/// `app/models`), or `None` for a top-level file.
73fn parent_dir(path: &str) -> Option<&str> {
74    path.rfind('/').map(|i| &path[..i])
75}
76
77/// A ranked search result. Serializes for `--json` / `--ndjson`.
78#[derive(Debug, Clone, PartialEq, serde::Serialize)]
79pub struct Hit {
80    pub name: String,
81    pub kind: String,
82    pub language: String,
83    pub file: String,
84    pub line: i64,
85    /// 1-based last line of the definition — read `line..=end_line` for the whole
86    /// span. Omitted in JSON when unknown (a row indexed before end-line tracking).
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub end_line: Option<i64>,
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub parent: Option<String>,
91    /// Access level (`public`/`crate`/`private`/`protected`) when the language
92    /// expresses one. Omitted when unknown.
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub visibility: Option<String>,
95    #[serde(rename = "repo")]
96    pub repo_identity: String,
97    /// Raw additive score — the ranking key and the `--explain` breakdown source.
98    /// Not serialized: JSON exposes the normalized `confidence` instead.
99    #[serde(skip)]
100    pub score: f64,
101    /// Normalized match confidence in [0,1], filled before output (see
102    /// [`score::confidence`]). This is what JSON carries in place of the raw score.
103    pub confidence: f64,
104    /// The scoring features, serialized as their names in descending weight order
105    /// (the raw values are low-signal unnormalized; `--explain` shows them in text).
106    #[serde(serialize_with = "serialize_feature_names")]
107    pub features: Vec<Feature>,
108    /// The definition's source line (trimmed) — filled for displayed results in
109    /// machine-readable output. Omitted when unread (matching `--symbols`).
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub signature: Option<String>,
112    /// The full definition source (`line..=end_line`), filled only by `--show`.
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub body: Option<String>,
115}
116
117/// Serialize a hit's features as a name list, strongest first — the values are
118/// unnormalized and low-signal, so the ordered names are the useful part.
119fn serialize_feature_names<S: serde::Serializer>(
120    features: &[Feature],
121    s: S,
122) -> Result<S::Ok, S::Error> {
123    use serde::Serialize;
124    let mut sorted: Vec<&Feature> = features.iter().collect();
125    sorted.sort_by(|a, b| b.value.total_cmp(&a.value));
126    let names: Vec<&str> = sorted.iter().map(|f| f.name).collect();
127    names.serialize(s)
128}
129
130/// Search the index for `query`, returning up to `limit` ranked hits.
131/// `current_repo_id` (if any) boosts results from the repository you're in;
132/// `only_repo` (if any) restricts results to that repository, so a search inside
133/// a repo answers about *that* repo rather than leaking others you've indexed;
134/// `active` boosts files you're changing on the current branch.
135pub fn search(
136    store: &Store,
137    query: &str,
138    current_repo_id: Option<i64>,
139    only_repo: Option<i64>,
140    active: &ActiveFiles,
141    limit: usize,
142) -> crate::store::Result<Vec<Hit>> {
143    // Recall keys off the leaf name only — a `Foo::Bar` qualifier targets the
144    // parent during scoring, and the store indexes `name`, not `parent`. A
145    // wildcard query then keys off its literal chars (the store indexes literal
146    // trigrams); the glob matches precisely during scoring.
147    let (leaf, _) = score::parse_qualified(query);
148    let stripped;
149    let recall = if score::has_wildcard(leaf) {
150        stripped = score::strip_wildcards(leaf);
151        stripped.as_str()
152    } else {
153        leaf
154    };
155    let trace_on = crate::trace::enabled();
156    let t = std::time::Instant::now();
157    let candidates = store.search_candidates(recall, CANDIDATE_LIMIT, score::has_wildcard(leaf))?;
158    let n_candidates = candidates.len();
159    let t_recall = t.elapsed();
160    let t = std::time::Instant::now();
161    let now = now_unix();
162    let learned = learned_boosts(store, query, now)?;
163
164    let mut hits: Vec<Hit> = candidates
165        .into_iter()
166        .filter_map(|c| {
167            // Repo scope: outside `--all-repos`, a search inside a repo returns
168            // only that repo's definitions — never another indexed repo's.
169            if only_repo.is_some_and(|r| r != c.repository_id) {
170                return None;
171            }
172            // learned is empty for most queries — skip the per-candidate
173            // String clones the key would cost
174            let learned_boost = if learned.is_empty() {
175                0.0
176            } else {
177                let key = (c.repository_id, c.file.clone(), c.name.clone());
178                learned.get(&key).copied().unwrap_or(0.0)
179            };
180            let boosts = Boosts {
181                learned: learned_boost,
182                // prefer whichever recency signal is more recent: a recent edit
183                // (mtime, stored in nanoseconds — convert to seconds) or a
184                // recent commit (git_ts, seconds)
185                recency: recency_boost(c.git_ts.max(c.mtime.map(|n| n / 1_000_000_000)), now),
186                branch: if active.is_empty() {
187                    0.0
188                } else {
189                    active.boost(&c.file)
190                },
191            };
192            rank_one(query, c, current_repo_id, boosts)
193        })
194        .collect();
195    let n_hits = hits.len();
196    let t_score = t.elapsed();
197
198    let t = std::time::Instant::now();
199    sort_and_truncate(&mut hits, limit);
200    // The search path already measures these for its trace line; profiling
201    // records the same numbers rather than timing the work twice.
202    crate::profile::record("recall", t_recall, || format!("{n_candidates} candidates"));
203    crate::profile::record("score", t_score, || format!("{n_hits} hits"));
204    crate::profile::record("sort", t.elapsed(), || format!("top {limit}"));
205    if trace_on {
206        crate::trace!(
207            "search {query:?}: recall {n_candidates} cand in {} ms, score→{n_hits} hits in {} ms, sort {} ms",
208            t_recall.as_millis(),
209            t_score.as_millis(),
210            t.elapsed().as_millis(),
211        );
212    }
213    Ok(hits)
214}
215
216/// Symbols in recently-modified files rank higher. ~14-day half-life and no
217/// floor, so files untouched for a while contribute nothing.
218fn recency_boost(mtime: Option<i64>, now: i64) -> f64 {
219    let Some(mtime) = mtime else {
220        return 0.0;
221    };
222    let age_days = (now - mtime).max(0) as f64 / 86_400.0;
223    let boost = 120.0 * 0.5_f64.powf(age_days / 14.0);
224    if boost < 1.0 { 0.0 } else { boost }
225}
226
227/// Decay-weighted learned boosts for a query, keyed by `(repo, file, name)`.
228fn learned_boosts(
229    store: &Store,
230    query: &str,
231    now: i64,
232) -> crate::store::Result<HashMap<(i64, String, String), f64>> {
233    let q = query.to_ascii_lowercase();
234    let mut map: HashMap<(i64, String, String), f64> = HashMap::new();
235    for s in store.selections_for(&q)? {
236        // several stored queries can match (e.g. "han" and "handler"); keep the
237        // strongest boost for each candidate
238        let boost = learned_boost(s.selections, s.last_selected_at, now);
239        let entry = map.entry((s.repository_id, s.file, s.name)).or_insert(0.0);
240        *entry = entry.max(boost);
241    }
242    Ok(map)
243}
244
245/// Turn a selection count + recency into a ranking boost. Evidence ramps over
246/// ~5 selections; recency decays with a ~30-day half-life, floored so old picks
247/// still count for something.
248fn learned_boost(selections: i64, last_selected_at: i64, now: i64) -> f64 {
249    if selections <= 0 {
250        return 0.0;
251    }
252    let strength = (selections.min(5) as f64) / 5.0;
253    let age_days = (now - last_selected_at).max(0) as f64 / 86_400.0;
254    let recency = 0.5_f64.powf(age_days / 30.0).max(0.25);
255    260.0 * strength * recency
256}
257
258fn now_unix() -> i64 {
259    SystemTime::now()
260        .duration_since(UNIX_EPOCH)
261        .map(|d| d.as_secs() as i64)
262        .unwrap_or(0)
263}
264
265/// Layer 4: scan `root` live (no index required) and return ranked hits.
266/// Results are treated as the current repo, so the current-repo boost applies.
267/// `skip` names already-indexed files to ignore, and `deadline` bounds the scan
268/// — both empty/`None` for an unbounded scan of a never-indexed directory. When
269/// `prefilter` is set, only files containing the query (substring) are parsed —
270/// fast for exact/prefix/substring queries, but blind to fuzzy abbreviations, so
271/// callers retry with `prefilter = false` if a filtered scan finds nothing.
272pub fn live_search(
273    root: &Path,
274    query: &str,
275    limit: usize,
276    skip: &HashSet<String>,
277    deadline: Option<Instant>,
278    prefilter: bool,
279) -> Vec<Hit> {
280    let needle = prefilter.then_some(query.as_bytes());
281    let identity = crate::index::detect_identity(root).to_string();
282    let mut hits: Vec<Hit> = crate::index::scan(root, skip, deadline, needle)
283        .into_iter()
284        .flat_map(|fs| fs.symbols)
285        .filter_map(|s| {
286            let row = SymbolRow {
287                name: s.name,
288                kind: s.kind.as_str().to_string(),
289                language: s.language,
290                file: s.file,
291                line: s.line as i64,
292                end_line: Some(s.end_line as i64),
293                parent: s.parent,
294                repository_id: LIVE_REPO_ID,
295                repo_identity: identity.clone(),
296                mtime: None,
297                git_ts: None,
298                visibility: s.visibility.map(str::to_string),
299            };
300            rank_one(query, row, Some(LIVE_REPO_ID), Boosts::default())
301        })
302        .collect();
303    sort_and_truncate(&mut hits, limit);
304    hits
305}
306
307/// Merge two ranked lists, de-duplicating by location and name (keeping the
308/// higher score), then re-rank and truncate. Used to blend index and live-scan
309/// results.
310pub fn merge(a: Vec<Hit>, b: Vec<Hit>, limit: usize) -> Vec<Hit> {
311    use std::collections::HashMap;
312    let mut by_key: HashMap<(String, i64, String), Hit> = HashMap::new();
313    for hit in a.into_iter().chain(b) {
314        let key = (hit.file.clone(), hit.line, hit.name.clone());
315        match by_key.get(&key) {
316            Some(existing) if existing.score >= hit.score => {}
317            _ => {
318                by_key.insert(key, hit);
319            }
320        }
321    }
322    let mut hits: Vec<Hit> = by_key.into_values().collect();
323    sort_and_truncate(&mut hits, limit);
324    hits
325}
326
327/// Scope gate for a qualified query (`Foo::Bar#baz`). When the user names an
328/// enclosing scope and at least one result actually sits in it, drop the rest —
329/// a `baz` outside `Foo::Bar` is noise next to the one inside it, the same way
330/// the relevance gate drops fuzzy near-matches beside an exact hit. When
331/// *nothing* matches the scope, the list is left untouched: the scope was a
332/// hint, and the definition may simply live somewhere we didn't expect, so a
333/// `baz` elsewhere still surfaces rather than returning empty.
334///
335/// An in-scope result is one the scorer gave the `parent` feature — i.e. its
336/// recorded parent ends with the qualifier's scope chain.
337pub fn apply_scope_gate(query: &str, hits: &mut Vec<Hit>) {
338    if score::parse_qualified(query).1.is_none() {
339        return; // unqualified query — nothing to gate on
340    }
341    let in_scope = |h: &Hit| h.features.iter().any(|f| f.name == "parent");
342    if hits.iter().any(in_scope) {
343        hits.retain(in_scope);
344    }
345}
346
347/// Highest score first; ties broken toward shorter (more specific) names.
348fn sort_and_truncate(hits: &mut Vec<Hit>, limit: usize) {
349    hits.sort_by(|a, b| {
350        b.score
351            .partial_cmp(&a.score)
352            .unwrap_or(std::cmp::Ordering::Equal)
353            .then_with(|| a.name.len().cmp(&b.name.len()))
354            .then_with(|| a.name.cmp(&b.name))
355    });
356    hits.truncate(limit);
357}
358
359fn rank_one(
360    query: &str,
361    c: SymbolRow,
362    current_repo_id: Option<i64>,
363    boosts: Boosts,
364) -> Option<Hit> {
365    let scored = score::score(query, &c, current_repo_id, boosts)?;
366    Some(Hit {
367        name: c.name,
368        kind: c.kind,
369        language: c.language,
370        file: c.file,
371        line: c.line,
372        end_line: c.end_line,
373        parent: c.parent,
374        visibility: c.visibility,
375        repo_identity: c.repo_identity,
376        score: scored.total,
377        confidence: 0.0, // filled from the final result set before output
378        features: scored.features,
379        signature: None,
380        body: None,
381    })
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use crate::core::{Kind, Symbol};
388
389    fn sym(name: &str, kind: Kind) -> Symbol {
390        Symbol {
391            name: name.into(),
392            kind,
393            language: "ruby".into(),
394            file: "app/x.rb".into(),
395            line: 1,
396            end_line: 1,
397            parent: None,
398            visibility: None,
399        }
400    }
401
402    fn store_with(symbols: &[Symbol]) -> Store {
403        let mut store = Store::open_in_memory().unwrap();
404        let repo = store
405            .upsert_repository(&crate::core::RepoIdentity::local("/tmp/x"), None)
406            .unwrap();
407        store
408            .replace_file_symbols(repo, "app/x.rb", "ruby", None, "h", symbols)
409            .unwrap();
410        store
411    }
412
413    fn names(hits: &[Hit]) -> Vec<&str> {
414        hits.iter().map(|h| h.name.as_str()).collect()
415    }
416
417    /// Two repos, each with its own symbol, so scoping can be exercised.
418    fn store_two_repos() -> (Store, i64, i64) {
419        let mut store = Store::open_in_memory().unwrap();
420        let a = store
421            .upsert_repository(&crate::core::RepoIdentity::local("/tmp/a"), None)
422            .unwrap();
423        let b = store
424            .upsert_repository(&crate::core::RepoIdentity::local("/tmp/b"), None)
425            .unwrap();
426        store
427            .replace_file_symbols(a, "a.rb", "ruby", None, "h", &[sym("Widget", Kind::Class)])
428            .unwrap();
429        store
430            .replace_file_symbols(b, "b.rb", "ruby", None, "h", &[sym("Widget", Kind::Class)])
431            .unwrap();
432        (store, a, b)
433    }
434
435    #[test]
436    fn only_repo_scopes_results_to_that_repo() {
437        let (store, a, b) = store_two_repos();
438        // scoped to repo A: only A's Widget, never B's
439        let hits = search(
440            &store,
441            "Widget",
442            Some(a),
443            Some(a),
444            &ActiveFiles::default(),
445            10,
446        )
447        .unwrap();
448        assert_eq!(hits.len(), 1);
449        assert_eq!(hits[0].repo_identity, "local:/tmp/a");
450        // no scope (--all-repos): both repos' Widgets surface
451        let all = search(&store, "Widget", Some(a), None, &ActiveFiles::default(), 10).unwrap();
452        assert_eq!(all.len(), 2);
453        let _ = b;
454    }
455
456    #[test]
457    fn scoped_search_reports_no_match_rather_than_leaking_another_repo() {
458        let (store, a, _b) = store_two_repos();
459        // "Gadget" exists in neither; scoped to A it's simply absent (not B's)
460        let hits = search(
461            &store,
462            "Gadget",
463            Some(a),
464            Some(a),
465            &ActiveFiles::default(),
466            10,
467        )
468        .unwrap();
469        assert!(hits.is_empty());
470    }
471
472    #[test]
473    fn ranks_exact_match_first() {
474        let store = store_with(&[
475            sym("Users", Kind::Class),
476            sym("User", Kind::Class),
477            sym("UserMailer", Kind::Class),
478        ]);
479        let hits = search(&store, "user", None, None, &ActiveFiles::default(), 10).unwrap();
480        assert_eq!(hits[0].name, "User");
481    }
482
483    #[test]
484    fn abbreviation_finds_the_intended_symbol() {
485        let store = store_with(&[
486            sym("RefundProcessor", Kind::Class),
487            sym("Refund", Kind::Class),
488            sym("Payment", Kind::Class),
489        ]);
490        let hits = search(
491            &store,
492            "refundproc",
493            None,
494            None,
495            &ActiveFiles::default(),
496            10,
497        )
498        .unwrap();
499        assert_eq!(hits[0].name, "RefundProcessor");
500        assert!(!names(&hits).contains(&"Payment"));
501    }
502
503    #[test]
504    fn short_fuzzy_query_still_resolves() {
505        let store = store_with(&[sym("User", Kind::Class), sym("Account", Kind::Class)]);
506        let hits = search(&store, "usr", None, None, &ActiveFiles::default(), 10).unwrap();
507        assert_eq!(hits[0].name, "User");
508    }
509
510    #[test]
511    fn no_match_returns_empty() {
512        let store = store_with(&[sym("User", Kind::Class)]);
513        let hits = search(&store, "zzzzz", None, None, &ActiveFiles::default(), 10).unwrap();
514        assert!(hits.is_empty());
515    }
516
517    #[test]
518    fn merge_dedups_by_location_keeping_higher_score() {
519        let mk = |name: &str, score: f64| Hit {
520            name: name.into(),
521            kind: "class".into(),
522            language: "ruby".into(),
523            file: "a.rb".into(),
524            line: 1,
525            end_line: Some(1),
526            parent: None,
527            visibility: None,
528            repo_identity: "r".into(),
529            score,
530            confidence: 0.0,
531            features: vec![],
532            signature: None,
533            body: None,
534        };
535        let from_index = vec![mk("User", 100.0)];
536        let from_live = vec![mk("User", 500.0), mk("Account", 200.0)];
537        let merged = merge(from_index, from_live, 10);
538        assert_eq!(merged.len(), 2, "the duplicate User is collapsed");
539        assert_eq!(merged[0].name, "User");
540        assert_eq!(merged[0].score, 500.0, "the higher-scored duplicate wins");
541    }
542
543    #[test]
544    fn active_files_boosts_the_file_and_its_neighbors() {
545        let active = ActiveFiles::new(["app/services/refund.rb".to_string()]);
546        // the changed file itself: full boost
547        assert_eq!(active.boost("app/services/refund.rb"), BRANCH_FILE_BOOST);
548        // a sibling in the same directory: neighbor boost
549        assert_eq!(active.boost("app/services/charge.rb"), BRANCH_DIR_BOOST);
550        // unrelated directory: nothing
551        assert_eq!(active.boost("app/models/user.rb"), 0.0);
552    }
553
554    fn nested(name: &str, kind: Kind, parent: &str) -> Symbol {
555        Symbol {
556            parent: Some(parent.into()),
557            ..sym(name, kind)
558        }
559    }
560
561    #[test]
562    fn qualified_query_ranks_the_definition_in_the_named_scope() {
563        let store = store_with(&[
564            nested("Config", Kind::Class, "Baz"),
565            nested("Config", Kind::Class, "Foo"),
566            nested("Config", Kind::Class, "Qux"),
567        ]);
568        // `Foo::Config` should surface the Config nested under Foo first
569        let hits = search(
570            &store,
571            "Foo::Config",
572            None,
573            None,
574            &ActiveFiles::default(),
575            10,
576        )
577        .unwrap();
578        assert_eq!(hits[0].parent.as_deref(), Some("Foo"));
579        assert!(hits[0].features.iter().any(|f| f.name == "parent"));
580    }
581
582    #[test]
583    fn qualifier_resolves_modules_and_methods_too() {
584        let store = store_with(&[
585            nested("perform", Kind::Method, "Bar::Worker"),
586            nested("perform", Kind::Method, "Other::Worker"),
587            nested("Worker", Kind::Module, "Bar"),
588        ]);
589        // a method qualified by its full scope chain
590        let m = search(
591            &store,
592            "Bar::Worker#perform",
593            None,
594            None,
595            &ActiveFiles::default(),
596            10,
597        )
598        .unwrap();
599        assert_eq!(m[0].kind, "method");
600        assert_eq!(m[0].parent.as_deref(), Some("Bar::Worker"));
601        // a module qualified by its enclosing scope
602        let w = search(
603            &store,
604            "Bar::Worker",
605            None,
606            None,
607            &ActiveFiles::default(),
608            10,
609        )
610        .unwrap();
611        assert_eq!(w[0].name, "Worker");
612        assert_eq!(w[0].parent.as_deref(), Some("Bar"));
613    }
614
615    fn hit(name: &str, in_scope: bool) -> Hit {
616        Hit {
617            name: name.into(),
618            kind: "method".into(),
619            language: "ruby".into(),
620            file: "a.rb".into(),
621            line: 1,
622            end_line: Some(1),
623            parent: None,
624            visibility: None,
625            repo_identity: "r".into(),
626            score: 1.0,
627            confidence: 0.0,
628            features: if in_scope {
629                vec![Feature {
630                    name: "parent",
631                    value: 180.0,
632                }]
633            } else {
634                vec![]
635            },
636            signature: None,
637            body: None,
638        }
639    }
640
641    #[test]
642    fn scope_gate_keeps_only_in_scope_results_when_some_match() {
643        let mut hits = vec![hit("baz", true), hit("baz", false), hit("baz", false)];
644        apply_scope_gate("Foo::Bar#baz", &mut hits);
645        assert_eq!(hits.len(), 1, "out-of-scope baz methods are dropped");
646        assert!(hits[0].features.iter().any(|f| f.name == "parent"));
647    }
648
649    #[test]
650    fn scope_gate_falls_back_when_nothing_matches_the_scope() {
651        // no result is in `Foo::Bar`, so a `baz` defined elsewhere still surfaces
652        let mut hits = vec![hit("baz", false), hit("baz", false)];
653        apply_scope_gate("Foo::Bar#baz", &mut hits);
654        assert_eq!(hits.len(), 2, "fall back rather than return empty");
655    }
656
657    #[test]
658    fn scope_gate_is_a_noop_for_an_unqualified_query() {
659        let mut hits = vec![hit("baz", true), hit("baz", false)];
660        apply_scope_gate("baz", &mut hits);
661        assert_eq!(hits.len(), 2, "no qualifier — nothing to gate on");
662    }
663
664    #[test]
665    fn branch_boost_lifts_an_active_file() {
666        let store = store_with(&[sym("User", Kind::Class)]); // lives in app/x.rb
667        let active = ActiveFiles::new(["app/x.rb".to_string()]);
668        let hits = search(&store, "user", None, None, &active, 10).unwrap();
669        assert!(hits[0].features.iter().any(|f| f.name == "branch"));
670    }
671}