gravityfile_core/
config.rs1use std::path::PathBuf;
4
5use derive_builder::Builder;
6use globset::{Glob, GlobSet, GlobSetBuilder};
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Builder, Serialize, Deserialize)]
11#[builder(setter(into), build_fn(validate = "Self::validate"))]
12pub struct ScanConfig {
13 #[serde(with = "crate::path_serde")]
15 pub root: PathBuf,
16
17 #[builder(default = "false")]
19 #[serde(default)]
20 pub follow_symlinks: bool,
21
22 #[builder(default = "false")]
24 #[serde(default)]
25 pub cross_filesystems: bool,
26
27 #[builder(default = "false")]
29 #[serde(default)]
30 pub apparent_size: bool,
31
32 #[builder(default = "false")]
34 #[serde(default)]
35 pub count_hard_links: bool,
36
37 #[builder(default)]
41 #[serde(default)]
42 pub max_depth: Option<u32>,
43
44 #[builder(default)]
51 #[serde(default)]
52 pub max_entries: Option<u64>,
53
54 #[builder(default)]
56 #[serde(default)]
57 pub ignore_patterns: Vec<String>,
58
59 #[builder(default = "0")]
61 #[serde(default)]
62 pub threads: usize,
63
64 #[builder(default = "true")]
66 #[serde(default = "default_true")]
67 pub include_hidden: bool,
68
69 #[builder(default = "false")]
71 #[serde(default)]
72 pub git_status: bool,
73
74 #[serde(skip)]
76 #[builder(setter(skip))]
77 #[builder(default)]
78 compiled_ignore: Option<(Vec<String>, GlobSet)>,
79}
80
81fn default_true() -> bool {
82 true
83}
84
85impl ScanConfigBuilder {
86 fn validate(&self) -> Result<(), String> {
87 if let Some(ref root) = self.root {
88 if root.as_os_str().is_empty() {
89 return Err("Root path cannot be empty".to_string());
90 }
91 } else {
92 return Err("Root path is required".to_string());
93 }
94 ScanConfig::build_ignore_globset(self.ignore_patterns.as_deref().unwrap_or_default())?;
95 Ok(())
96 }
97}
98
99impl ScanConfig {
100 pub fn builder() -> ScanConfigBuilder {
102 ScanConfigBuilder::default()
103 }
104
105 pub fn new(root: impl Into<PathBuf>) -> Self {
107 Self {
108 root: root.into(),
109 follow_symlinks: false,
110 cross_filesystems: false,
111 apparent_size: false,
112 count_hard_links: false,
113 max_depth: None,
114 max_entries: None,
115 ignore_patterns: Vec::new(),
116 threads: 0,
117 include_hidden: true,
118 git_status: false,
119 compiled_ignore: None,
120 }
121 }
122
123 pub fn validate(&self) -> Result<(), String> {
125 if self.root.as_os_str().is_empty() {
126 return Err("Root path cannot be empty".into());
127 }
128 self.effective_ignore_globset()?;
129 Ok(())
130 }
131
132 pub fn build_ignore_globset(patterns: &[String]) -> Result<Option<GlobSet>, String> {
134 if patterns.is_empty() {
135 return Ok(None);
136 }
137 let mut builder = GlobSetBuilder::new();
138 for pattern in patterns {
139 builder.add(
140 Glob::new(pattern)
141 .map_err(|error| format!("Invalid ignore pattern {pattern:?}: {error}"))?,
142 );
143 }
144 builder
145 .build()
146 .map(Some)
147 .map_err(|error| format!("Invalid ignore pattern set: {error}"))
148 }
149
150 pub fn effective_ignore_globset(&self) -> Result<Option<GlobSet>, String> {
152 match &self.compiled_ignore {
153 Some((patterns, set)) if patterns == &self.ignore_patterns => Ok(Some(set.clone())),
154 _ => Self::build_ignore_globset(&self.ignore_patterns),
155 }
156 }
157
158 pub fn compile_patterns(&mut self) -> Result<(), String> {
161 self.compiled_ignore = Self::build_ignore_globset(&self.ignore_patterns)?
162 .map(|set| (self.ignore_patterns.clone(), set));
163 Ok(())
164 }
165
166 pub fn should_ignore(&self, name: &str) -> Result<bool, String> {
168 match &self.compiled_ignore {
169 Some((patterns, set)) if patterns == &self.ignore_patterns => Ok(set.is_match(name)),
170 _ => Ok(self
171 .effective_ignore_globset()?
172 .is_some_and(|set| set.is_match(name))),
173 }
174 }
175
176 #[inline]
178 pub fn should_skip_hidden(&self, name: &str) -> bool {
179 !self.include_hidden && name.starts_with('.')
180 }
181}
182
183impl Default for ScanConfig {
184 fn default() -> Self {
185 Self::new(".")
186 }
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192
193 #[test]
194 fn public_pattern_mutation_cannot_reuse_a_stale_cache() {
195 let mut config = ScanConfig::new(".");
196 config.ignore_patterns = vec!["*.log".into()];
197 config.compile_patterns().unwrap();
198 config.ignore_patterns = vec!["*.tmp".into()];
199 assert!(!config.should_ignore("data.log").unwrap());
200 assert!(config.should_ignore("data.tmp").unwrap());
201 config.ignore_patterns = vec!["[".into()];
202 assert!(config.should_ignore("data.log").is_err());
203 assert!(config.validate().is_err());
204 config.ignore_patterns.clear();
205 assert!(!config.should_ignore("data.log").unwrap());
206 }
207
208 #[test]
209 fn builder_rejects_invalid_ignore_scope() {
210 assert!(
211 ScanConfig::builder()
212 .root(".")
213 .ignore_patterns(vec!["[".into()])
214 .build()
215 .is_err()
216 );
217 }
218
219 #[test]
220 fn test_config_builder() {
221 let config = ScanConfig::builder()
222 .root("/home/user")
223 .threads(4usize)
224 .follow_symlinks(true)
225 .build()
226 .unwrap();
227
228 assert_eq!(config.root, PathBuf::from("/home/user"));
229 assert_eq!(config.threads, 4);
230 assert!(config.follow_symlinks);
231 }
232
233 #[test]
234 fn test_config_simple() {
235 let config = ScanConfig::new("/home/user");
236 assert_eq!(config.root, PathBuf::from("/home/user"));
237 assert!(!config.follow_symlinks);
238 assert_eq!(config.threads, 0);
239 }
240
241 #[test]
242 fn test_should_ignore_glob() {
243 let mut config = ScanConfig::builder()
244 .root("/test")
245 .ignore_patterns(vec![
246 "node_modules".to_string(),
247 "*.log".to_string(),
248 "**/*.tmp".to_string(),
249 ])
250 .build()
251 .unwrap();
252 config.compile_patterns().unwrap();
253
254 assert!(config.should_ignore("node_modules").unwrap());
255 assert!(config.should_ignore("test.log").unwrap());
256 assert!(config.should_ignore("cache.tmp").unwrap());
257 assert!(!config.should_ignore("src").unwrap());
258 assert!(!config.should_ignore("test.txt").unwrap());
259 }
260
261 #[test]
262 fn test_should_ignore_prefix_glob() {
263 let mut config = ScanConfig::builder()
264 .root("/test")
265 .ignore_patterns(vec!["build*".to_string()])
266 .build()
267 .unwrap();
268 config.compile_patterns().unwrap();
269
270 assert!(config.should_ignore("build").unwrap());
271 assert!(config.should_ignore("build-output").unwrap());
272 assert!(!config.should_ignore("rebuild").unwrap());
273 }
274
275 #[test]
276 fn test_should_skip_hidden() {
277 let mut config = ScanConfig::new("/test");
278
279 assert!(!config.should_skip_hidden(".git"));
281
282 config.include_hidden = false;
284 assert!(config.should_skip_hidden(".git"));
285 assert!(!config.should_skip_hidden("src"));
286 }
287}