Skip to main content

timebomb/
config.rs

1use crate::error::{parse_duration_days, Error, Result};
2use globset::{Glob, GlobSet, GlobSetBuilder};
3use serde::{Deserialize, Serialize};
4use std::path::Path;
5
6/// The structure of `.timebomb.toml` on disk.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8#[serde(deny_unknown_fields)]
9pub struct ConfigFile {
10    /// Tags to scan for.
11    #[serde(default)]
12    pub triggers: Option<Vec<String>>,
13
14    /// Warn (and optionally fail) if a fuse expires within this many days.
15    #[serde(default)]
16    pub fuse_days: Option<u32>,
17
18    /// Glob patterns to exclude from scanning.
19    #[serde(default)]
20    pub exclude: Option<Vec<String>>,
21
22    /// File extensions to scan. If empty/absent, scan all text files.
23    #[serde(default)]
24    pub extensions: Option<Vec<String>>,
25
26    /// Ratchet: fail if the number of detonated fuses exceeds this limit.
27    #[serde(default)]
28    pub max_detonated: Option<usize>,
29
30    /// Ratchet: fail if the number of ticking fuses exceeds this limit.
31    #[serde(default)]
32    pub max_ticking: Option<usize>,
33}
34
35/// Fully-resolved configuration after merging the config file with CLI overrides.
36#[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    /// Whether to fail (exit 1) when ticking items are found (--fail-on-ticking).
43    pub fail_on_ticking: bool,
44    /// If Some, only scan files whose relative path is in this set (--since git-diff mode).
45    /// None means scan all files (normal mode).
46    pub diff_files: Option<std::collections::HashSet<std::path::PathBuf>>,
47    /// Ratchet: fail if the number of detonated fuses exceeds this limit.
48    pub max_detonated: Option<usize>,
49    /// Ratchet: fail if the number of ticking fuses exceeds this limit.
50    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/// CLI-level overrides that can be applied on top of a `Config`.
126#[derive(Debug, Default, Clone)]
127pub struct CliOverrides {
128    /// `--fuse 30d`
129    pub fuse: Option<String>,
130    /// `--fail-on-ticking`
131    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
143/// Load `.timebomb.toml` from `root_dir` if it exists, merge with defaults and CLI overrides.
144///
145/// Only `root_dir/.timebomb.toml` is checked. CWD fallback is the caller's responsibility
146/// (handled in `main.rs`) so that tests using temp directories are not affected by a
147/// `.timebomb.toml` that happens to exist in the current working directory.
148///
149/// Returns a fully-resolved `Config`.
150pub 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    // Apply CLI overrides
199    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    /// Build a `GlobSet` from the exclude patterns for fast path matching.
217    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    /// Return true if the given path should be excluded per exclude globs.
233    /// `path` should be relative to the scan root for glob matching to work correctly.
234    pub fn is_excluded(&self, path: &Path, globset: &GlobSet) -> bool {
235        // Match against the full relative path string and each component
236        if globset.is_match(path) {
237            return true;
238        }
239        // Also try matching just the filename so that patterns like `*.min.js`
240        // correctly exclude nested files (e.g. `dist/app.min.js`).
241        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    /// Return true if the file extension is in the allowed list.
250    /// If the extensions list is empty, all files are considered eligible.
251    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    /// Build the fuse regex pattern from the configured triggers.
265    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        // Extensions should fall back to defaults
317        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        // CLI should win
347        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}