sift_core/query/planner.rs
1use std::collections::HashSet;
2use std::path::PathBuf;
3
4use crate::Candidate;
5use crate::StoreMeta;
6use crate::index::Indexes;
7use crate::query::QuerySpec;
8use crate::search::CandidateFilter;
9
10/// Whether search needs all candidate paths or only potential matches.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum CandidateRequirement {
13 Complete,
14 PotentialMatches,
15}
16
17/// Whether the planner should walk for files not present in the index snapshot.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
19pub enum UnindexedStrategy {
20 /// Return only index-narrowed candidates. No filesystem walk.
21 #[default]
22 Skip,
23 /// Walk to discover files not yet indexed and merge them into results.
24 Walk,
25}
26
27/// Plans candidate selection by consulting the index registry and falling back
28/// to a filesystem walk when no index can narrow the query.
29///
30/// The planner is the single coordination point between the search pipeline
31/// and the index layer. It is index-agnostic: it calls `Indexes::candidates()`
32/// without knowing which index types are present.
33pub struct QueryPlanner<'a> {
34 spec: QuerySpec<'a>,
35}
36
37impl<'a> QueryPlanner<'a> {
38 #[must_use]
39 pub const fn new(spec: QuerySpec<'a>) -> Self {
40 Self { spec }
41 }
42
43 /// Resolve candidates using indexes or the lazy base provider.
44 ///
45 /// When `unindexed` is [`UnindexedStrategy::Walk`] and the index narrows
46 /// candidates, also walk corpus paths that are not yet present in the
47 /// current snapshot.
48 ///
49 /// # Errors
50 ///
51 /// Delegates to `base` when fallback is triggered; returns `base` errors unchanged.
52 pub fn candidates(
53 &self,
54 indexes: &Indexes,
55 requirement: CandidateRequirement,
56 filter: &CandidateFilter,
57 store_meta: Option<&StoreMeta>,
58 unindexed: UnindexedStrategy,
59 base: impl FnOnce() -> crate::Result<Vec<Candidate>>,
60 ) -> crate::Result<Vec<Candidate>> {
61 match requirement {
62 CandidateRequirement::Complete => {
63 if indexes.is_empty() {
64 return base();
65 }
66 if store_meta.is_some_and(|meta| !meta.matches_search_filter(filter)) {
67 return base();
68 }
69 Ok(indexes.complete_candidates())
70 }
71 CandidateRequirement::PotentialMatches => {
72 if indexes.is_empty() {
73 return base();
74 }
75 match indexes.candidates(&self.spec) {
76 None => base(),
77 Some(snapshot_hits) if unindexed == UnindexedStrategy::Skip => {
78 Ok(snapshot_hits)
79 }
80 Some(mut snapshot_hits) => {
81 let indexed_paths = indexes.indexed_rel_paths();
82 let walked = base()?;
83 let mut seen: HashSet<PathBuf> = snapshot_hits
84 .iter()
85 .map(|c| c.rel_path().to_path_buf())
86 .collect();
87 for candidate in walked {
88 if indexed_paths.contains(candidate.rel_path()) {
89 continue;
90 }
91 if seen.insert(candidate.rel_path().to_path_buf()) {
92 snapshot_hits.push(candidate);
93 }
94 }
95 Ok(snapshot_hits)
96 }
97 }
98 }
99 }
100 }
101}