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