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