Skip to main content

timebomb/
error.rs

1use std::fmt;
2use std::io;
3use std::path::PathBuf;
4
5/// Top-level error type for timebomb
6#[derive(Debug)]
7pub enum Error {
8    /// I/O error with optional path context
9    Io {
10        source: io::Error,
11        path: Option<PathBuf>,
12    },
13
14    /// Failed to parse the config file
15    ConfigParse {
16        source: toml::de::Error,
17        path: PathBuf,
18    },
19
20    /// Config file could not be read
21    ConfigRead { source: io::Error, path: PathBuf },
22
23    /// Regex compilation failed (should never happen at runtime if patterns are constant)
24    RegexCompile(regex::Error),
25
26    /// A date string in an annotation was not a valid calendar date
27    InvalidDate {
28        date_str: String,
29        file: PathBuf,
30        line: usize,
31    },
32
33    /// A glob pattern in the config was invalid
34    InvalidGlob {
35        pattern: String,
36        source: globset::Error,
37    },
38
39    /// A CLI argument was semantically invalid (e.g. bad duration string)
40    InvalidArgument(String),
41}
42
43impl fmt::Display for Error {
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        match self {
46            Error::Io {
47                source,
48                path: Some(p),
49            } => {
50                write!(f, "I/O error reading '{}': {}", p.display(), source)
51            }
52            Error::Io { source, path: None } => {
53                write!(f, "I/O error: {}", source)
54            }
55            Error::ConfigParse { source, path } => {
56                write!(f, "Failed to parse config '{}': {}", path.display(), source)
57            }
58            Error::ConfigRead { source, path } => {
59                write!(f, "Failed to read config '{}': {}", path.display(), source)
60            }
61            Error::RegexCompile(e) => {
62                write!(f, "Regex compilation error: {}", e)
63            }
64            Error::InvalidDate {
65                date_str,
66                file,
67                line,
68            } => {
69                write!(
70                    f,
71                    "Invalid date '{}' at {}:{} (expected YYYY-MM-DD)",
72                    date_str,
73                    file.display(),
74                    line
75                )
76            }
77            Error::InvalidGlob { pattern, source } => {
78                write!(f, "Invalid glob pattern '{}': {}", pattern, source)
79            }
80            Error::InvalidArgument(msg) => {
81                write!(f, "Invalid argument: {}", msg)
82            }
83        }
84    }
85}
86
87impl std::error::Error for Error {
88    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
89        match self {
90            Error::Io { source, .. } => Some(source),
91            Error::ConfigParse { source, .. } => Some(source),
92            Error::ConfigRead { source, .. } => Some(source),
93            Error::RegexCompile(e) => Some(e),
94            Error::InvalidGlob { source, .. } => Some(source),
95            _ => None,
96        }
97    }
98}
99
100impl From<regex::Error> for Error {
101    fn from(e: regex::Error) -> Self {
102        Error::RegexCompile(e)
103    }
104}
105
106/// Convenience result alias
107pub type Result<T> = std::result::Result<T, Error>;
108
109/// Maximum allowed value for `--fuse` (10 years).
110///
111/// Values beyond this are almost certainly mistakes (e.g. off-by-one on unit
112/// conversion) and would suppress all warnings across any realistic codebase.
113const MAX_FUSE_DAYS: u32 = 3_650;
114
115/// Parse a duration string like "30d", "14d", "7d" into a number of days.
116/// Only day-based durations are supported.
117pub fn parse_duration_days(s: &str) -> Result<u32> {
118    let s = s.trim();
119    if let Some(num_str) = s.strip_suffix('d') {
120        let days = num_str.parse::<u32>().map_err(|_| {
121            Error::InvalidArgument(format!(
122                "'{}' is not a valid duration — expected a format like '30d'",
123                s
124            ))
125        })?;
126        if days > MAX_FUSE_DAYS {
127            return Err(Error::InvalidArgument(format!(
128                "'{}' exceeds the maximum allowed fuse window of {}d (10 years)",
129                s, MAX_FUSE_DAYS
130            )));
131        }
132        Ok(days)
133    } else {
134        Err(Error::InvalidArgument(format!(
135            "'{}' is not a valid duration — expected a format like '30d'",
136            s
137        )))
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn test_parse_duration_days_valid() {
147        assert_eq!(parse_duration_days("30d").unwrap(), 30);
148        assert_eq!(parse_duration_days("0d").unwrap(), 0);
149        assert_eq!(parse_duration_days("365d").unwrap(), 365);
150        assert_eq!(parse_duration_days("  14d  ").unwrap(), 14);
151    }
152
153    #[test]
154    fn test_parse_duration_days_cap() {
155        assert!(parse_duration_days("3650d").is_ok());
156        assert!(parse_duration_days("3651d").is_err());
157        assert!(parse_duration_days("9999999d").is_err());
158    }
159
160    #[test]
161    fn test_parse_duration_days_invalid() {
162        assert!(parse_duration_days("30").is_err());
163        assert!(parse_duration_days("abc").is_err());
164        assert!(parse_duration_days("30h").is_err());
165        assert!(parse_duration_days("").is_err());
166        assert!(parse_duration_days("-5d").is_err());
167    }
168
169    #[test]
170    fn test_error_display_io_with_path() {
171        let err = Error::Io {
172            source: io::Error::new(io::ErrorKind::NotFound, "not found"),
173            path: Some(PathBuf::from("/some/file.rs")),
174        };
175        let msg = format!("{}", err);
176        assert!(msg.contains("/some/file.rs"));
177        assert!(msg.contains("not found"));
178    }
179
180    #[test]
181    fn test_error_display_io_without_path() {
182        let err = Error::Io {
183            source: io::Error::new(io::ErrorKind::PermissionDenied, "denied"),
184            path: None,
185        };
186        let msg = format!("{}", err);
187        assert!(msg.contains("denied"));
188    }
189
190    #[test]
191    fn test_error_display_invalid_date() {
192        let err = Error::InvalidDate {
193            date_str: "2026-13-45".to_string(),
194            file: PathBuf::from("src/main.rs"),
195            line: 42,
196        };
197        let msg = format!("{}", err);
198        assert!(msg.contains("2026-13-45"));
199        assert!(msg.contains("src/main.rs"));
200        assert!(msg.contains("42"));
201    }
202
203    #[test]
204    fn test_error_display_invalid_argument() {
205        let err = Error::InvalidArgument("bad value".to_string());
206        let msg = format!("{}", err);
207        assert!(msg.contains("bad value"));
208    }
209}