clean_dev_dirs/config/
file.rs1use std::path::{Path, PathBuf};
43
44use serde::Deserialize;
45
46#[derive(Deserialize, Default, Debug)]
51pub struct FileConfig {
52 pub project_type: Option<String>,
54
55 pub dirs: Option<Vec<PathBuf>>,
57
58 pub dir: Option<PathBuf>,
60
61 #[serde(default)]
63 pub filtering: FileFilterConfig,
64
65 #[serde(default)]
67 pub scanning: FileScanConfig,
68
69 #[serde(default)]
71 pub execution: FileExecutionConfig,
72}
73
74#[derive(Deserialize, Default, Debug)]
76pub struct FileFilterConfig {
77 pub keep_size: Option<String>,
79
80 pub keep_days: Option<u32>,
82
83 pub sort: Option<String>,
85
86 pub reverse: Option<bool>,
88
89 pub name_pattern: Option<String>,
91}
92
93#[derive(Deserialize, Default, Debug)]
95pub struct FileScanConfig {
96 pub threads: Option<usize>,
98
99 pub verbose: Option<bool>,
101
102 pub skip: Option<Vec<PathBuf>>,
104
105 pub ignore: Option<Vec<PathBuf>>,
107
108 pub max_depth: Option<usize>,
110}
111
112#[derive(Deserialize, Default, Debug)]
114pub struct FileExecutionConfig {
115 pub keep_executables: Option<bool>,
117
118 pub interactive: Option<bool>,
120
121 pub dry_run: Option<bool>,
123
124 pub use_trash: Option<bool>,
127}
128
129#[must_use]
142pub fn expand_tilde(path: &Path) -> PathBuf {
143 if let Ok(rest) = path.strip_prefix("~")
144 && let Some(home) = dirs::home_dir()
145 {
146 return home.join(rest);
147 }
148 path.to_path_buf()
149}
150
151impl FileConfig {
152 #[must_use]
163 pub fn config_path() -> Option<PathBuf> {
164 dirs::config_dir().map(|p| p.join("clean-dev-dirs").join("config.toml"))
165 }
166
167 pub fn load() -> anyhow::Result<Self> {
178 let Some(path) = Self::config_path() else {
179 return Ok(Self::default());
180 };
181
182 if !path.exists() {
183 return Ok(Self::default());
184 }
185
186 let content = std::fs::read_to_string(&path).map_err(|e| {
187 anyhow::anyhow!("Failed to read config file at {}: {e}", path.display())
188 })?;
189
190 let config: Self = toml::from_str(&content).map_err(|e| {
191 anyhow::anyhow!("Failed to parse config file at {}: {e}", path.display())
192 })?;
193
194 Ok(config)
195 }
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201
202 #[test]
203 fn test_default_file_config() {
204 let config = FileConfig::default();
205
206 assert!(config.project_type.is_none());
207 assert!(config.dirs.is_none());
208 assert!(config.dir.is_none());
209 assert!(config.filtering.keep_size.is_none());
210 assert!(config.filtering.keep_days.is_none());
211 assert!(config.filtering.sort.is_none());
212 assert!(config.filtering.reverse.is_none());
213 assert!(config.filtering.name_pattern.is_none());
214 assert!(config.scanning.threads.is_none());
215 assert!(config.scanning.verbose.is_none());
216 assert!(config.scanning.skip.is_none());
217 assert!(config.scanning.ignore.is_none());
218 assert!(config.execution.keep_executables.is_none());
219 assert!(config.execution.interactive.is_none());
220 assert!(config.execution.dry_run.is_none());
221 assert!(config.execution.use_trash.is_none());
222 }
223
224 #[test]
225 fn test_parse_full_config() {
226 let toml_content = r#"
227project_type = "rust"
228dir = "~/Projects"
229
230[filtering]
231keep_size = "50MB"
232keep_days = 7
233sort = "size"
234reverse = true
235name_pattern = "my-*"
236
237[scanning]
238threads = 4
239verbose = true
240skip = [".cargo", "vendor"]
241ignore = [".git"]
242
243[execution]
244keep_executables = true
245interactive = false
246dry_run = false
247use_trash = true
248"#;
249
250 let config: FileConfig = toml::from_str(toml_content).unwrap();
251
252 assert_eq!(config.project_type, Some("rust".to_string()));
253 assert_eq!(config.dir, Some(PathBuf::from("~/Projects")));
254 assert_eq!(config.filtering.keep_size, Some("50MB".to_string()));
255 assert_eq!(config.filtering.keep_days, Some(7));
256 assert_eq!(config.filtering.sort, Some("size".to_string()));
257 assert_eq!(config.filtering.reverse, Some(true));
258 assert_eq!(config.filtering.name_pattern, Some("my-*".to_string()));
259 assert_eq!(config.scanning.threads, Some(4));
260 assert_eq!(config.scanning.verbose, Some(true));
261 assert_eq!(
262 config.scanning.skip,
263 Some(vec![PathBuf::from(".cargo"), PathBuf::from("vendor")])
264 );
265 assert_eq!(config.scanning.ignore, Some(vec![PathBuf::from(".git")]));
266 assert_eq!(config.execution.keep_executables, Some(true));
267 assert_eq!(config.execution.interactive, Some(false));
268 assert_eq!(config.execution.dry_run, Some(false));
269 assert_eq!(config.execution.use_trash, Some(true));
270 }
271
272 #[test]
273 fn test_parse_dirs_field() {
274 let toml_content = r#"dirs = ["~/Projects", "~/work"]"#;
275 let config: FileConfig = toml::from_str(toml_content).unwrap();
276
277 assert_eq!(
278 config.dirs,
279 Some(vec![PathBuf::from("~/Projects"), PathBuf::from("~/work")])
280 );
281 assert!(config.dir.is_none());
282 }
283
284 #[test]
285 fn test_parse_partial_config() {
286 let toml_content = r#"
287[filtering]
288keep_size = "100MB"
289"#;
290
291 let config: FileConfig = toml::from_str(toml_content).unwrap();
292
293 assert!(config.project_type.is_none());
294 assert!(config.dir.is_none());
295 assert_eq!(config.filtering.keep_size, Some("100MB".to_string()));
296 assert!(config.filtering.keep_days.is_none());
297 assert!(config.filtering.sort.is_none());
298 assert!(config.filtering.reverse.is_none());
299 assert!(config.scanning.threads.is_none());
300 }
301
302 #[test]
303 fn test_parse_empty_config() {
304 let toml_content = "";
305 let config: FileConfig = toml::from_str(toml_content).unwrap();
306
307 assert!(config.project_type.is_none());
308 assert!(config.dir.is_none());
309 }
310
311 #[test]
312 fn test_malformed_config_errors() {
313 let toml_content = r#"
314[filtering]
315keep_days = "not_a_number"
316"#;
317 let result = toml::from_str::<FileConfig>(toml_content);
318 assert!(result.is_err());
319 }
320
321 #[test]
322 fn test_config_path_returns_expected_suffix() {
323 let path = FileConfig::config_path();
324 if let Some(p) = path {
325 assert!(p.ends_with("clean-dev-dirs/config.toml"));
326 }
327 }
328
329 #[test]
330 fn test_load_returns_defaults_when_no_file() {
331 let config = FileConfig::load().unwrap();
332 assert!(config.project_type.is_none());
333 assert!(config.dir.is_none());
334 }
335
336 #[test]
337 fn test_expand_tilde_with_home() {
338 let path = PathBuf::from("~/Projects");
339 let expanded = expand_tilde(&path);
340
341 if let Some(home) = dirs::home_dir() {
342 assert_eq!(expanded, home.join("Projects"));
343 }
344 }
345
346 #[test]
347 fn test_expand_tilde_absolute_path_unchanged() {
348 let path = PathBuf::from("/absolute/path");
349 let expanded = expand_tilde(&path);
350 assert_eq!(expanded, PathBuf::from("/absolute/path"));
351 }
352
353 #[test]
354 fn test_expand_tilde_relative_path_unchanged() {
355 let path = PathBuf::from("relative/path");
356 let expanded = expand_tilde(&path);
357 assert_eq!(expanded, PathBuf::from("relative/path"));
358 }
359
360 #[test]
361 fn test_expand_tilde_bare() {
362 let path = PathBuf::from("~");
363 let expanded = expand_tilde(&path);
364
365 if let Some(home) = dirs::home_dir() {
366 assert_eq!(expanded, home);
367 }
368 }
369
370 #[test]
373 fn test_config_path_is_platform_appropriate() {
374 let path = FileConfig::config_path();
375
376 if let Some(p) = &path {
379 let path_str = p.to_string_lossy();
380
381 #[cfg(target_os = "linux")]
382 assert!(
383 path_str.contains(".config"),
384 "Linux config path should be under $XDG_CONFIG_HOME or ~/.config, got: {path_str}"
385 );
386
387 #[cfg(target_os = "macos")]
388 assert!(
389 path_str.contains("Application Support") || path_str.contains(".config"),
390 "macOS config path should be under Library/Application Support, got: {path_str}"
391 );
392
393 #[cfg(target_os = "windows")]
394 assert!(
395 path_str.contains("AppData"),
396 "Windows config path should be under AppData, got: {path_str}"
397 );
398
399 assert!(
401 p.ends_with("clean-dev-dirs/config.toml")
402 || p.ends_with(Path::new("clean-dev-dirs").join("config.toml"))
403 );
404 }
405 }
406
407 #[test]
408 fn test_config_path_parent_exists_or_can_be_created() {
409 if let Some(path) = FileConfig::config_path()
412 && let Some(grandparent) = path.parent().and_then(Path::parent)
413 {
414 assert!(
416 grandparent.exists(),
417 "Config grandparent directory should exist: {}",
418 grandparent.display()
419 );
420 }
421 }
422
423 #[test]
424 fn test_expand_tilde_deeply_nested() {
425 let path = PathBuf::from("~/a/b/c/d");
426 let expanded = expand_tilde(&path);
427
428 if let Some(home) = dirs::home_dir() {
429 assert_eq!(expanded, home.join("a").join("b").join("c").join("d"));
430 assert!(!expanded.to_string_lossy().contains('~'));
431 }
432 }
433
434 #[test]
435 fn test_expand_tilde_no_effect_on_non_tilde() {
436 let relative = PathBuf::from("some/relative/path");
438 assert_eq!(expand_tilde(&relative), relative);
439
440 let absolute = PathBuf::from("/usr/local/bin");
442 assert_eq!(expand_tilde(&absolute), absolute);
443
444 #[cfg(windows)]
446 {
447 let win_abs = PathBuf::from(r"C:\Users\user\Documents");
448 assert_eq!(expand_tilde(&win_abs), win_abs);
449 }
450 }
451
452 #[test]
453 fn test_config_toml_parsing_with_platform_paths() {
454 let toml_unix = "dir = \"/home/user/projects\"\n";
456 let config: FileConfig = toml::from_str(toml_unix).unwrap();
457 assert_eq!(config.dir, Some(PathBuf::from("/home/user/projects")));
458
459 let toml_tilde = "dir = \"~/Projects\"\n";
460 let config: FileConfig = toml::from_str(toml_tilde).unwrap();
461 assert_eq!(config.dir, Some(PathBuf::from("~/Projects")));
462
463 let toml_relative = "dir = \"./projects\"\n";
464 let config: FileConfig = toml::from_str(toml_relative).unwrap();
465 assert_eq!(config.dir, Some(PathBuf::from("./projects")));
466 }
467
468 #[test]
469 fn test_file_config_all_execution_options_parse() {
470 let toml_content = r"
471[execution]
472keep_executables = true
473interactive = false
474dry_run = true
475use_trash = false
476";
477 let config: FileConfig = toml::from_str(toml_content).unwrap();
478
479 assert_eq!(config.execution.keep_executables, Some(true));
480 assert_eq!(config.execution.interactive, Some(false));
481 assert_eq!(config.execution.dry_run, Some(true));
482 assert_eq!(config.execution.use_trash, Some(false));
483 }
484}