Skip to main content

sift_core/
candidate.rs

1use std::path::PathBuf;
2use std::sync::OnceLock;
3
4use crate::search::output::style::PathDisplay;
5
6/// A candidate file that might match a query.
7///
8/// Created by index planning or filesystem walk, then processed through
9/// the candidate pipeline for depth, filesize, and metadata constraints.
10/// The normalized string path is computed lazily on first access.
11///
12/// Fields are accessible via accessor methods to guarantee that the lazy
13/// `rel_str` cache remains consistent with `rel_path`.
14#[derive(Debug, Clone)]
15pub struct Candidate {
16    /// Path relative to the index root or filter root.
17    rel_path: PathBuf,
18    /// Absolute filesystem path.
19    abs_path: PathBuf,
20    rel_str: OnceLock<String>,
21}
22
23impl Candidate {
24    #[must_use]
25    pub const fn new(rel_path: PathBuf, abs_path: PathBuf) -> Self {
26        Self {
27            rel_path,
28            abs_path,
29            rel_str: OnceLock::new(),
30        }
31    }
32
33    #[must_use]
34    pub fn rel_path(&self) -> &std::path::Path {
35        &self.rel_path
36    }
37
38    #[must_use]
39    pub fn abs_path(&self) -> &std::path::Path {
40        &self.abs_path
41    }
42
43    /// Normalized relative path string (forward slashes), computed lazily.
44    #[must_use]
45    pub fn rel_str(&self) -> &str {
46        self.rel_str
47            .get_or_init(|| self.rel_path.to_string_lossy().replace('\\', "/"))
48    }
49
50    #[must_use]
51    pub fn display_path(&self, display: PathDisplay, path_separator: Option<u8>) -> String {
52        let raw = match display {
53            PathDisplay::Absolute => self.abs_path().display().to_string(),
54            PathDisplay::Relative => self.rel_path().display().to_string(),
55        };
56        if let Some(sep) = path_separator {
57            let sep_char = sep as char;
58            raw.replace(std::path::MAIN_SEPARATOR, &sep_char.to_string())
59        } else {
60            raw
61        }
62    }
63
64    /// Check depth constraint against a filter's max depth.
65    #[must_use]
66    pub fn within_depth(&self, max_depth: Option<usize>) -> bool {
67        max_depth.is_none_or(|d| self.rel_path.components().count().saturating_sub(1) <= d)
68    }
69
70    /// Check filesize constraint against a filter's max filesize.
71    #[must_use]
72    pub fn within_filesize(&self, max_filesize: Option<u64>) -> bool {
73        max_filesize.is_none_or(|limit| {
74            std::fs::metadata(&self.abs_path).map_or(true, |m| m.len() <= limit)
75        })
76    }
77
78    /// Check all configured filter rules: depth, filesize, and path-based rules.
79    #[must_use]
80    pub fn matches(&self, filter: &crate::search::filter::CandidateFilter) -> bool {
81        self.within_depth(filter.max_depth())
82            && self.within_filesize(filter.max_filesize())
83            && filter.matches_candidate(self)
84    }
85}
86
87impl PartialEq for Candidate {
88    fn eq(&self, other: &Self) -> bool {
89        self.rel_path == other.rel_path && self.abs_path == other.abs_path
90    }
91}
92
93impl Eq for Candidate {}