1use chrono::{DateTime, Utc};
13
14use crate::datadog::error::DatadogError;
15
16pub 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
46pub 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
62fn 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}