sbom-tools 0.2.0

Semantic SBOM diff and analysis tool
Documentation
//! Watch configuration and duration parsing.

use super::WatchError;
use crate::config::{EnrichmentConfig, OutputConfig};
use std::path::PathBuf;
use std::time::Duration;

/// Configuration for the watch command.
#[derive(Debug, Clone)]
pub struct WatchConfig {
    /// Directories to monitor for SBOM files
    pub watch_dirs: Vec<PathBuf>,
    /// Polling interval for file changes
    pub poll_interval: Duration,
    /// Interval between enrichment refresh cycles
    pub enrich_interval: Duration,
    /// Debounce duration — wait this long after detecting a change before
    /// processing, to coalesce rapid successive writes (default: 2s).
    pub debounce: Duration,
    /// Output configuration
    pub output: OutputConfig,
    /// Enrichment configuration
    pub enrichment: EnrichmentConfig,
    /// Optional webhook URL for alerts
    pub webhook_url: Option<String>,
    /// Exit after first detected change (CI mode)
    pub exit_on_change: bool,
    /// Maximum number of diff snapshots to retain per SBOM
    pub max_snapshots: usize,
    /// Suppress non-essential output
    pub quiet: bool,
    /// Dry-run mode: do initial scan only, then exit
    pub dry_run: bool,
    /// Periodically probe the curated CRA-standards catalogue and surface
    /// status drift through the configured [`super::alerts::AlertSink`]s.
    pub cra_standards_enabled: bool,
    /// Interval between CRA-standards probe cycles
    pub cra_standards_interval: Duration,
    /// Per-request timeout for CRA-standards HTTP probes
    pub cra_standards_timeout: Duration,
}

/// Parse a human-readable duration string into a [`Duration`].
///
/// Supported suffixes: `ms` (milliseconds), `s` (seconds), `m` (minutes),
/// `h` (hours), `d` (days).
///
/// # Examples
///
/// ```ignore
/// assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30));
/// assert_eq!(parse_duration("5m").unwrap(), Duration::from_secs(300));
/// ```
pub fn parse_duration(s: &str) -> Result<Duration, WatchError> {
    let s = s.trim();
    if s.is_empty() {
        return Err(WatchError::InvalidInterval(s.to_string()));
    }

    let (num_str, unit) = if let Some(stripped) = s.strip_suffix("ms") {
        (stripped, "ms")
    } else if s.ends_with('s') || s.ends_with('m') || s.ends_with('h') || s.ends_with('d') {
        (&s[..s.len() - 1], &s[s.len() - 1..])
    } else {
        return Err(WatchError::InvalidInterval(s.to_string()));
    };

    let value: u64 = num_str
        .parse()
        .map_err(|_| WatchError::InvalidInterval(s.to_string()))?;

    // Overflow-safe unit conversion: an absurd interval like
    // `300000000000000d` must be a clean error, not a multiply panic
    // (debug) or a silently wrapped duration (release).
    let too_large = || WatchError::InvalidInterval(format!("{s} (interval too large)"));
    match unit {
        "ms" => Ok(Duration::from_millis(value)),
        "s" => Ok(Duration::from_secs(value)),
        "m" => value
            .checked_mul(60)
            .map(Duration::from_secs)
            .ok_or_else(too_large),
        "h" => value
            .checked_mul(3600)
            .map(Duration::from_secs)
            .ok_or_else(too_large),
        "d" => value
            .checked_mul(86400)
            .map(Duration::from_secs)
            .ok_or_else(too_large),
        _ => Err(WatchError::InvalidInterval(s.to_string())),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_duration_seconds() {
        assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30));
    }

    #[test]
    fn test_parse_duration_minutes() {
        assert_eq!(parse_duration("5m").unwrap(), Duration::from_secs(300));
    }

    #[test]
    fn test_parse_duration_hours() {
        assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
    }

    #[test]
    fn test_parse_duration_days() {
        assert_eq!(parse_duration("2d").unwrap(), Duration::from_secs(172_800));
    }

    #[test]
    fn test_parse_duration_milliseconds() {
        assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
    }

    #[test]
    fn test_parse_duration_with_whitespace() {
        assert_eq!(parse_duration("  10s  ").unwrap(), Duration::from_secs(10));
    }

    #[test]
    fn test_parse_duration_invalid_unit() {
        assert!(parse_duration("10x").is_err());
    }

    #[test]
    fn test_parse_duration_invalid_number() {
        assert!(parse_duration("abcs").is_err());
    }

    #[test]
    fn test_parse_duration_empty() {
        assert!(parse_duration("").is_err());
    }

    #[test]
    fn test_parse_duration_no_unit() {
        assert!(parse_duration("100").is_err());
    }

    #[test]
    fn test_parse_duration_overflow_is_clean_error() {
        // Used to panic with "attempt to multiply with overflow".
        for s in [
            "300000000000000d",
            "18446744073709551615h",
            "18446744073709551615m",
        ] {
            let err = parse_duration(s).expect_err("overflowing interval must error");
            assert!(
                err.to_string().contains("interval too large"),
                "error must say the interval is too large: {err}"
            );
        }
        // Large but representable values still parse.
        assert_eq!(
            parse_duration("10000d").unwrap(),
            Duration::from_secs(10_000 * 86_400)
        );
    }
}