Skip to main content

codex_ops/
time.rs

1use crate::error::AppError;
2use chrono::{
3    DateTime, Datelike, Duration, FixedOffset, Local, LocalResult, NaiveDate, Offset, TimeZone,
4    Timelike, Utc,
5};
6
7pub const ALL_USAGE_RANGE_START: &str = "1900-01-01T00:00:00+00:00";
8pub const ALL_USAGE_RANGE_END: &str = "9999-12-31T23:59:59.999+00:00";
9
10#[derive(Debug, Clone, Copy, Eq, PartialEq)]
11pub enum DateBound {
12    Start,
13    End,
14}
15
16#[derive(Debug, Clone, Copy, Eq, PartialEq)]
17pub struct DateRange {
18    pub start: DateTime<Utc>,
19    pub end: DateTime<Utc>,
20}
21
22#[derive(Debug, Clone, Default, Eq, PartialEq)]
23pub struct RawRangeOptions {
24    pub start: Option<String>,
25    pub end: Option<String>,
26    pub all: bool,
27    pub today: bool,
28    pub yesterday: bool,
29    pub month: bool,
30    pub last: Option<String>,
31}
32
33#[derive(Debug, Clone, Copy, Eq, PartialEq)]
34pub enum StatGroupBy {
35    Hour,
36    Day,
37    Week,
38    Month,
39    Model,
40    Cwd,
41    Account,
42}
43
44impl StatGroupBy {
45    pub fn parse(value: &str) -> Result<Self, AppError> {
46        match value {
47            "hour" => Ok(Self::Hour),
48            "day" => Ok(Self::Day),
49            "week" => Ok(Self::Week),
50            "month" => Ok(Self::Month),
51            "model" => Ok(Self::Model),
52            "cwd" => Ok(Self::Cwd),
53            "account" => Ok(Self::Account),
54            _ => Err(AppError::invalid_input(
55                "Invalid group-by value. Expected one of: hour, day, week, month, model, cwd, account.",
56            )),
57        }
58    }
59
60    pub fn as_str(self) -> &'static str {
61        match self {
62            Self::Hour => "hour",
63            Self::Day => "day",
64            Self::Week => "week",
65            Self::Month => "month",
66            Self::Model => "model",
67            Self::Cwd => "cwd",
68            Self::Account => "account",
69        }
70    }
71}
72
73pub fn parse_date_bound(value: &str, bound: DateBound) -> Result<DateTime<Utc>, AppError> {
74    if value.len() == 10 {
75        let parts = value.split('-').collect::<Vec<_>>();
76        if parts.len() == 3 {
77            if let (Ok(year), Ok(month), Ok(day)) = (
78                parts[0].parse::<i32>(),
79                parts[1].parse::<u32>(),
80                parts[2].parse::<u32>(),
81            ) {
82                return match bound {
83                    DateBound::Start => Ok(local_to_utc(year, month, day, 0, 0, 0, 0)),
84                    DateBound::End => Ok(local_to_utc(year, month, day, 23, 59, 59, 999)),
85                };
86            }
87        }
88    }
89
90    if let Ok(date) = DateTime::parse_from_rfc3339(value) {
91        return Ok(date.with_timezone(&Utc));
92    }
93
94    for pattern in [
95        "%Y-%m-%d %H:%M:%S",
96        "%Y-%m-%d %H:%M",
97        "%Y-%m-%dT%H:%M:%S",
98        "%Y-%m-%dT%H:%M",
99    ] {
100        if let Ok(date) = chrono::NaiveDateTime::parse_from_str(value, pattern) {
101            return local_naive_to_utc(date, value);
102        }
103    }
104
105    let name = match bound {
106        DateBound::Start => "start",
107        DateBound::End => "end",
108    };
109    Err(AppError::new(format!("Invalid {name} time: {value}")))
110}
111
112pub fn resolve_date_range(
113    raw: &RawRangeOptions,
114    now: DateTime<Utc>,
115) -> Result<DateRange, AppError> {
116    let quick_ranges = [
117        raw.all,
118        raw.today,
119        raw.yesterday,
120        raw.month,
121        raw.last.is_some(),
122    ]
123    .into_iter()
124    .filter(|enabled| *enabled)
125    .count();
126
127    if quick_ranges > 1 {
128        return Err(AppError::new(
129            "Use only one quick range option: --all, --today, --yesterday, --month, or --last.",
130        ));
131    }
132
133    if quick_ranges == 1 && (raw.start.is_some() || raw.end.is_some()) {
134        return Err(AppError::new(
135            "Quick range options cannot be combined with --start or --end.",
136        ));
137    }
138
139    if raw.all {
140        return Ok(DateRange {
141            start: local_to_utc(1900, 1, 1, 0, 0, 0, 0),
142            end: local_to_utc(9999, 12, 31, 23, 59, 59, 999),
143        });
144    }
145
146    if raw.today {
147        let local = now.with_timezone(&Local);
148        return Ok(DateRange {
149            start: local_to_utc(local.year(), local.month(), local.day(), 0, 0, 0, 0),
150            end: now,
151        });
152    }
153
154    if raw.yesterday {
155        let local = now.with_timezone(&Local);
156        let start_today = local_to_utc(local.year(), local.month(), local.day(), 0, 0, 0, 0);
157        let start = start_today - Duration::days(1);
158        return Ok(DateRange {
159            start,
160            end: start + Duration::days(1) - Duration::milliseconds(1),
161        });
162    }
163
164    if raw.month {
165        let local = now.with_timezone(&Local);
166        return Ok(DateRange {
167            start: local_to_utc(local.year(), local.month(), 1, 0, 0, 0, 0),
168            end: now,
169        });
170    }
171
172    if let Some(last) = &raw.last {
173        return Ok(DateRange {
174            start: now - Duration::milliseconds(parse_duration_ms(last)?),
175            end: now,
176        });
177    }
178
179    let end = match &raw.end {
180        Some(end) => parse_date_bound(end, DateBound::End)?,
181        None => now,
182    };
183    let start = match &raw.start {
184        Some(start) => parse_date_bound(start, DateBound::Start)?,
185        None => end - Duration::days(7),
186    };
187
188    Ok(DateRange { start, end })
189}
190
191pub fn parse_duration_ms(value: &str) -> Result<i64, AppError> {
192    let trimmed = value.trim();
193    let digits = trimmed
194        .chars()
195        .take_while(|char| char.is_ascii_digit())
196        .collect::<String>();
197    let unit = &trimmed[digits.len()..];
198
199    if digits.is_empty() || !matches!(unit, "h" | "d" | "w" | "mo") {
200        return Err(AppError::invalid_input(
201            "Invalid --last value. Use a duration like 12h, 7d, 2w, or 1mo.",
202        ));
203    }
204
205    let amount = digits.parse::<i64>().map_err(|_| {
206        AppError::invalid_input("Invalid --last value. Duration must be a positive integer.")
207    })?;
208    if amount <= 0 {
209        return Err(AppError::invalid_input(
210            "Invalid --last value. Duration must be a positive integer.",
211        ));
212    }
213
214    let hours = match unit {
215        "h" => amount,
216        "d" => amount * 24,
217        "w" => amount * 7 * 24,
218        "mo" => amount * 30 * 24,
219        _ => unreachable!("validated unit"),
220    };
221
222    Ok(hours * 60 * 60 * 1000)
223}
224
225pub fn resolve_group_by(
226    explicit: Option<&str>,
227    raw: &RawRangeOptions,
228    range: &DateRange,
229) -> Result<StatGroupBy, AppError> {
230    if let Some(value) = explicit {
231        return StatGroupBy::parse(value);
232    }
233
234    if raw.all {
235        return Ok(StatGroupBy::Month);
236    }
237
238    if raw.month {
239        return Ok(StatGroupBy::Day);
240    }
241
242    let duration = range.end - range.start;
243    if duration <= Duration::hours(48) {
244        return Ok(StatGroupBy::Hour);
245    }
246
247    if duration <= Duration::days(31) {
248        return Ok(StatGroupBy::Day);
249    }
250
251    if range.end <= add_months_local(range.start, 6)? {
252        return Ok(StatGroupBy::Week);
253    }
254
255    Ok(StatGroupBy::Month)
256}
257
258fn add_months_local(date: DateTime<Utc>, months: i32) -> Result<DateTime<Utc>, AppError> {
259    let local = date.with_timezone(&Local);
260    let month_zero = local.month0() as i32 + months;
261    let year = local.year() + month_zero.div_euclid(12);
262    let month = month_zero.rem_euclid(12) as u32 + 1;
263    let day = local.day().min(days_in_month(year, month));
264    local_to_utc_checked(
265        year,
266        month,
267        day,
268        local.hour(),
269        local.minute(),
270        local.second(),
271        local.timestamp_subsec_millis(),
272    )
273    .ok_or_else(|| AppError::new("Invalid local time: month adjustment"))
274}
275
276fn days_in_month(year: i32, month: u32) -> u32 {
277    let (next_year, next_month) = if month == 12 {
278        (year + 1, 1)
279    } else {
280        (year, month + 1)
281    };
282    let next = NaiveDate::from_ymd_opt(next_year, next_month, 1).expect("valid next month");
283    (next - Duration::days(1)).day()
284}
285
286fn local_naive_to_utc(date: chrono::NaiveDateTime, value: &str) -> Result<DateTime<Utc>, AppError> {
287    match Local.from_local_datetime(&date) {
288        LocalResult::Single(value) => Ok(value.with_timezone(&Utc)),
289        LocalResult::Ambiguous(earliest, _) => Ok(earliest.with_timezone(&Utc)),
290        LocalResult::None => Err(AppError::new(format!("Invalid local time: {value}"))),
291    }
292}
293
294pub fn local_to_utc(
295    year: i32,
296    month: u32,
297    day: u32,
298    hour: u32,
299    minute: u32,
300    second: u32,
301    millis: u32,
302) -> DateTime<Utc> {
303    local_to_utc_checked(year, month, day, hour, minute, second, millis).expect("valid local date")
304}
305
306pub fn local_to_utc_checked(
307    year: i32,
308    month: u32,
309    day: u32,
310    hour: u32,
311    minute: u32,
312    second: u32,
313    millis: u32,
314) -> Option<DateTime<Utc>> {
315    let local_result = Local.with_ymd_and_hms(year, month, day, hour, minute, second);
316    match local_result {
317        LocalResult::Single(value) => value
318            .with_nanosecond(millis * 1_000_000)
319            .map(|value| value.with_timezone(&Utc)),
320        LocalResult::Ambiguous(earliest, _) => earliest
321            .with_nanosecond(millis * 1_000_000)
322            .map(|value| value.with_timezone(&Utc)),
323        LocalResult::None => {
324            let offset_seconds = Local::now().offset().fix().local_minus_utc();
325            let offset = FixedOffset::east_opt(offset_seconds)?;
326            offset
327                .with_ymd_and_hms(year, month, day, hour, minute, second)
328                .single()?
329                .with_nanosecond(millis * 1_000_000)
330                .map(|value| value.with_timezone(&Utc))
331        }
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    fn now() -> DateTime<Utc> {
340        DateTime::parse_from_rfc3339("2026-05-17T04:34:56.000Z")
341            .expect("now")
342            .with_timezone(&Utc)
343    }
344
345    #[test]
346    fn parses_date_only_bounds_as_local_day_edges() {
347        let start = parse_date_bound("2026-05-01", DateBound::Start)
348            .expect("start")
349            .with_timezone(&Local);
350        let end = parse_date_bound("2026-05-01", DateBound::End)
351            .expect("end")
352            .with_timezone(&Local);
353
354        assert_eq!(
355            (
356                start.year(),
357                start.month(),
358                start.day(),
359                start.hour(),
360                start.minute()
361            ),
362            (2026, 5, 1, 0, 0)
363        );
364        assert_eq!(
365            (
366                end.year(),
367                end.month(),
368                end.day(),
369                end.hour(),
370                end.minute(),
371                end.second(),
372                end.timestamp_subsec_millis()
373            ),
374            (2026, 5, 1, 23, 59, 59, 999)
375        );
376    }
377
378    #[test]
379    fn parses_local_t_separator_bounds_like_stats_cli() {
380        let parsed = parse_date_bound("2026-05-01T12:34", DateBound::Start)
381            .expect("local datetime")
382            .with_timezone(&Local);
383
384        assert_eq!(
385            (
386                parsed.year(),
387                parsed.month(),
388                parsed.day(),
389                parsed.hour(),
390                parsed.minute()
391            ),
392            (2026, 5, 1, 12, 34)
393        );
394    }
395
396    #[test]
397    fn resolves_quick_ranges() {
398        let range = resolve_date_range(
399            &RawRangeOptions {
400                today: true,
401                ..RawRangeOptions::default()
402            },
403            now(),
404        )
405        .expect("range");
406
407        let start = range.start.with_timezone(&Local);
408        assert_eq!(
409            (
410                start.year(),
411                start.month(),
412                start.day(),
413                start.hour(),
414                start.minute()
415            ),
416            (2026, 5, 17, 0, 0)
417        );
418        assert_eq!(range.end, now());
419
420        let yesterday = resolve_date_range(
421            &RawRangeOptions {
422                yesterday: true,
423                ..RawRangeOptions::default()
424            },
425            now(),
426        )
427        .expect("range");
428        let yesterday_start = yesterday.start.with_timezone(&Local);
429        let yesterday_end = yesterday.end.with_timezone(&Local);
430        assert_eq!(
431            (
432                yesterday_start.year(),
433                yesterday_start.month(),
434                yesterday_start.day(),
435                yesterday_start.hour(),
436                yesterday_start.minute()
437            ),
438            (2026, 5, 16, 0, 0)
439        );
440        assert_eq!(
441            (
442                yesterday_end.year(),
443                yesterday_end.month(),
444                yesterday_end.day(),
445                yesterday_end.hour(),
446                yesterday_end.minute(),
447                yesterday_end.second(),
448                yesterday_end.timestamp_subsec_millis()
449            ),
450            (2026, 5, 16, 23, 59, 59, 999)
451        );
452    }
453
454    #[test]
455    fn parses_last_durations_like_typescript() {
456        assert_eq!(parse_duration_ms("12h").expect("duration"), 43_200_000);
457        assert_eq!(parse_duration_ms("7d").expect("duration"), 604_800_000);
458        assert_eq!(parse_duration_ms("2w").expect("duration"), 1_209_600_000);
459        assert_eq!(parse_duration_ms("1mo").expect("duration"), 2_592_000_000);
460        assert!(parse_duration_ms("0d").is_err());
461        assert!(parse_duration_ms("3m").is_err());
462    }
463
464    #[test]
465    fn rejects_conflicting_quick_ranges() {
466        let error = resolve_date_range(
467            &RawRangeOptions {
468                today: true,
469                last: Some("12h".to_string()),
470                ..RawRangeOptions::default()
471            },
472            now(),
473        )
474        .expect_err("conflict");
475
476        assert_eq!(
477            error.message(),
478            "Use only one quick range option: --all, --today, --yesterday, --month, or --last."
479        );
480    }
481
482    #[test]
483    fn resolves_default_group_by_from_range() {
484        let raw = RawRangeOptions::default();
485        let hour_range = DateRange {
486            start: now() - Duration::hours(12),
487            end: now(),
488        };
489        let day_range = DateRange {
490            start: now() - Duration::days(7),
491            end: now(),
492        };
493        let week_range = DateRange {
494            start: now() - Duration::days(90),
495            end: now(),
496        };
497        let month_range = DateRange {
498            start: now() - Duration::days(220),
499            end: now(),
500        };
501
502        assert_eq!(
503            resolve_group_by(None, &raw, &hour_range).expect("group"),
504            StatGroupBy::Hour
505        );
506        assert_eq!(
507            resolve_group_by(None, &raw, &day_range).expect("group"),
508            StatGroupBy::Day
509        );
510        assert_eq!(
511            resolve_group_by(None, &raw, &week_range).expect("group"),
512            StatGroupBy::Week
513        );
514        assert_eq!(
515            resolve_group_by(None, &raw, &month_range).expect("group"),
516            StatGroupBy::Month
517        );
518    }
519
520    #[test]
521    fn all_and_month_override_default_group_by() {
522        let range = DateRange {
523            start: now() - Duration::hours(1),
524            end: now(),
525        };
526
527        assert_eq!(
528            resolve_group_by(
529                None,
530                &RawRangeOptions {
531                    all: true,
532                    ..RawRangeOptions::default()
533                },
534                &range
535            )
536            .expect("group"),
537            StatGroupBy::Month
538        );
539        assert_eq!(
540            resolve_group_by(
541                None,
542                &RawRangeOptions {
543                    month: true,
544                    ..RawRangeOptions::default()
545                },
546                &range
547            )
548            .expect("group"),
549            StatGroupBy::Day
550        );
551        assert_eq!(
552            resolve_group_by(Some("cwd"), &RawRangeOptions::default(), &range).expect("group"),
553            StatGroupBy::Cwd
554        );
555    }
556}