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