Skip to main content

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}