1use crate::error::{parse_duration_days, Error, Result};
2use globset::{Glob, GlobSet, GlobSetBuilder};
3use serde::{Deserialize, Serialize};
4use std::path::Path;
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
8#[serde(deny_unknown_fields)]
9pub struct ConfigFile {
10 #[serde(default)]
12 pub triggers: Option<Vec<String>>,
13
14 #[serde(default)]
16 pub fuse_days: Option<u32>,
17
18 #[serde(default)]
20 pub exclude: Option<Vec<String>>,
21
22 #[serde(default)]
24 pub extensions: Option<Vec<String>>,
25
26 #[serde(default)]
28 pub max_detonated: Option<usize>,
29
30 #[serde(default)]
32 pub max_ticking: Option<usize>,
33}
34
35#[derive(Debug, Clone)]
37pub struct Config {
38 pub triggers: Vec<String>,
39 pub fuse_days: u32,
40 pub exclude_patterns: Vec<String>,
41 pub extensions: Vec<String>,
42 pub fail_on_ticking: bool,
44 pub diff_files: Option<std::collections::HashSet<std::path::PathBuf>>,
47 pub max_detonated: Option<usize>,
49 pub max_ticking: Option<usize>,
51}
52
53impl Default for Config {
54 fn default() -> Self {
55 Config {
56 triggers: default_triggers(),
57 fuse_days: 0,
58 exclude_patterns: default_excludes(),
59 extensions: default_extensions(),
60 fail_on_ticking: false,
61 diff_files: None,
62 max_detonated: None,
63 max_ticking: None,
64 }
65 }
66}
67
68fn default_triggers() -> Vec<String> {
69 vec![
70 "TODO".to_string(),
71 "FIXME".to_string(),
72 "HACK".to_string(),
73 "TEMP".to_string(),
74 "REMOVEME".to_string(),
75 "DEBT".to_string(),
76 "STOPSHIP".to_string(),
77 "WORKAROUND".to_string(),
78 "DEPRECATED".to_string(),
79 "BUG".to_string(),
80 ]
81}
82
83fn default_excludes() -> Vec<String> {
84 vec![
85 "vendor/**".to_string(),
86 "node_modules/**".to_string(),
87 "*.min.js".to_string(),
88 ".git/**".to_string(),
89 ]
90}
91
92fn default_extensions() -> Vec<String> {
93 vec![
94 "rs".to_string(),
95 "go".to_string(),
96 "ts".to_string(),
97 "js".to_string(),
98 "py".to_string(),
99 "rb".to_string(),
100 "java".to_string(),
101 "cs".to_string(),
102 "fs".to_string(),
103 "hs".to_string(),
104 "php".to_string(),
105 "clj".to_string(),
106 "lisp".to_string(),
107 "rkt".to_string(),
108 "ex".to_string(),
109 "erl".to_string(),
110 "c".to_string(),
111 "cpp".to_string(),
112 "d".to_string(),
113 "swift".to_string(),
114 "ml".to_string(),
115 "lua".to_string(),
116 "dart".to_string(),
117 "kt".to_string(),
118 "sql".to_string(),
119 "tf".to_string(),
120 "yaml".to_string(),
121 "yml".to_string(),
122 ]
123}
124
125#[derive(Debug, Default, Clone)]
127pub struct CliOverrides {
128 pub fuse: Option<String>,
130 pub fail_on_ticking: bool,
132}
133
134impl CliOverrides {
135 pub fn new(fuse: Option<String>, fail_on_ticking: bool) -> Self {
136 CliOverrides {
137 fuse,
138 fail_on_ticking,
139 }
140 }
141}
142
143pub fn load_config(root_dir: &Path, overrides: &CliOverrides) -> Result<Config> {
151 let config_path = root_dir.join(".timebomb.toml");
152 let file_cfg = if config_path.exists() {
153 Some(read_config_file(&config_path)?)
154 } else {
155 None
156 };
157
158 merge_config(file_cfg, overrides)
159}
160
161fn read_config_file(path: &Path) -> Result<ConfigFile> {
162 let content = std::fs::read_to_string(path).map_err(|e| Error::ConfigRead {
163 source: e,
164 path: path.to_path_buf(),
165 })?;
166 toml::from_str(&content).map_err(|e| Error::ConfigParse {
167 source: e,
168 path: path.to_path_buf(),
169 })
170}
171
172fn merge_config(file_cfg: Option<ConfigFile>, overrides: &CliOverrides) -> Result<Config> {
173 let defaults = Config::default();
174
175 let triggers = file_cfg
176 .as_ref()
177 .and_then(|c| c.triggers.clone())
178 .unwrap_or(defaults.triggers);
179
180 let mut fuse_days = file_cfg
181 .as_ref()
182 .and_then(|c| c.fuse_days)
183 .unwrap_or(defaults.fuse_days);
184
185 let exclude_patterns = file_cfg
186 .as_ref()
187 .and_then(|c| c.exclude.clone())
188 .unwrap_or(defaults.exclude_patterns);
189
190 let extensions = file_cfg
191 .as_ref()
192 .and_then(|c| c.extensions.clone())
193 .unwrap_or(defaults.extensions);
194
195 let max_detonated = file_cfg.as_ref().and_then(|c| c.max_detonated);
196 let max_ticking = file_cfg.as_ref().and_then(|c| c.max_ticking);
197
198 if let Some(ref w) = overrides.fuse {
200 fuse_days = parse_duration_days(w)?;
201 }
202
203 Ok(Config {
204 triggers,
205 fuse_days,
206 exclude_patterns,
207 extensions,
208 fail_on_ticking: overrides.fail_on_ticking,
209 diff_files: None,
210 max_detonated,
211 max_ticking,
212 })
213}
214
215impl Config {
216 pub fn build_exclude_globset(&self) -> Result<GlobSet> {
218 let mut builder = GlobSetBuilder::new();
219 for pattern in &self.exclude_patterns {
220 let glob = Glob::new(pattern).map_err(|e| Error::InvalidGlob {
221 pattern: pattern.clone(),
222 source: e,
223 })?;
224 builder.add(glob);
225 }
226 builder.build().map_err(|e| Error::InvalidGlob {
227 pattern: "(combined)".to_string(),
228 source: e,
229 })
230 }
231
232 pub fn is_excluded(&self, path: &Path, globset: &GlobSet) -> bool {
235 if globset.is_match(path) {
237 return true;
238 }
239 if let Some(fname) = path.file_name() {
242 if globset.is_match(Path::new(fname)) {
243 return true;
244 }
245 }
246 false
247 }
248
249 pub fn extension_allowed(&self, path: &Path) -> bool {
252 if self.extensions.is_empty() {
253 return true;
254 }
255 match path.extension().and_then(|e| e.to_str()) {
256 Some(ext) => self
257 .extensions
258 .iter()
259 .any(|allowed| allowed.eq_ignore_ascii_case(ext)),
260 None => false,
261 }
262 }
263
264 pub fn fuse_regex_pattern(&self) -> String {
266 let triggers_alternation = self.triggers.join("|");
267 format!(
268 r"(?i)({tags})\[(\d{{4}}-\d{{2}}-\d{{2}})\](\[([^\]]+)\])?:\s*(.+)",
269 tags = triggers_alternation
270 )
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use std::io::Write;
278 use tempfile::NamedTempFile;
279
280 fn write_toml(content: &str) -> NamedTempFile {
281 let mut f = NamedTempFile::new().unwrap();
282 write!(f, "{}", content).unwrap();
283 f
284 }
285
286 #[test]
287 fn test_default_config() {
288 let cfg = Config::default();
289 assert!(cfg.triggers.contains(&"TODO".to_string()));
290 assert!(cfg.triggers.contains(&"FIXME".to_string()));
291 assert_eq!(cfg.fuse_days, 0);
292 assert!(!cfg.extensions.is_empty());
293 assert!(!cfg.fail_on_ticking);
294 }
295
296 #[test]
297 fn test_merge_no_file_no_overrides() {
298 let cfg = merge_config(None, &CliOverrides::default()).unwrap();
299 assert_eq!(cfg.triggers, default_triggers());
300 assert_eq!(cfg.fuse_days, 0);
301 }
302
303 #[test]
304 fn test_merge_file_overrides_triggers() {
305 let file_cfg = ConfigFile {
306 triggers: Some(vec!["TODO".to_string(), "FIXME".to_string()]),
307 fuse_days: Some(7),
308 exclude: None,
309 extensions: None,
310 max_detonated: None,
311 max_ticking: None,
312 };
313 let cfg = merge_config(Some(file_cfg), &CliOverrides::default()).unwrap();
314 assert_eq!(cfg.triggers, vec!["TODO", "FIXME"]);
315 assert_eq!(cfg.fuse_days, 7);
316 assert!(cfg.extensions.contains(&"rs".to_string()));
318 }
319
320 #[test]
321 fn test_cli_override_fuse() {
322 let overrides = CliOverrides::new(Some("30d".to_string()), false);
323 let cfg = merge_config(None, &overrides).unwrap();
324 assert_eq!(cfg.fuse_days, 30);
325 }
326
327 #[test]
328 fn test_cli_override_fail_on_ticking() {
329 let overrides = CliOverrides::new(None, true);
330 let cfg = merge_config(None, &overrides).unwrap();
331 assert!(cfg.fail_on_ticking);
332 }
333
334 #[test]
335 fn test_cli_fuse_overrides_file() {
336 let file_cfg = ConfigFile {
337 triggers: None,
338 fuse_days: Some(7),
339 exclude: None,
340 extensions: None,
341 max_detonated: None,
342 max_ticking: None,
343 };
344 let overrides = CliOverrides::new(Some("30d".to_string()), false);
345 let cfg = merge_config(Some(file_cfg), &overrides).unwrap();
346 assert_eq!(cfg.fuse_days, 30);
348 }
349
350 #[test]
351 fn test_cli_invalid_duration() {
352 let overrides = CliOverrides::new(Some("notadate".to_string()), false);
353 let result = merge_config(None, &overrides);
354 assert!(result.is_err());
355 }
356
357 #[test]
358 fn test_extension_allowed_rs() {
359 let cfg = Config::default();
360 assert!(cfg.extension_allowed(Path::new("src/main.rs")));
361 assert!(cfg.extension_allowed(Path::new("src/lib.go")));
362 }
363
364 #[test]
365 fn test_extension_allowed_unknown() {
366 let cfg = Config::default();
367 assert!(!cfg.extension_allowed(Path::new("file.xyz")));
368 assert!(!cfg.extension_allowed(Path::new("Makefile")));
369 }
370
371 #[test]
372 fn test_extension_empty_allows_all() {
373 let cfg = Config {
374 extensions: vec![],
375 ..Config::default()
376 };
377 assert!(cfg.extension_allowed(Path::new("anything.xyz")));
378 assert!(cfg.extension_allowed(Path::new("Makefile")));
379 }
380
381 #[test]
382 fn test_is_excluded_git() {
383 let cfg = Config::default();
384 let gs = cfg.build_exclude_globset().unwrap();
385 assert!(cfg.is_excluded(Path::new(".git/config"), &gs));
386 assert!(cfg.is_excluded(Path::new(".git/HEAD"), &gs));
387 }
388
389 #[test]
390 fn test_is_excluded_node_modules() {
391 let cfg = Config::default();
392 let gs = cfg.build_exclude_globset().unwrap();
393 assert!(cfg.is_excluded(Path::new("node_modules/lodash/index.js"), &gs));
394 }
395
396 #[test]
397 fn test_is_not_excluded_src() {
398 let cfg = Config::default();
399 let gs = cfg.build_exclude_globset().unwrap();
400 assert!(!cfg.is_excluded(Path::new("src/main.rs"), &gs));
401 }
402
403 #[test]
404 fn test_fuse_regex_pattern_contains_triggers() {
405 let cfg = Config::default();
406 let pattern = cfg.fuse_regex_pattern();
407 assert!(pattern.contains("TODO"));
408 assert!(pattern.contains("FIXME"));
409 assert!(pattern.contains("HACK"));
410 assert!(pattern.contains("TEMP"));
411 assert!(pattern.contains("REMOVEME"));
412 assert!(pattern.contains("DEBT"));
413 assert!(pattern.contains("STOPSHIP"));
414 assert!(pattern.contains("WORKAROUND"));
415 assert!(pattern.contains("DEPRECATED"));
416 assert!(pattern.contains("BUG"));
417 }
418
419 #[test]
420 fn test_read_config_file_valid() {
421 let toml_content = r#"
422triggers = ["TODO", "FIXME"]
423fuse_days = 14
424exclude = ["vendor/**"]
425extensions = ["rs", "go"]
426"#;
427 let f = write_toml(toml_content);
428 let cfg_file = read_config_file(f.path()).unwrap();
429 assert_eq!(cfg_file.triggers.unwrap(), vec!["TODO", "FIXME"]);
430 assert_eq!(cfg_file.fuse_days.unwrap(), 14);
431 assert_eq!(cfg_file.exclude.unwrap(), vec!["vendor/**"]);
432 assert_eq!(cfg_file.extensions.unwrap(), vec!["rs", "go"]);
433 }
434
435 #[test]
436 fn test_read_config_file_empty() {
437 let f = write_toml("");
438 let cfg_file = read_config_file(f.path()).unwrap();
439 assert!(cfg_file.triggers.is_none());
440 assert!(cfg_file.fuse_days.is_none());
441 }
442
443 #[test]
444 fn test_read_config_file_invalid_toml() {
445 let f = write_toml("this is not valid toml ][[[");
446 let result = read_config_file(f.path());
447 assert!(result.is_err());
448 }
449
450 #[test]
451 fn test_read_config_file_not_found() {
452 let result = read_config_file(Path::new("/nonexistent/path/.timebomb.toml"));
453 assert!(result.is_err());
454 }
455}