Skip to main content

omni_dev/datadog/
time.rs

1//! Time-range parsing for Datadog CLI commands.
2//!
3//! Accepts three forms:
4//! - Relative shorthand: `{N}{s|m|h|d|w}` (e.g. `15m`, `1h`, `7d`).
5//! - Literal `now`.
6//! - RFC 3339 timestamps (timezone required, e.g. `2026-04-22T10:00:00Z`).
7//! - Unix epoch seconds (non-negative integer).
8//!
9//! All results are returned as epoch seconds (`i64`) — the unit expected
10//! by Datadog v1 query parameters.
11
12use chrono::{DateTime, Utc};
13
14use crate::datadog::error::DatadogError;
15
16/// Parses a single time instant relative to `now`.
17///
18/// Returns Unix epoch seconds. See module docs for accepted syntax.
19pub fn parse_instant(spec: &str, now: DateTime<Utc>) -> Result<i64, DatadogError> {
20    let spec = spec.trim();
21    if spec.is_empty() {
22        return Err(DatadogError::InvalidTimeRange("empty".to_string()));
23    }
24
25    if spec.eq_ignore_ascii_case("now") {
26        return Ok(now.timestamp());
27    }
28
29    if let Some(seconds) = parse_relative(spec) {
30        return Ok(now.timestamp() - seconds as i64);
31    }
32
33    if let Ok(ts) = DateTime::parse_from_rfc3339(spec) {
34        return Ok(ts.timestamp());
35    }
36
37    if let Ok(secs) = spec.parse::<i64>() {
38        if secs >= 0 {
39            return Ok(secs);
40        }
41    }
42
43    Err(DatadogError::InvalidTimeRange(spec.to_string()))
44}
45
46/// Parses a `(from, to)` pair. When `to` is `None`, defaults to `now`.
47pub fn parse_time_range(from: &str, to: Option<&str>) -> Result<(i64, i64), DatadogError> {
48    let now = Utc::now();
49    let from_ts = parse_instant(from, now)?;
50    let to_ts = match to {
51        Some(spec) => parse_instant(spec, now)?,
52        None => now.timestamp(),
53    };
54    if to_ts < from_ts {
55        return Err(DatadogError::InvalidTimeRange(format!(
56            "to ({to_ts}) is before from ({from_ts})"
57        )));
58    }
59    Ok((from_ts, to_ts))
60}
61
62/// Parses `{N}{unit}` shorthand. Returns the number of seconds it represents,
63/// or `None` if the input does not match the shorthand grammar.
64fn parse_relative(spec: &str) -> Option<u64> {
65    let unit_byte = *spec.as_bytes().last()?;
66    let unit_seconds: u64 = match unit_byte {
67        b's' => 1,
68        b'm' => 60,
69        b'h' => 60 * 60,
70        b'd' => 24 * 60 * 60,
71        b'w' => 7 * 24 * 60 * 60,
72        _ => return None,
73    };
74    let digits = &spec[..spec.len() - 1];
75    if digits.is_empty() {
76        return None;
77    }
78    let n: u64 = digits.parse().ok()?;
79    n.checked_mul(unit_seconds)
80}
81
82#[cfg(test)]
83#[allow(clippy::unwrap_used)]
84mod tests {
85    use super::*;
86    use chrono::TimeZone;
87
88    fn now() -> DateTime<Utc> {
89        Utc.with_ymd_and_hms(2026, 4, 22, 12, 0, 0).unwrap()
90    }
91
92    #[test]
93    fn parses_now() {
94        assert_eq!(parse_instant("now", now()).unwrap(), now().timestamp());
95    }
96
97    #[test]
98    fn parses_now_case_insensitive() {
99        assert_eq!(parse_instant("NOW", now()).unwrap(), now().timestamp());
100        assert_eq!(parse_instant("Now", now()).unwrap(), now().timestamp());
101    }
102
103    #[test]
104    fn parses_relative_minutes() {
105        assert_eq!(
106            parse_instant("15m", now()).unwrap(),
107            now().timestamp() - 15 * 60
108        );
109    }
110
111    #[test]
112    fn parses_relative_seconds() {
113        assert_eq!(parse_instant("30s", now()).unwrap(), now().timestamp() - 30);
114    }
115
116    #[test]
117    fn parses_relative_hours() {
118        assert_eq!(
119            parse_instant("2h", now()).unwrap(),
120            now().timestamp() - 2 * 60 * 60
121        );
122    }
123
124    #[test]
125    fn parses_relative_days() {
126        assert_eq!(
127            parse_instant("3d", now()).unwrap(),
128            now().timestamp() - 3 * 24 * 60 * 60
129        );
130    }
131
132    #[test]
133    fn parses_relative_weeks() {
134        assert_eq!(
135            parse_instant("1w", now()).unwrap(),
136            now().timestamp() - 7 * 24 * 60 * 60
137        );
138    }
139
140    #[test]
141    fn parses_rfc3339_utc() {
142        let expected = Utc
143            .with_ymd_and_hms(2026, 4, 22, 10, 0, 0)
144            .unwrap()
145            .timestamp();
146        assert_eq!(
147            parse_instant("2026-04-22T10:00:00Z", now()).unwrap(),
148            expected
149        );
150    }
151
152    #[test]
153    fn parses_rfc3339_with_offset() {
154        let expected = Utc
155            .with_ymd_and_hms(2026, 4, 22, 9, 0, 0)
156            .unwrap()
157            .timestamp();
158        assert_eq!(
159            parse_instant("2026-04-22T10:00:00+01:00", now()).unwrap(),
160            expected
161        );
162    }
163
164    #[test]
165    fn parses_epoch_seconds() {
166        assert_eq!(parse_instant("1700000000", now()).unwrap(), 1_700_000_000);
167    }
168
169    #[test]
170    fn rejects_empty() {
171        assert!(parse_instant("", now()).is_err());
172        assert!(parse_instant("   ", now()).is_err());
173    }
174
175    #[test]
176    fn rejects_compound_relative() {
177        assert!(parse_instant("1h30m", now()).is_err());
178    }
179
180    #[test]
181    fn rejects_unit_without_digits() {
182        assert!(parse_instant("h", now()).is_err());
183        assert!(parse_instant("m", now()).is_err());
184    }
185
186    #[test]
187    fn rejects_unknown_unit() {
188        assert!(parse_instant("5y", now()).is_err());
189    }
190
191    #[test]
192    fn rejects_negative_epoch() {
193        assert!(parse_instant("-1", now()).is_err());
194    }
195
196    #[test]
197    fn rejects_rfc3339_without_timezone() {
198        assert!(parse_instant("2026-04-22T10:00:00", now()).is_err());
199    }
200
201    #[test]
202    fn rejects_non_numeric_garbage() {
203        assert!(parse_instant("zzz", now()).is_err());
204    }
205
206    #[test]
207    fn time_range_defaults_to_to_now() {
208        let (from, to) = parse_time_range("1h", None).unwrap();
209        assert!(to - from <= 60 * 60 + 5);
210        assert!(to - from >= 60 * 60 - 5);
211    }
212
213    #[test]
214    fn time_range_explicit_to() {
215        let (from, to) =
216            parse_time_range("2026-04-22T09:00:00Z", Some("2026-04-22T10:00:00Z")).unwrap();
217        assert_eq!(to - from, 60 * 60);
218    }
219
220    #[test]
221    fn time_range_rejects_inverted_range() {
222        let err =
223            parse_time_range("2026-04-22T10:00:00Z", Some("2026-04-22T09:00:00Z")).unwrap_err();
224        assert!(err.to_string().contains("before"));
225    }
226
227    #[test]
228    fn time_range_propagates_from_error() {
229        assert!(parse_time_range("garbage", None).is_err());
230    }
231
232    #[test]
233    fn time_range_propagates_to_error() {
234        assert!(parse_time_range("1h", Some("garbage")).is_err());
235    }
236}