Skip to main content

sbom_tools/watch/
config.rs

1//! Watch configuration and duration parsing.
2
3use super::WatchError;
4use crate::config::{EnrichmentConfig, OutputConfig};
5use std::path::PathBuf;
6use std::time::Duration;
7
8/// Configuration for the watch command.
9#[derive(Debug, Clone)]
10pub struct WatchConfig {
11    /// Directories to monitor for SBOM files
12    pub watch_dirs: Vec<PathBuf>,
13    /// Polling interval for file changes
14    pub poll_interval: Duration,
15    /// Interval between enrichment refresh cycles
16    pub enrich_interval: Duration,
17    /// Debounce duration — wait this long after detecting a change before
18    /// processing, to coalesce rapid successive writes (default: 2s).
19    pub debounce: Duration,
20    /// Output configuration
21    pub output: OutputConfig,
22    /// Enrichment configuration
23    pub enrichment: EnrichmentConfig,
24    /// Optional webhook URL for alerts
25    pub webhook_url: Option<String>,
26    /// Exit after first detected change (CI mode)
27    pub exit_on_change: bool,
28    /// Maximum number of diff snapshots to retain per SBOM
29    pub max_snapshots: usize,
30    /// Suppress non-essential output
31    pub quiet: bool,
32    /// Dry-run mode: do initial scan only, then exit
33    pub dry_run: bool,
34    /// Periodically probe the curated CRA-standards catalogue and surface
35    /// status drift through the configured [`super::alerts::AlertSink`]s.
36    pub cra_standards_enabled: bool,
37    /// Interval between CRA-standards probe cycles
38    pub cra_standards_interval: Duration,
39    /// Per-request timeout for CRA-standards HTTP probes
40    pub cra_standards_timeout: Duration,
41}
42
43/// Parse a human-readable duration string into a [`Duration`].
44///
45/// Supported suffixes: `ms` (milliseconds), `s` (seconds), `m` (minutes),
46/// `h` (hours), `d` (days).
47///
48/// # Examples
49///
50/// ```ignore
51/// assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30));
52/// assert_eq!(parse_duration("5m").unwrap(), Duration::from_secs(300));
53/// ```
54pub fn parse_duration(s: &str) -> Result<Duration, WatchError> {
55    let s = s.trim();
56    if s.is_empty() {
57        return Err(WatchError::InvalidInterval(s.to_string()));
58    }
59
60    let (num_str, unit) = if let Some(stripped) = s.strip_suffix("ms") {
61        (stripped, "ms")
62    } else if s.ends_with('s') || s.ends_with('m') || s.ends_with('h') || s.ends_with('d') {
63        (&s[..s.len() - 1], &s[s.len() - 1..])
64    } else {
65        return Err(WatchError::InvalidInterval(s.to_string()));
66    };
67
68    let value: u64 = num_str
69        .parse()
70        .map_err(|_| WatchError::InvalidInterval(s.to_string()))?;
71
72    // Overflow-safe unit conversion: an absurd interval like
73    // `300000000000000d` must be a clean error, not a multiply panic
74    // (debug) or a silently wrapped duration (release).
75    let too_large = || WatchError::InvalidInterval(format!("{s} (interval too large)"));
76    match unit {
77        "ms" => Ok(Duration::from_millis(value)),
78        "s" => Ok(Duration::from_secs(value)),
79        "m" => value
80            .checked_mul(60)
81            .map(Duration::from_secs)
82            .ok_or_else(too_large),
83        "h" => value
84            .checked_mul(3600)
85            .map(Duration::from_secs)
86            .ok_or_else(too_large),
87        "d" => value
88            .checked_mul(86400)
89            .map(Duration::from_secs)
90            .ok_or_else(too_large),
91        _ => Err(WatchError::InvalidInterval(s.to_string())),
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn test_parse_duration_seconds() {
101        assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30));
102    }
103
104    #[test]
105    fn test_parse_duration_minutes() {
106        assert_eq!(parse_duration("5m").unwrap(), Duration::from_secs(300));
107    }
108
109    #[test]
110    fn test_parse_duration_hours() {
111        assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
112    }
113
114    #[test]
115    fn test_parse_duration_days() {
116        assert_eq!(parse_duration("2d").unwrap(), Duration::from_secs(172_800));
117    }
118
119    #[test]
120    fn test_parse_duration_milliseconds() {
121        assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
122    }
123
124    #[test]
125    fn test_parse_duration_with_whitespace() {
126        assert_eq!(parse_duration("  10s  ").unwrap(), Duration::from_secs(10));
127    }
128
129    #[test]
130    fn test_parse_duration_invalid_unit() {
131        assert!(parse_duration("10x").is_err());
132    }
133
134    #[test]
135    fn test_parse_duration_invalid_number() {
136        assert!(parse_duration("abcs").is_err());
137    }
138
139    #[test]
140    fn test_parse_duration_empty() {
141        assert!(parse_duration("").is_err());
142    }
143
144    #[test]
145    fn test_parse_duration_no_unit() {
146        assert!(parse_duration("100").is_err());
147    }
148
149    #[test]
150    fn test_parse_duration_overflow_is_clean_error() {
151        // Used to panic with "attempt to multiply with overflow".
152        for s in [
153            "300000000000000d",
154            "18446744073709551615h",
155            "18446744073709551615m",
156        ] {
157            let err = parse_duration(s).expect_err("overflowing interval must error");
158            assert!(
159                err.to_string().contains("interval too large"),
160                "error must say the interval is too large: {err}"
161            );
162        }
163        // Large but representable values still parse.
164        assert_eq!(
165            parse_duration("10000d").unwrap(),
166            Duration::from_secs(10_000 * 86_400)
167        );
168    }
169}