Skip to main content

chezmoi_files/
config.rs

1//! Configuration module for file filtering.
2//!
3//! This module handles loading and parsing configuration from a TOML file
4//! located at `~/.config/chezmoi/chezmoi-files.toml`.
5//!
6//! # Examples
7//!
8//! ```
9//! use chezmoi_files::Config;
10//!
11//! // Load configuration from file (or use defaults)
12//! let config = Config::new();
13//!
14//! // Check if a path should be excluded
15//! assert!(config.is_excluded("DS_Store"));
16//! assert!(!config.is_excluded("regular_file.txt"));
17//!
18//! // Use default configuration
19//! let default_config = Config::default();
20//! ```
21
22use serde::Deserialize;
23use std::collections::HashMap;
24use std::env;
25use std::fs;
26use std::path::PathBuf;
27
28/// Configuration for file filtering.
29///
30/// This struct contains lists of files to exclude and include when processing paths.
31#[derive(Debug, Deserialize)]
32pub struct Config {
33    /// List of files to exclude from the tree visualization.
34    #[serde(rename = "excluded-files", default)]
35    pub excluded_files: FileList,
36    /// List of files to include (overrides exclusions).
37    #[serde(rename = "included-files", default)]
38    pub included_files: FileList,
39    /// Color configuration.
40    #[serde(default)]
41    pub colors: ColorConfig,
42}
43
44/// A list of file patterns.
45#[derive(Debug, Deserialize, Default)]
46pub struct FileList {
47    /// The file patterns to match against.
48    #[serde(default)]
49    pub files: Vec<String>,
50}
51
52/// Color configuration for the tree output.
53#[derive(Debug, Deserialize)]
54pub struct ColorConfig {
55    /// Whether colors are enabled.
56    #[serde(default = "default_true")]
57    pub enabled: bool,
58    /// Color for folders.
59    pub folder: Option<String>,
60    /// Default color for files.
61    #[serde(rename = "default-file")]
62    pub default_file: Option<String>,
63    /// Colors for specific file extensions.
64    #[serde(default)]
65    pub extensions: HashMap<String, String>,
66}
67
68const fn default_true() -> bool {
69    true
70}
71
72impl Default for ColorConfig {
73    fn default() -> Self {
74        Self {
75            enabled: true,
76            folder: None,
77            default_file: None,
78            extensions: HashMap::new(),
79        }
80    }
81}
82
83impl Config {
84    /// Creates a new `Config` by loading from the configuration file.
85    ///
86    /// The configuration file is located at `~/.config/chezmoi/chezmoi-files.toml`.
87    /// If the file doesn't exist or cannot be parsed, default values are used.
88    ///
89    /// # Default Exclusions
90    ///
91    /// - `DS_Store`
92    /// - `fish_variables*`
93    /// - `.rubocop.yml`
94    /// - `.ruff_cache`
95    /// - `yazi.toml-`
96    /// - `.zcompcache`
97    /// - `.zcompdump`
98    /// - `.zsh_history`
99    /// - `plugins/fish`
100    /// - `plugins/zsh`
101    ///
102    /// # Example
103    ///
104    /// ```no_run
105    /// use chezmoi_files::Config;
106    ///
107    /// let config = Config::new();
108    /// ```
109    #[must_use]
110    pub fn new() -> Self {
111        let config_path = Self::config_path();
112
113        match fs::read_to_string(&config_path) {
114            Ok(content) if !content.trim().is_empty() => match toml::from_str(&content) {
115                Ok(config) => config,
116                Err(e) => {
117                    eprintln!(
118                        "Warning: failed to parse config file {}: {e}",
119                        config_path.display()
120                    );
121                    Self::default()
122                }
123            },
124            _ => Self::default(),
125        }
126    }
127
128    /// Returns the path to the configuration file.
129    ///
130    /// Uses `~/.config/chezmoi/chezmoi-files.toml` as the standard location.
131    #[must_use]
132    pub fn config_path() -> PathBuf {
133        let home = env::var("HOME").unwrap_or_else(|_| String::from("."));
134        PathBuf::from(home)
135            .join(".config")
136            .join("chezmoi")
137            .join("chezmoi-files.toml")
138    }
139
140    /// Returns the default configuration as a TOML string.
141    ///
142    /// This is useful for creating a default configuration file.
143    #[must_use]
144    pub fn default_config_toml() -> String {
145        r#"# Configuration for chezmoi-files
146# Edit this file to customize which files are excluded from the tree visualization
147
148[excluded-files]
149# Patterns support glob-style wildcards: *, ?, [abc], [a-z]
150# Examples:
151#   "*.tmp"        - matches any file ending in .tmp
152#   "cache/*"      - matches any file in a cache directory
153#   "test_*.rs"    - matches test_foo.rs, test_bar.rs, etc.
154files = [
155    "DS_Store",
156    "fish_variables*",
157    ".rubocop.yml",
158    ".ruff_cache",
159    "yazi.toml-*",
160    ".zcompcache",
161    ".zcompdump",
162    ".zsh_history",
163    "plugins/fish",
164    "plugins/zsh",
165]
166
167[included-files]
168# Files matching these patterns will be included even if they match exclusions
169files = []
170
171[colors]
172# Set to false to disable colors entirely
173enabled = true
174
175# Customize colors for folders and files
176# Available colors: black, red, green, yellow, blue, magenta, cyan, white
177# You can also use custom ANSI codes like "\x1b[1;32m"
178# folder = "white"
179# default-file = "blue"
180
181# Customize colors for specific file extensions
182# [colors.extensions]
183# ".rs" = "red"
184# ".py" = "green"
185# ".md" = "cyan"
186"#
187        .to_string()
188    }
189
190    /// Checks if a path matches any exclusion pattern using glob matching.
191    ///
192    /// # Arguments
193    ///
194    /// * `path` - The path to check against exclusion patterns
195    ///
196    /// # Returns
197    ///
198    /// `true` if the path matches any exclusion pattern, `false` otherwise
199    #[must_use]
200    pub fn is_excluded(&self, path: &str) -> bool {
201        self.excluded_files
202            .files
203            .iter()
204            .any(|pattern| Self::matches_glob(path, pattern))
205    }
206
207    /// Checks if a path matches any inclusion pattern using glob matching.
208    ///
209    /// # Arguments
210    ///
211    /// * `path` - The path to check against inclusion patterns
212    ///
213    /// # Returns
214    ///
215    /// `true` if the path matches any inclusion pattern, `false` otherwise
216    #[must_use]
217    pub fn is_included(&self, path: &str) -> bool {
218        self.included_files
219            .files
220            .iter()
221            .any(|pattern| Self::matches_glob(path, pattern))
222    }
223
224    /// Matches a path against a glob pattern.
225    ///
226    /// Supports wildcards: `*`, `?`, `[abc]`, `[a-z]`
227    fn matches_glob(path: &str, pattern: &str) -> bool {
228        // If pattern contains glob characters, use glob matching
229        if (pattern.contains('*') || pattern.contains('?') || pattern.contains('['))
230            && let Ok(glob_pattern) = glob::Pattern::new(pattern)
231        {
232            // Try matching the full path
233            if glob_pattern.matches(path) {
234                return true;
235            }
236            // Also try matching any component of the path
237            return path
238                .split('/')
239                .any(|component| glob_pattern.matches(component));
240        }
241
242        // Fall back to substring matching
243        path.contains(pattern)
244    }
245}
246
247impl Default for Config {
248    fn default() -> Self {
249        Self {
250            excluded_files: FileList {
251                files: vec![
252                    "DS_Store",
253                    "fish_variables*",
254                    ".rubocop.yml",
255                    ".ruff_cache",
256                    "yazi.toml-*",
257                    ".zcompcache",
258                    ".zcompdump",
259                    ".zsh_history",
260                    "plugins/fish",
261                    "plugins/zsh",
262                ]
263                .into_iter()
264                .map(String::from)
265                .collect(),
266            },
267            included_files: FileList { files: Vec::new() },
268            colors: ColorConfig::default(),
269        }
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn test_matches_glob_simple_substring() {
279        assert!(Config::matches_glob("path/to/DS_Store", "DS_Store"));
280        assert!(Config::matches_glob("foo/bar/baz", "bar"));
281        assert!(!Config::matches_glob("foo/baz", "bar"));
282    }
283
284    #[test]
285    fn test_matches_glob_wildcard() {
286        assert!(Config::matches_glob(
287            "fish_variables.bak",
288            "fish_variables*"
289        ));
290        assert!(Config::matches_glob("yazi.toml-old", "yazi.toml-*"));
291        assert!(Config::matches_glob("test.tmp", "*.tmp"));
292        assert!(!Config::matches_glob("test.txt", "*.tmp"));
293    }
294
295    #[test]
296    fn test_matches_glob_question_mark() {
297        assert!(Config::matches_glob("test1.txt", "test?.txt"));
298        assert!(Config::matches_glob("testA.txt", "test?.txt"));
299        assert!(!Config::matches_glob("test12.txt", "test?.txt"));
300    }
301
302    #[test]
303    fn test_matches_glob_character_class() {
304        assert!(Config::matches_glob("testa.txt", "test[abc].txt"));
305        assert!(Config::matches_glob("testb.txt", "test[abc].txt"));
306        assert!(!Config::matches_glob("testd.txt", "test[abc].txt"));
307    }
308
309    #[test]
310    fn test_is_excluded() {
311        let config = Config::default();
312
313        assert!(config.is_excluded("path/to/DS_Store"));
314        assert!(config.is_excluded("config/fish_variables"));
315        assert!(config.is_excluded("config/fish_variables.bak"));
316        assert!(config.is_excluded(".rubocop.yml"));
317        assert!(!config.is_excluded("regular_file.txt"));
318    }
319
320    #[test]
321    fn test_inclusion_overrides_exclusion() {
322        let mut config = Config::default();
323        config
324            .included_files
325            .files
326            .push("important.txt".to_string());
327        config.excluded_files.files.push("*.txt".to_string());
328
329        assert!(!config.is_excluded("important.txt") || config.is_included("important.txt"));
330    }
331
332    #[test]
333    fn test_default_config_has_colors() {
334        let config = Config::default();
335        assert!(config.colors.enabled);
336    }
337
338    #[test]
339    fn test_is_included() {
340        let mut config = Config::default();
341        config
342            .included_files
343            .files
344            .push("important.txt".to_string());
345
346        assert!(config.is_included("important.txt"));
347        assert!(config.is_included("path/to/important.txt"));
348        assert!(!config.is_included("other.txt"));
349    }
350
351    #[test]
352    fn test_config_new_with_missing_file() {
353        // This should not panic even if config file doesn't exist
354        let config = Config::new();
355        // Config should be initialized (either from file or defaults)
356        // Verify that colors field exists and is accessible
357        let _ = config.colors.enabled;
358    }
359
360    #[test]
361    fn test_config_path() {
362        let path = Config::config_path();
363        assert!(path.to_string_lossy().contains("chezmoi-files.toml"));
364    }
365
366    #[test]
367    fn test_default_config_toml() {
368        let toml = Config::default_config_toml();
369        assert!(toml.contains("[excluded-files]"));
370        assert!(toml.contains("[included-files]"));
371        assert!(toml.contains("[colors]"));
372        assert!(toml.contains("DS_Store"));
373    }
374
375    #[test]
376    fn test_file_list_default() {
377        let file_list = FileList::default();
378        assert_eq!(file_list.files.len(), 0);
379    }
380
381    #[test]
382    fn test_color_config_default() {
383        let color_config = ColorConfig::default();
384        assert!(color_config.enabled);
385        assert!(color_config.folder.is_none());
386        assert!(color_config.default_file.is_none());
387        assert_eq!(color_config.extensions.len(), 0);
388    }
389
390    #[test]
391    fn test_matches_glob_path_components() {
392        // Test that patterns match path components, not just the full path
393        assert!(Config::matches_glob("dir/cache/file.txt", "cache"));
394        assert!(Config::matches_glob("a/b/c/test.tmp", "*.tmp"));
395    }
396
397    #[test]
398    fn test_matches_glob_invalid_pattern() {
399        // Invalid glob patterns should fall back to substring matching
400        assert!(Config::matches_glob("test[file", "test[file"));
401    }
402
403    #[test]
404    fn test_matches_glob_range() {
405        assert!(Config::matches_glob("test1.txt", "test[0-9].txt"));
406        assert!(Config::matches_glob("test5.txt", "test[0-9].txt"));
407        assert!(!Config::matches_glob("testa.txt", "test[0-9].txt"));
408    }
409
410    #[test]
411    fn test_exclusion_patterns_with_wildcards() {
412        let config = Config::default();
413        // Test wildcard patterns from default config
414        assert!(config.is_excluded("fish_variables"));
415        assert!(config.is_excluded("fish_variables.bak"));
416        assert!(config.is_excluded("yazi.toml-old"));
417        assert!(config.is_excluded("yazi.toml-backup"));
418    }
419}