Skip to main content

gravityfile_core/
config.rs

1//! Scan configuration types.
2
3use std::path::PathBuf;
4
5use derive_builder::Builder;
6use globset::{Glob, GlobSet, GlobSetBuilder};
7use serde::{Deserialize, Serialize};
8
9/// Configuration for scanning operations.
10#[derive(Debug, Clone, Builder, Serialize, Deserialize)]
11#[builder(setter(into), build_fn(validate = "Self::validate"))]
12pub struct ScanConfig {
13    /// Root path to scan.
14    #[serde(with = "crate::path_serde")]
15    pub root: PathBuf,
16
17    /// Follow symbolic links.
18    #[builder(default = "false")]
19    #[serde(default)]
20    pub follow_symlinks: bool,
21
22    /// Cross filesystem boundaries.
23    #[builder(default = "false")]
24    #[serde(default)]
25    pub cross_filesystems: bool,
26
27    /// Use apparent size vs disk usage.
28    #[builder(default = "false")]
29    #[serde(default)]
30    pub apparent_size: bool,
31
32    /// Count every hard link to a file instead of only the first one seen.
33    #[builder(default = "false")]
34    #[serde(default)]
35    pub count_hard_links: bool,
36
37    /// Deepest level kept in the tree (None = unlimited). Everything below is
38    /// still traversed and counted into the sizes of the deepest kept
39    /// directories; only the nodes are not retained.
40    #[builder(default)]
41    #[serde(default)]
42    pub max_depth: Option<u32>,
43
44    /// Maximum descendant entries consumed after hidden/ignore filtering, at
45    /// every depth (`max_depth` does not reduce the count).
46    /// `None` is unlimited; zero permits only an empty scope. The root does
47    /// not count. Metadata failures and unsupported file types still count.
48    /// Exceeding the limit fails the scan instead of returning a partial tree.
49    /// This is not a bound on walker prefetching, Git enrichment, or total RAM.
50    #[builder(default)]
51    #[serde(default)]
52    pub max_entries: Option<u64>,
53
54    /// Patterns to ignore using globset syntax (not gitignore negation rules).
55    #[builder(default)]
56    #[serde(default)]
57    pub ignore_patterns: Vec<String>,
58
59    /// Number of threads for scanning (0 = auto-detect).
60    #[builder(default = "0")]
61    #[serde(default)]
62    pub threads: usize,
63
64    /// Include hidden files (starting with .).
65    #[builder(default = "true")]
66    #[serde(default = "default_true")]
67    pub include_hidden: bool,
68
69    /// Annotate nodes with Git status when the root is inside a repository.
70    #[builder(default = "false")]
71    #[serde(default)]
72    pub git_status: bool,
73
74    /// Compiled glob patterns for ignore matching. Rebuilt from `ignore_patterns`.
75    #[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    /// Create a new scan config builder.
101    pub fn builder() -> ScanConfigBuilder {
102        ScanConfigBuilder::default()
103    }
104
105    /// Create a simple config for scanning a path.
106    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    /// Validate a directly constructed or deserialized configuration before use.
124    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    /// Compile every supplied pattern, rejecting invalid patterns as a unit.
133    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    /// Return a compiled snapshot of the current patterns, never a stale cache.
151    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    /// Cache the current valid patterns. Public-field mutations are detected
159    /// automatically; callers need not manually invalidate this cache.
160    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    /// Match a name, returning an error if any configured pattern is invalid.
167    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    /// Check if hidden files should be skipped.
177    #[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        // By default, hidden files are included
280        assert!(!config.should_skip_hidden(".git"));
281
282        // When hidden files are excluded
283        config.include_hidden = false;
284        assert!(config.should_skip_hidden(".git"));
285        assert!(!config.should_skip_hidden("src"));
286    }
287}