1use serde::Deserialize;
23use std::collections::HashMap;
24use std::env;
25use std::fs;
26use std::path::PathBuf;
27
28#[derive(Debug, Deserialize)]
32pub struct Config {
33 #[serde(rename = "excluded-files", default)]
35 pub excluded_files: FileList,
36 #[serde(rename = "included-files", default)]
38 pub included_files: FileList,
39 #[serde(default)]
41 pub colors: ColorConfig,
42}
43
44#[derive(Debug, Deserialize, Default)]
46pub struct FileList {
47 #[serde(default)]
49 pub files: Vec<String>,
50}
51
52#[derive(Debug, Deserialize)]
54pub struct ColorConfig {
55 #[serde(default = "default_true")]
57 pub enabled: bool,
58 pub folder: Option<String>,
60 #[serde(rename = "default-file")]
62 pub default_file: Option<String>,
63 #[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 #[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 #[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 #[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 #[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 #[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 fn matches_glob(path: &str, pattern: &str) -> bool {
228 if (pattern.contains('*') || pattern.contains('?') || pattern.contains('['))
230 && let Ok(glob_pattern) = glob::Pattern::new(pattern)
231 {
232 if glob_pattern.matches(path) {
234 return true;
235 }
236 return path
238 .split('/')
239 .any(|component| glob_pattern.matches(component));
240 }
241
242 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 let config = Config::new();
355 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 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 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 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}