Skip to main content

systemprompt_cli/commands/analytics/shared/
time.rs

1//! Time-range parsing and period bucketing for analytics queries.
2//!
3//! Parses the `--since` / `--until` flag forms (relative durations like `24h`,
4//! calendar dates, and RFC-3339 timestamps) into a UTC range via
5//! [`parse_time_range`], and snaps timestamps to hour/day/week/month boundaries
6//! with [`truncate_to_period`] for trend grouping.
7
8use anyhow::{Result, anyhow};
9use chrono::{DateTime, Datelike, Duration, NaiveDate, Timelike, Utc};
10
11pub fn parse_duration(s: &str) -> Option<Duration> {
12    let s = s.trim().to_lowercase();
13
14    s.strip_suffix('d')
15        .and_then(|d| d.parse::<i64>().ok())
16        .map(Duration::days)
17        .or_else(|| {
18            s.strip_suffix('h')
19                .and_then(|h| h.parse::<i64>().ok())
20                .map(Duration::hours)
21        })
22        .or_else(|| {
23            s.strip_suffix('m')
24                .and_then(|m| m.parse::<i64>().ok())
25                .map(Duration::minutes)
26        })
27        .or_else(|| {
28            s.strip_suffix('w')
29                .and_then(|w| w.parse::<i64>().ok())
30                .map(Duration::weeks)
31        })
32}
33
34pub fn parse_since(since: Option<&String>) -> Result<Option<DateTime<Utc>>> {
35    let Some(s) = since else {
36        return Ok(None);
37    };
38
39    let s = s.trim().to_lowercase();
40
41    if let Some(duration) = parse_duration(&s) {
42        return Ok(Some(Utc::now() - duration));
43    }
44
45    if let Ok(date) = NaiveDate::parse_from_str(&s, "%Y-%m-%d") {
46        return date
47            .and_hms_opt(0, 0, 0)
48            .map(|naive| DateTime::from_naive_utc_and_offset(naive, Utc))
49            .map(Some)
50            .ok_or_else(|| anyhow!("Invalid date: {}", s));
51    }
52
53    DateTime::parse_from_str(&format!("{}+00:00", s), "%Y-%m-%dT%H:%M:%S%:z")
54        .map(|dt| Some(dt.with_timezone(&Utc)))
55        .map_err(|_e| {
56            anyhow!(
57                "Invalid --since format: {}. Use '1h', '24h', '7d', '2026-01-13', or \
58                 '2026-01-13T10:00:00'",
59                s
60            )
61        })
62}
63
64pub fn parse_until(until: Option<&String>) -> Result<Option<DateTime<Utc>>> {
65    let Some(s) = until else {
66        return Ok(None);
67    };
68
69    let s = s.trim().to_lowercase();
70
71    if let Some(duration) = parse_duration(&s) {
72        return Ok(Some(Utc::now() - duration));
73    }
74
75    if let Ok(date) = NaiveDate::parse_from_str(&s, "%Y-%m-%d") {
76        return date
77            .and_hms_opt(23, 59, 59)
78            .map(|naive| DateTime::from_naive_utc_and_offset(naive, Utc))
79            .map(Some)
80            .ok_or_else(|| anyhow!("Invalid date: {}", s));
81    }
82
83    DateTime::parse_from_str(&format!("{}+00:00", s), "%Y-%m-%dT%H:%M:%S%:z")
84        .map(|dt| Some(dt.with_timezone(&Utc)))
85        .map_err(|_e| {
86            anyhow!(
87                "Invalid --until format: {}. Use '1h', '24h', '7d', '2026-01-13', or \
88                 '2026-01-13T10:00:00'",
89                s
90            )
91        })
92}
93
94pub fn parse_time_range(
95    since: Option<&String>,
96    until: Option<&String>,
97) -> Result<(DateTime<Utc>, DateTime<Utc>)> {
98    let start = parse_since(since)?.unwrap_or_else(|| Utc::now() - Duration::hours(24));
99    let end = parse_until(until)?.unwrap_or_else(Utc::now);
100    Ok((start, end))
101}
102
103pub fn truncate_to_period(dt: DateTime<Utc>, period: &str) -> DateTime<Utc> {
104    match period {
105        "hour" => dt
106            .date_naive()
107            .and_hms_opt(dt.time().hour(), 0, 0)
108            .map_or(dt, |naive| DateTime::from_naive_utc_and_offset(naive, Utc)),
109        "day" => dt
110            .date_naive()
111            .and_hms_opt(0, 0, 0)
112            .map_or(dt, |naive| DateTime::from_naive_utc_and_offset(naive, Utc)),
113        "week" => {
114            let days_since_monday = dt.weekday().num_days_from_monday();
115            (dt.date_naive() - Duration::days(i64::from(days_since_monday)))
116                .and_hms_opt(0, 0, 0)
117                .map_or(dt, |naive| DateTime::from_naive_utc_and_offset(naive, Utc))
118        },
119        "month" => dt
120            .date_naive()
121            .with_day(1)
122            .and_then(|d: NaiveDate| d.and_hms_opt(0, 0, 0))
123            .map_or(dt, |naive| DateTime::from_naive_utc_and_offset(naive, Utc)),
124        _ => dt,
125    }
126}
127
128pub use systemprompt_models::time_format::{
129    format_duration_ms, format_period_label, format_timestamp,
130};