clean_dev_dirs/config/
file.rs1use std::path::{Path, PathBuf};
35
36use serde::Deserialize;
37
38#[derive(Deserialize, Default, Debug)]
43pub struct FileConfig {
44 pub project_type: Option<String>,
46
47 pub dir: Option<PathBuf>,
49
50 #[serde(default)]
52 pub filtering: FileFilterConfig,
53
54 #[serde(default)]
56 pub scanning: FileScanConfig,
57
58 #[serde(default)]
60 pub execution: FileExecutionConfig,
61}
62
63#[derive(Deserialize, Default, Debug)]
65pub struct FileFilterConfig {
66 pub keep_size: Option<String>,
68
69 pub keep_days: Option<u32>,
71}
72
73#[derive(Deserialize, Default, Debug)]
75pub struct FileScanConfig {
76 pub threads: Option<usize>,
78
79 pub verbose: Option<bool>,
81
82 pub skip: Option<Vec<PathBuf>>,
84
85 pub ignore: Option<Vec<PathBuf>>,
87}
88
89#[derive(Deserialize, Default, Debug)]
91pub struct FileExecutionConfig {
92 pub keep_executables: Option<bool>,
94
95 pub interactive: Option<bool>,
97
98 pub dry_run: Option<bool>,
100}
101
102#[must_use]
115pub fn expand_tilde(path: &Path) -> PathBuf {
116 if let Ok(rest) = path.strip_prefix("~")
117 && let Some(home) = dirs::home_dir()
118 {
119 return home.join(rest);
120 }
121 path.to_path_buf()
122}
123
124impl FileConfig {
125 #[must_use]
136 pub fn config_path() -> Option<PathBuf> {
137 dirs::config_dir().map(|p| p.join("clean-dev-dirs").join("config.toml"))
138 }
139
140 pub fn load() -> anyhow::Result<Self> {
151 let Some(path) = Self::config_path() else {
152 return Ok(Self::default());
153 };
154
155 if !path.exists() {
156 return Ok(Self::default());
157 }
158
159 let content = std::fs::read_to_string(&path).map_err(|e| {
160 anyhow::anyhow!("Failed to read config file at {}: {e}", path.display())
161 })?;
162
163 let config: Self = toml::from_str(&content).map_err(|e| {
164 anyhow::anyhow!("Failed to parse config file at {}: {e}", path.display())
165 })?;
166
167 Ok(config)
168 }
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174
175 #[test]
176 fn test_default_file_config() {
177 let config = FileConfig::default();
178
179 assert!(config.project_type.is_none());
180 assert!(config.dir.is_none());
181 assert!(config.filtering.keep_size.is_none());
182 assert!(config.filtering.keep_days.is_none());
183 assert!(config.scanning.threads.is_none());
184 assert!(config.scanning.verbose.is_none());
185 assert!(config.scanning.skip.is_none());
186 assert!(config.scanning.ignore.is_none());
187 assert!(config.execution.keep_executables.is_none());
188 assert!(config.execution.interactive.is_none());
189 assert!(config.execution.dry_run.is_none());
190 }
191
192 #[test]
193 fn test_parse_full_config() {
194 let toml_content = r#"
195project_type = "rust"
196dir = "~/Projects"
197
198[filtering]
199keep_size = "50MB"
200keep_days = 7
201
202[scanning]
203threads = 4
204verbose = true
205skip = [".cargo", "vendor"]
206ignore = [".git"]
207
208[execution]
209keep_executables = true
210interactive = false
211dry_run = false
212"#;
213
214 let config: FileConfig = toml::from_str(toml_content).unwrap();
215
216 assert_eq!(config.project_type, Some("rust".to_string()));
217 assert_eq!(config.dir, Some(PathBuf::from("~/Projects")));
218 assert_eq!(config.filtering.keep_size, Some("50MB".to_string()));
219 assert_eq!(config.filtering.keep_days, Some(7));
220 assert_eq!(config.scanning.threads, Some(4));
221 assert_eq!(config.scanning.verbose, Some(true));
222 assert_eq!(
223 config.scanning.skip,
224 Some(vec![PathBuf::from(".cargo"), PathBuf::from("vendor")])
225 );
226 assert_eq!(config.scanning.ignore, Some(vec![PathBuf::from(".git")]));
227 assert_eq!(config.execution.keep_executables, Some(true));
228 assert_eq!(config.execution.interactive, Some(false));
229 assert_eq!(config.execution.dry_run, Some(false));
230 }
231
232 #[test]
233 fn test_parse_partial_config() {
234 let toml_content = r#"
235[filtering]
236keep_size = "100MB"
237"#;
238
239 let config: FileConfig = toml::from_str(toml_content).unwrap();
240
241 assert!(config.project_type.is_none());
242 assert!(config.dir.is_none());
243 assert_eq!(config.filtering.keep_size, Some("100MB".to_string()));
244 assert!(config.filtering.keep_days.is_none());
245 assert!(config.scanning.threads.is_none());
246 }
247
248 #[test]
249 fn test_parse_empty_config() {
250 let toml_content = "";
251 let config: FileConfig = toml::from_str(toml_content).unwrap();
252
253 assert!(config.project_type.is_none());
254 assert!(config.dir.is_none());
255 }
256
257 #[test]
258 fn test_malformed_config_errors() {
259 let toml_content = r#"
260[filtering]
261keep_days = "not_a_number"
262"#;
263 let result = toml::from_str::<FileConfig>(toml_content);
264 assert!(result.is_err());
265 }
266
267 #[test]
268 fn test_config_path_returns_expected_suffix() {
269 let path = FileConfig::config_path();
270 if let Some(p) = path {
271 assert!(p.ends_with("clean-dev-dirs/config.toml"));
272 }
273 }
274
275 #[test]
276 fn test_load_returns_defaults_when_no_file() {
277 let config = FileConfig::load().unwrap();
278 assert!(config.project_type.is_none());
279 assert!(config.dir.is_none());
280 }
281
282 #[test]
283 fn test_expand_tilde_with_home() {
284 let path = PathBuf::from("~/Projects");
285 let expanded = expand_tilde(&path);
286
287 if let Some(home) = dirs::home_dir() {
288 assert_eq!(expanded, home.join("Projects"));
289 }
290 }
291
292 #[test]
293 fn test_expand_tilde_absolute_path_unchanged() {
294 let path = PathBuf::from("/absolute/path");
295 let expanded = expand_tilde(&path);
296 assert_eq!(expanded, PathBuf::from("/absolute/path"));
297 }
298
299 #[test]
300 fn test_expand_tilde_relative_path_unchanged() {
301 let path = PathBuf::from("relative/path");
302 let expanded = expand_tilde(&path);
303 assert_eq!(expanded, PathBuf::from("relative/path"));
304 }
305
306 #[test]
307 fn test_expand_tilde_bare() {
308 let path = PathBuf::from("~");
309 let expanded = expand_tilde(&path);
310
311 if let Some(home) = dirs::home_dir() {
312 assert_eq!(expanded, home);
313 }
314 }
315}