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    /// File size cached from the walk `DirEntry` or index `FileFingerprint`,
22    /// avoiding a redundant `statx` syscall in `within_filesize` and
23    /// `total_file_bytes`.
24    cached_size: Option<u64>,
25    /// Directory depth cached from the walk `DirEntry`, avoiding repeated
26    /// `components().count()` iterations in `within_depth`.
27    cached_depth: Option<usize>,
28}
29
30impl Candidate {
31    #[must_use]
32    pub const fn new(rel_path: PathBuf, abs_path: PathBuf) -> Self {
33        Self {
34            rel_path,
35            abs_path,
36            rel_str: OnceLock::new(),
37            cached_size: None,
38            cached_depth: None,
39        }
40    }
41
42    #[must_use]
43    pub const fn with_metadata(
44        rel_path: PathBuf,
45        abs_path: PathBuf,
46        size: Option<u64>,
47        depth: Option<usize>,
48    ) -> Self {
49        Self {
50            rel_path,
51            abs_path,
52            rel_str: OnceLock::new(),
53            cached_size: size,
54            cached_depth: depth,
55        }
56    }
57
58    #[must_use]
59    pub fn rel_path(&self) -> &std::path::Path {
60        &self.rel_path
61    }
62
63    #[must_use]
64    pub fn abs_path(&self) -> &std::path::Path {
65        &self.abs_path
66    }
67
68    /// Normalized relative path string (forward slashes), computed lazily.
69    #[must_use]
70    pub fn rel_str(&self) -> &str {
71        self.rel_str
72            .get_or_init(|| self.rel_path.to_string_lossy().replace('\\', "/"))
73    }
74
75    #[must_use]
76    pub fn display_path(&self, display: PathDisplay, path_separator: Option<u8>) -> String {
77        let raw = match display {
78            PathDisplay::Absolute => self.abs_path().display().to_string(),
79            PathDisplay::Relative => self.rel_path().display().to_string(),
80        };
81        if let Some(sep) = path_separator {
82            let mut buf = [0u8; 4];
83            let sep_str = (sep as char).encode_utf8(&mut buf);
84            raw.replace(std::path::MAIN_SEPARATOR, sep_str)
85        } else {
86            raw
87        }
88    }
89
90    /// Check depth constraint against a filter's max depth.
91    #[must_use]
92    pub fn within_depth(&self, max_depth: Option<usize>) -> bool {
93        max_depth.is_none_or(|d| {
94            self.cached_depth
95                .unwrap_or_else(|| self.rel_path.components().count().saturating_sub(1))
96                <= d
97        })
98    }
99
100    /// Cached file size, if available from walk or index metadata.
101    #[must_use]
102    pub const fn cached_size(&self) -> Option<u64> {
103        self.cached_size
104    }
105
106    /// Check filesize constraint against a filter's max filesize.
107    #[must_use]
108    pub fn within_filesize(&self, max_filesize: Option<u64>) -> bool {
109        max_filesize.is_none_or(|limit| {
110            self.cached_size.map_or_else(
111                || std::fs::metadata(&self.abs_path).map_or(true, |m| m.len() <= limit),
112                |size| size <= limit,
113            )
114        })
115    }
116
117    /// Sum on-disk byte sizes for all candidates (used for search stats).
118    #[must_use]
119    pub fn total_file_bytes(candidates: &[Self]) -> u64 {
120        candidates.iter().fold(0u64, |acc, c| {
121            acc + c
122                .cached_size
123                .unwrap_or_else(|| std::fs::metadata(c.abs_path()).map_or(0, |m| m.len()))
124        })
125    }
126
127    /// Check all configured filter rules: depth, filesize, and path-based rules.
128    #[must_use]
129    pub fn matches(&self, filter: &crate::search::filter::CandidateFilter) -> bool {
130        self.within_depth(filter.max_depth())
131            && self.within_filesize(filter.max_filesize())
132            && filter.matches_candidate(self)
133    }
134}
135
136impl PartialEq for Candidate {
137    fn eq(&self, other: &Self) -> bool {
138        self.rel_path == other.rel_path && self.abs_path == other.abs_path
139    }
140}
141
142impl Eq for Candidate {}