Skip to main content

obz_core/
time.rs

1//! Time expression parser.
2//!
3//! Parses various time formats used in CLI `--from` and `--to` flags.
4//! Delegates to [`parse_datetime`] for the heavy lifting, with a thin
5//! normalization layer for Prometheus/Grafana shorthand syntax.
6//!
7//! # Supported formats
8//!
9//! | Format | Example | Notes |
10//! |--------|---------|-------|
11//! | Relative (Prometheus) | `now-1h`, `now-30m`, `-7d` | Normalized then parsed |
12//! | Relative (natural) | `1 hour ago`, `+3 days`, `yesterday` | Handled by `parse_datetime` |
13//! | RFC3339 / ISO 8601 | `2026-03-24T10:00:00Z` | Handled by `parse_datetime` |
14//! | Unix timestamp | `@1740280800` | 10-digit=seconds, 13-digit=milliseconds (auto-converted) |
15//! | Now | `now` | Current time |
16//!
17//! All parsed times are converted to Unix seconds (i64).
18
19use crate::model::error::{ErrorCode, ObzError};
20
21/// Parse a time expression into Unix seconds.
22///
23/// Supports Prometheus/Grafana shorthand (`now-1h`, `-30m`), Unix
24/// timestamps (`@<seconds>` or `@<milliseconds>`), and all formats
25/// supported by [`parse_datetime`] (RFC3339, natural language like
26/// `yesterday`, `1 hour ago`).
27///
28/// For `@<digits>` timestamps, 13-digit values are treated as
29/// milliseconds and automatically converted to seconds.
30///
31/// # Arguments
32///
33/// * `input` — The time expression string.
34///
35/// # Errors
36///
37/// Returns [`ObzError::InvalidArgument`] if the input cannot be parsed.
38///
39/// # Examples
40///
41/// ```
42/// use obz_core::time::parse_time;
43///
44/// // These all work:
45/// let ts = parse_time("now").unwrap();
46/// assert!(ts > 0);
47///
48/// let ts = parse_time("@1740280800").unwrap();
49/// assert_eq!(ts, 1740280800);
50///
51/// // 13-digit millisecond timestamp → auto-converted to seconds
52/// let ts = parse_time("@1740280800000").unwrap();
53/// assert_eq!(ts, 1740280800);
54///
55/// let ts = parse_time("2026-03-24T10:00:00Z").unwrap();
56/// assert_eq!(ts, 1774346400);
57/// ```
58pub fn parse_time(input: &str) -> Result<i64, ObzError> {
59    let trimmed = input.trim();
60    if trimmed.is_empty() {
61        return Err(ObzError::InvalidArgument {
62            code: ErrorCode::InvalidTimeRange,
63            message: "empty time expression".to_string(),
64            suggestion: None,
65        });
66    }
67
68    // Handle @<unix_timestamp> with 13-digit millisecond auto-conversion.
69    if let Some(digits) = trimmed.strip_prefix('@') {
70        return parse_unix_timestamp(digits, trimmed);
71    }
72
73    let normalized = normalize_shorthand(trimmed);
74    let zoned =
75        parse_datetime::parse_datetime(&normalized).map_err(|_| ObzError::InvalidArgument {
76            code: ErrorCode::InvalidTimeRange,
77            message: format!(
78                "unrecognized time format: '{trimmed}'. \
79                 Supported: now-1h, -1h, RFC3339, @<unix_seconds>, 'yesterday', '1 hour ago'"
80            ),
81            suggestion: None,
82        })?;
83
84    Ok(zoned.timestamp().as_second())
85}
86
87/// Parse a `@<digits>` Unix timestamp.
88///
89/// - 10-digit values are treated as seconds.
90/// - 13-digit values are treated as milliseconds and divided by 1000.
91/// - Other lengths are parsed as seconds (best effort).
92fn parse_unix_timestamp(digits: &str, original: &str) -> Result<i64, ObzError> {
93    let ts: i64 = digits.parse().map_err(|_| ObzError::InvalidArgument {
94        code: ErrorCode::InvalidTimeRange,
95        message: format!(
96            "invalid Unix timestamp: '{original}'. Expected @<digits> (10-digit seconds or 13-digit milliseconds)"
97        ),
98        suggestion: None,
99    })?;
100
101    if digits.len() == 13 {
102        Ok(ts / 1000)
103    } else {
104        Ok(ts)
105    }
106}
107
108/// Parse a query step string (e.g., `15s`, `1m`, `5m`) into seconds.
109///
110/// Supports Prometheus-style duration suffixes and bare numbers (seconds).
111///
112/// # Errors
113///
114/// Returns [`ObzError::InvalidArgument`] if the input is invalid.
115///
116/// # Examples
117///
118/// ```
119/// use obz_core::time::parse_step;
120///
121/// assert_eq!(parse_step("15s").unwrap(), 15);
122/// assert_eq!(parse_step("1m").unwrap(), 60);
123/// assert_eq!(parse_step("5m").unwrap(), 300);
124/// assert_eq!(parse_step("1h").unwrap(), 3600);
125/// assert_eq!(parse_step("30").unwrap(), 30);
126/// ```
127pub fn parse_step(input: &str) -> Result<u64, ObzError> {
128    let s = input.trim();
129    if s.is_empty() {
130        return Err(ObzError::InvalidArgument {
131            code: ErrorCode::InvalidTimeRange,
132            message: "step cannot be empty".to_string(),
133            suggestion: None,
134        });
135    }
136
137    // Try as bare number first.
138    if let Ok(n) = s.parse::<u64>() {
139        if n == 0 {
140            return Err(ObzError::InvalidArgument {
141                code: ErrorCode::InvalidTimeRange,
142                message: "step must be positive".to_string(),
143                suggestion: None,
144            });
145        }
146        return Ok(n);
147    }
148
149    // Parse "<number><unit>" directly — pure arithmetic, no clock needed.
150    let (num, multiplier) = parse_duration_parts(s)?;
151    let result = num * multiplier;
152    if result == 0 {
153        return Err(ObzError::InvalidArgument {
154            code: ErrorCode::InvalidTimeRange,
155            message: "step must be positive".to_string(),
156            suggestion: None,
157        });
158    }
159    Ok(result)
160}
161
162/// Parse a duration string like `"15s"`, `"1m"`, `"1h"` into `(number, multiplier)`.
163///
164/// Pure arithmetic — no clock, no allocations.
165fn parse_duration_parts(s: &str) -> Result<(u64, u64), ObzError> {
166    let (num_str, unit) = s
167        .find(|c: char| !c.is_ascii_digit())
168        .map(|pos| (&s[..pos], &s[pos..]))
169        .ok_or_else(|| ObzError::InvalidArgument {
170            code: ErrorCode::InvalidTimeRange,
171            message: format!("missing unit in duration: '{s}'. Expected s, m, h, d, or w"),
172            suggestion: None,
173        })?;
174
175    let num: u64 = num_str.parse().map_err(|_| ObzError::InvalidArgument {
176        code: ErrorCode::InvalidTimeRange,
177        message: format!("invalid number in duration: '{s}'"),
178        suggestion: None,
179    })?;
180
181    let multiplier = match unit {
182        "s" => 1,
183        "m" | "min" => 60,
184        "h" => 3600,
185        "d" => 86400,
186        "w" => 604800,
187        _ => {
188            return Err(ObzError::InvalidArgument {
189                code: ErrorCode::InvalidTimeRange,
190                message: format!("invalid unit '{unit}' in '{s}'. Expected s, m, h, d, or w"),
191                suggestion: None,
192            });
193        }
194    };
195
196    Ok((num, multiplier))
197}
198
199/// Validate that `from` < `to`.
200///
201/// # Errors
202///
203/// Returns [`ObzError::InvalidArgument`] if `from >= to`.
204pub fn validate_time_range(from: i64, to: i64) -> Result<(), ObzError> {
205    if from >= to {
206        return Err(ObzError::InvalidArgument {
207            code: ErrorCode::InvalidTimeRange,
208            message: format!("--from ({from}) must be before --to ({to})"),
209            suggestion: None,
210        });
211    }
212    Ok(())
213}
214
215/// Resolve `--from` and `--to` with defaults.
216///
217/// If `from_str` is `None`, uses `default_from` (e.g., `"now-1h"`).
218/// If `to_str` is `None`, uses `"now"`.
219///
220/// # Errors
221///
222/// Returns [`ObzError::InvalidArgument`] if either time expression is
223/// invalid or if `from >= to`.
224pub fn resolve_time_range(
225    from_str: Option<&str>,
226    to_str: Option<&str>,
227    default_from: &str,
228) -> Result<(i64, i64), ObzError> {
229    let from = parse_time(from_str.unwrap_or(default_from))?;
230    let to = parse_time(to_str.unwrap_or("now"))?;
231    validate_time_range(from, to)?;
232    Ok((from, to))
233}
234
235/// Normalize Prometheus/Grafana shorthand to `parse_datetime` format.
236///
237/// - `now-1h` → `-1 hour`
238/// - `now-30m` → `-30 minute`
239/// - `-1h` → `-1 hour`
240/// - `now+2d` → `+2 day`
241/// - Everything else passes through unchanged.
242fn normalize_shorthand(input: &str) -> String {
243    let s = input.trim();
244
245    // "now" alone — pass through.
246    if s == "now" {
247        return s.to_string();
248    }
249
250    // "now-<num><unit>" → "-<num> <unit>"
251    if let Some(rest) = s.strip_prefix("now-") {
252        if let Some(expanded) = expand_duration(rest) {
253            return format!("-{expanded}");
254        }
255    }
256
257    // "now+<num><unit>" → "+<num> <unit>"
258    if let Some(rest) = s.strip_prefix("now+") {
259        if let Some(expanded) = expand_duration(rest) {
260            return format!("+{expanded}");
261        }
262    }
263
264    // "-<num><unit>" shortcut → "-<num> <unit>"
265    if let Some(rest) = s.strip_prefix('-') {
266        if rest.chars().next().is_some_and(|c| c.is_ascii_digit()) {
267            if let Some(expanded) = expand_duration(rest) {
268                return format!("-{expanded}");
269            }
270        }
271    }
272
273    s.to_string()
274}
275
276/// Expand a compact duration like `1h` to `1 hour`.
277fn expand_duration(s: &str) -> Option<String> {
278    let unit_pos = s.find(|c: char| !c.is_ascii_digit())?;
279    let num = &s[..unit_pos];
280    if num.is_empty() {
281        return None;
282    }
283    let unit_name = match &s[unit_pos..] {
284        "s" => "second",
285        "m" | "min" => "minute",
286        "h" => "hour",
287        "d" => "day",
288        "w" => "week",
289        _ => return None,
290    };
291    Some(format!("{num} {unit_name}"))
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    // --- normalize_shorthand ---
299
300    #[test]
301    fn test_normalize_now() {
302        assert_eq!(normalize_shorthand("now"), "now");
303    }
304
305    #[test]
306    fn test_normalize_relative() {
307        assert_eq!(normalize_shorthand("now-1h"), "-1 hour");
308        assert_eq!(normalize_shorthand("now-30m"), "-30 minute");
309        assert_eq!(normalize_shorthand("now-7d"), "-7 day");
310        assert_eq!(normalize_shorthand("now-2w"), "-2 week");
311        assert_eq!(normalize_shorthand("now-60s"), "-60 second");
312    }
313
314    #[test]
315    fn test_normalize_shortcut() {
316        assert_eq!(normalize_shorthand("-1h"), "-1 hour");
317        assert_eq!(normalize_shorthand("-30m"), "-30 minute");
318    }
319
320    #[test]
321    fn test_normalize_plus() {
322        assert_eq!(normalize_shorthand("now+2d"), "+2 day");
323    }
324
325    #[test]
326    fn test_normalize_passthrough() {
327        assert_eq!(normalize_shorthand("yesterday"), "yesterday");
328        assert_eq!(normalize_shorthand("@1740280800"), "@1740280800");
329        assert_eq!(
330            normalize_shorthand("2026-03-24T10:00:00Z"),
331            "2026-03-24T10:00:00Z"
332        );
333    }
334
335    // --- parse_time ---
336
337    #[test]
338    fn test_parse_time_now() {
339        let ts = parse_time("now").unwrap();
340        let actual_now = jiff::Zoned::now().timestamp().as_second();
341        assert!((ts - actual_now).abs() < 2);
342    }
343
344    #[test]
345    fn test_parse_time_relative() {
346        let now = jiff::Zoned::now().timestamp().as_second();
347        let ts = parse_time("now-1h").unwrap();
348        assert!((ts - (now - 3600)).abs() < 2);
349
350        let ts = parse_time("-30m").unwrap();
351        assert!((ts - (now - 1800)).abs() < 2);
352    }
353
354    #[test]
355    fn test_parse_time_unix() {
356        // 10-digit → seconds (pass-through)
357        assert_eq!(parse_time("@1740280800").unwrap(), 1740280800);
358    }
359
360    #[test]
361    fn test_parse_time_unix_milliseconds() {
362        // 13-digit → milliseconds, auto-converted to seconds
363        assert_eq!(parse_time("@1740280800000").unwrap(), 1740280800);
364        assert_eq!(parse_time("@1740280800123").unwrap(), 1740280800);
365    }
366
367    #[test]
368    fn test_parse_time_unix_invalid() {
369        assert!(parse_time("@not_a_number").is_err());
370    }
371
372    #[test]
373    fn test_parse_time_rfc3339() {
374        assert_eq!(parse_time("2026-03-24T10:00:00Z").unwrap(), 1774346400);
375    }
376
377    #[test]
378    fn test_parse_time_natural() {
379        // "yesterday" should be roughly now - 86400
380        let ts = parse_time("yesterday").unwrap();
381        let now = jiff::Zoned::now().timestamp().as_second();
382        assert!((ts - (now - 86400)).abs() < 2);
383    }
384
385    #[test]
386    fn test_parse_time_invalid() {
387        assert!(parse_time("").is_err());
388        assert!(parse_time("invalid-garbage").is_err());
389    }
390
391    // --- parse_step ---
392
393    #[test]
394    fn test_parse_step() {
395        assert_eq!(parse_step("15s").unwrap(), 15);
396        assert_eq!(parse_step("1m").unwrap(), 60);
397        assert_eq!(parse_step("5m").unwrap(), 300);
398        assert_eq!(parse_step("1h").unwrap(), 3600);
399    }
400
401    #[test]
402    fn test_parse_step_bare_number() {
403        assert_eq!(parse_step("30").unwrap(), 30);
404        assert_eq!(parse_step("60").unwrap(), 60);
405    }
406
407    #[test]
408    fn test_parse_step_day_week() {
409        assert_eq!(parse_step("1d").unwrap(), 86400);
410        assert_eq!(parse_step("2w").unwrap(), 1_209_600);
411    }
412
413    #[test]
414    fn test_parse_step_zero_rejected() {
415        assert!(parse_step("0").is_err());
416        assert!(parse_step("0s").is_err());
417    }
418
419    #[test]
420    fn test_parse_step_invalid() {
421        assert!(parse_step("").is_err());
422        assert!(parse_step("abc").is_err());
423    }
424
425    // --- validate/resolve ---
426
427    #[test]
428    fn test_validate_time_range_valid() {
429        assert!(validate_time_range(100, 200).is_ok());
430    }
431
432    #[test]
433    fn test_validate_time_range_invalid() {
434        assert!(validate_time_range(200, 100).is_err());
435        assert!(validate_time_range(100, 100).is_err());
436    }
437
438    // --- resolve_time_range ---
439
440    #[test]
441    fn test_resolve_time_range_defaults() {
442        // When both from and to are None, default_from and "now" are used.
443        let (from, to) = resolve_time_range(None, None, "now-1h").unwrap();
444        let now = jiff::Zoned::now().timestamp().as_second();
445        assert!((from - (now - 3600)).abs() < 2);
446        assert!((to - now).abs() < 2);
447    }
448
449    #[test]
450    fn test_resolve_time_range_explicit_values() {
451        // When both from and to are provided, they override defaults.
452        let (from, to) = resolve_time_range(Some("@1000"), Some("@2000"), "now-1h").unwrap();
453        assert_eq!(from, 1000);
454        assert_eq!(to, 2000);
455    }
456
457    #[test]
458    fn test_resolve_time_range_explicit_from_default_to() {
459        // Explicit from, default to ("now").
460        let (from, to) = resolve_time_range(Some("@1000"), None, "now-1h").unwrap();
461        let now = jiff::Zoned::now().timestamp().as_second();
462        assert_eq!(from, 1000);
463        assert!((to - now).abs() < 2);
464    }
465
466    #[test]
467    fn test_resolve_time_range_invalid_range() {
468        // from > to should fail validation.
469        assert!(resolve_time_range(Some("@2000"), Some("@1000"), "now-1h").is_err());
470    }
471}