Skip to main content

cfgd_core/util/
time.rs

1/// Returns the current UTC time as an ISO 8601 / RFC 3339 string.
2pub fn utc_now_iso8601() -> String {
3    let secs = std::time::SystemTime::now()
4        .duration_since(std::time::UNIX_EPOCH)
5        .unwrap_or_default()
6        .as_secs();
7    unix_secs_to_iso8601(secs)
8}
9
10/// Strip filename-unsafe characters (`:`, `-`, `T`, `Z`) from an ISO 8601
11/// timestamp so it can be used as a path segment. Helper extracted from three
12/// inline replace calls in oci/build, cli/module/keys, and gateway/api/drift.
13pub fn iso8601_to_filename_safe(ts: &str) -> String {
14    ts.replace([':', '-', 'T', 'Z'], "")
15}
16
17/// Convenience: current UTC time as a filename-safe string.
18pub fn utc_now_filename_safe() -> String {
19    iso8601_to_filename_safe(&utc_now_iso8601())
20}
21
22/// Returns the current time as seconds since the Unix epoch.
23pub fn unix_secs_now() -> u64 {
24    std::time::SystemTime::now()
25        .duration_since(std::time::UNIX_EPOCH)
26        .unwrap_or_default()
27        .as_secs()
28}
29
30/// Converts a Unix timestamp (seconds since epoch) to an ISO 8601 UTC string.
31pub fn unix_secs_to_iso8601(secs: u64) -> String {
32    let days = secs / 86400;
33    let time_of_day = secs % 86400;
34    let hours = time_of_day / 3600;
35    let minutes = (time_of_day % 3600) / 60;
36    let seconds = time_of_day % 60;
37
38    let (year, month, day) = days_to_ymd(days);
39
40    format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
41}
42
43fn days_to_ymd(days: u64) -> (u64, u64, u64) {
44    // Algorithm from http://howardhinnant.github.io/date_algorithms.html
45    let z = days + 719468;
46    let era = z / 146097;
47    let doe = z - era * 146097;
48    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
49    let y = yoe + era * 400;
50    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
51    let mp = (5 * doy + 2) / 153;
52    let d = doy - (153 * mp + 2) / 5 + 1;
53    let m = if mp < 10 { mp + 3 } else { mp - 9 };
54    let y = if m <= 2 { y + 1 } else { y };
55    (y, m, d)
56}
57
58/// Parse a duration string like "30s", "5m", "1h", or a plain number (as seconds).
59///
60/// Returns an error description on invalid input.
61pub fn parse_duration_str(s: &str) -> Result<std::time::Duration, String> {
62    let s = s.trim();
63    const SUFFIXES: &[(char, u64)] = &[('s', 1), ('m', 60), ('h', 3600), ('d', 86400)];
64    for &(suffix, multiplier) in SUFFIXES {
65        if let Some(n) = s.strip_suffix(suffix) {
66            return n
67                .trim()
68                .parse::<u64>()
69                .map(|v| std::time::Duration::from_secs(v * multiplier))
70                .map_err(|_| format!("invalid timeout: {}", s));
71        }
72    }
73    s.parse::<u64>()
74        .map(std::time::Duration::from_secs)
75        .map_err(|_| format!("invalid timeout '{}': use 30s, 5m, or 1h", s))
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    #[test]
83    fn iso8601_to_filename_safe_strips_separators() {
84        assert_eq!(
85            iso8601_to_filename_safe("2026-05-12T14:30:25Z"),
86            "20260512143025"
87        );
88    }
89
90    #[test]
91    fn iso8601_to_filename_safe_preserves_fractional_seconds() {
92        // Only `:`, `-`, `T`, `Z` are stripped — `.` and digits survive.
93        assert_eq!(
94            iso8601_to_filename_safe("2026-05-12T14:30:25.123Z"),
95            "20260512143025.123"
96        );
97    }
98
99    #[test]
100    fn utc_now_filename_safe_has_no_unsafe_chars() {
101        let s = utc_now_filename_safe();
102        assert!(!s.is_empty());
103        assert!(
104            !s.contains([':', '-', 'T', 'Z']),
105            "filename-safe stamp contained banned char: {s:?}"
106        );
107    }
108}