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                let parsed = match bound {
83                    DateBound::Start => local_to_utc_checked(year, month, day, 0, 0, 0, 0),
84                    DateBound::End => local_to_utc_checked(year, month, day, 23, 59, 59, 999),
85                };
86                return parsed.ok_or_else(|| invalid_time_error(bound, value));
87            }
88        }
89    }
90
91    if let Ok(date) = DateTime::parse_from_rfc3339(value) {
92        return Ok(date.with_timezone(&Utc));
93    }
94
95    for pattern in [
96        "%Y-%m-%d %H:%M:%S",
97        "%Y-%m-%d %H:%M",
98        "%Y-%m-%dT%H:%M:%S",
99        "%Y-%m-%dT%H:%M",
100    ] {
101        if let Ok(date) = chrono::NaiveDateTime::parse_from_str(value, pattern) {
102            return local_naive_to_utc(date, value);
103        }
104    }
105
106    Err(invalid_time_error(bound, value))
107}
108
109pub fn resolve_date_range(
110    raw: &RawRangeOptions,
111    now: DateTime<Utc>,
112) -> Result<DateRange, AppError> {
113    let quick_ranges = [
114        raw.all,
115        raw.today,
116        raw.yesterday,
117        raw.month,
118        raw.last.is_some(),
119    ]
120    .into_iter()
121    .filter(|enabled| *enabled)
122    .count();
123
124    if quick_ranges > 1 {
125        return Err(AppError::new(
126            "Use only one quick range option: --all, --today, --yesterday, --month, or --last.",
127        ));
128    }
129
130    if quick_ranges == 1 && (raw.start.is_some() || raw.end.is_some()) {
131        return Err(AppError::new(
132            "Quick range options cannot be combined with --start or --end.",
133        ));
134    }
135
136    if raw.all {
137        return Ok(DateRange {
138            start: local_to_utc(1900, 1, 1, 0, 0, 0, 0),
139            end: local_to_utc(9999, 12, 31, 23, 59, 59, 999),
140        });
141    }
142
143    if raw.today {
144        let local = now.with_timezone(&Local);
145        return Ok(DateRange {
146            start: local_to_utc(local.year(), local.month(), local.day(), 0, 0, 0, 0),
147            end: now,
148        });
149    }
150
151    if raw.yesterday {
152        let local = now.with_timezone(&Local);
153        let start_today = local_to_utc(local.year(), local.month(), local.day(), 0, 0, 0, 0);
154        let start = start_today - Duration::days(1);
155        return Ok(DateRange {
156            start,
157            end: start + Duration::days(1) - Duration::milliseconds(1),
158        });
159    }
160
161    if raw.month {
162        let local = now.with_timezone(&Local);
163        return Ok(DateRange {
164            start: local_to_utc(local.year(), local.month(), 1, 0, 0, 0, 0),
165            end: now,
166        });
167    }
168
169    if let Some(last) = &raw.last {
170        let duration_ms = parse_duration_ms(last)?;
171        let start = now
172            .checked_sub_signed(Duration::milliseconds(duration_ms))
173            .ok_or_else(|| {
174                AppError::invalid_input("Invalid --last value. Duration is too large.")
175            })?;
176        return Ok(DateRange { start, end: now });
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" => Some(amount),
216        "d" => amount.checked_mul(24),
217        "w" => amount
218            .checked_mul(7)
219            .and_then(|amount| amount.checked_mul(24)),
220        "mo" => amount
221            .checked_mul(30)
222            .and_then(|amount| amount.checked_mul(24)),
223        _ => unreachable!("validated unit"),
224    }
225    .ok_or_else(|| AppError::invalid_input("Invalid --last value. Duration is too large."))?;
226
227    hours
228        .checked_mul(60)
229        .and_then(|minutes| minutes.checked_mul(60))
230        .and_then(|seconds| seconds.checked_mul(1000))
231        .ok_or_else(|| AppError::invalid_input("Invalid --last value. Duration is too large."))
232}
233
234fn invalid_time_error(bound: DateBound, value: &str) -> AppError {
235    let name = match bound {
236        DateBound::Start => "start",
237        DateBound::End => "end",
238    };
239    AppError::invalid_input(format!("Invalid {name} time: {value}"))
240}
241
242pub fn resolve_group_by(
243    explicit: Option<&str>,
244    raw: &RawRangeOptions,
245    range: &DateRange,
246) -> Result<StatGroupBy, AppError> {
247    if let Some(value) = explicit {
248        return StatGroupBy::parse(value);
249    }
250
251    if raw.all {
252        return Ok(StatGroupBy::Month);
253    }
254
255    if raw.month {
256        return Ok(StatGroupBy::Day);
257    }
258
259    let duration = range.end - range.start;
260    if duration <= Duration::hours(48) {
261        return Ok(StatGroupBy::Hour);
262    }
263
264    if duration <= Duration::days(31) {
265        return Ok(StatGroupBy::Day);
266    }
267
268    if range.end <= add_months_local(range.start, 6)? {
269        return Ok(StatGroupBy::Week);
270    }
271
272    Ok(StatGroupBy::Month)
273}
274
275fn add_months_local(date: DateTime<Utc>, months: i32) -> Result<DateTime<Utc>, AppError> {
276    let local = date.with_timezone(&Local);
277    let month_zero = local.month0() as i32 + months;
278    let year = local.year() + month_zero.div_euclid(12);
279    let month = month_zero.rem_euclid(12) as u32 + 1;
280    let day = local.day().min(days_in_month(year, month));
281    local_to_utc_checked(
282        year,
283        month,
284        day,
285        local.hour(),
286        local.minute(),
287        local.second(),
288        local.timestamp_subsec_millis(),
289    )
290    .ok_or_else(|| AppError::new("Invalid local time: month adjustment"))
291}
292
293fn days_in_month(year: i32, month: u32) -> u32 {
294    let (next_year, next_month) = if month == 12 {
295        (year + 1, 1)
296    } else {
297        (year, month + 1)
298    };
299    let next = NaiveDate::from_ymd_opt(next_year, next_month, 1).expect("valid next month");
300    (next - Duration::days(1)).day()
301}
302
303fn local_naive_to_utc(date: chrono::NaiveDateTime, value: &str) -> Result<DateTime<Utc>, AppError> {
304    match Local.from_local_datetime(&date) {
305        LocalResult::Single(value) => Ok(value.with_timezone(&Utc)),
306        LocalResult::Ambiguous(earliest, _) => Ok(earliest.with_timezone(&Utc)),
307        LocalResult::None => Err(AppError::new(format!("Invalid local time: {value}"))),
308    }
309}
310
311pub fn local_to_utc(
312    year: i32,
313    month: u32,
314    day: u32,
315    hour: u32,
316    minute: u32,
317    second: u32,
318    millis: u32,
319) -> DateTime<Utc> {
320    local_to_utc_checked(year, month, day, hour, minute, second, millis).expect("valid local date")
321}
322
323pub fn local_to_utc_checked(
324    year: i32,
325    month: u32,
326    day: u32,
327    hour: u32,
328    minute: u32,
329    second: u32,
330    millis: u32,
331) -> Option<DateTime<Utc>> {
332    let local_result = Local.with_ymd_and_hms(year, month, day, hour, minute, second);
333    match local_result {
334        LocalResult::Single(value) => value
335            .with_nanosecond(millis * 1_000_000)
336            .map(|value| value.with_timezone(&Utc)),
337        LocalResult::Ambiguous(earliest, _) => earliest
338            .with_nanosecond(millis * 1_000_000)
339            .map(|value| value.with_timezone(&Utc)),
340        LocalResult::None => {
341            let offset_seconds = Local::now().offset().fix().local_minus_utc();
342            let offset = FixedOffset::east_opt(offset_seconds)?;
343            offset
344                .with_ymd_and_hms(year, month, day, hour, minute, second)
345                .single()?
346                .with_nanosecond(millis * 1_000_000)
347                .map(|value| value.with_timezone(&Utc))
348        }
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    fn now() -> DateTime<Utc> {
357        DateTime::parse_from_rfc3339("2026-05-17T04:34:56.000Z")
358            .expect("now")
359            .with_timezone(&Utc)
360    }
361
362    #[test]
363    fn parses_date_only_bounds_as_local_day_edges() {
364        let start = parse_date_bound("2026-05-01", DateBound::Start)
365            .expect("start")
366            .with_timezone(&Local);
367        let end = parse_date_bound("2026-05-01", DateBound::End)
368            .expect("end")
369            .with_timezone(&Local);
370
371        assert_eq!(
372            (
373                start.year(),
374                start.month(),
375                start.day(),
376                start.hour(),
377                start.minute()
378            ),
379            (2026, 5, 1, 0, 0)
380        );
381        assert_eq!(
382            (
383                end.year(),
384                end.month(),
385                end.day(),
386                end.hour(),
387                end.minute(),
388                end.second(),
389                end.timestamp_subsec_millis()
390            ),
391            (2026, 5, 1, 23, 59, 59, 999)
392        );
393    }
394
395    #[test]
396    fn parses_local_t_separator_bounds_like_stats_cli() {
397        let parsed = parse_date_bound("2026-05-01T12:34", DateBound::Start)
398            .expect("local datetime")
399            .with_timezone(&Local);
400
401        assert_eq!(
402            (
403                parsed.year(),
404                parsed.month(),
405                parsed.day(),
406                parsed.hour(),
407                parsed.minute()
408            ),
409            (2026, 5, 1, 12, 34)
410        );
411    }
412
413    #[test]
414    fn resolves_quick_ranges() {
415        let range = resolve_date_range(
416            &RawRangeOptions {
417                today: true,
418                ..RawRangeOptions::default()
419            },
420            now(),
421        )
422        .expect("range");
423
424        let start = range.start.with_timezone(&Local);
425        assert_eq!(
426            (
427                start.year(),
428                start.month(),
429                start.day(),
430                start.hour(),
431                start.minute()
432            ),
433            (2026, 5, 17, 0, 0)
434        );
435        assert_eq!(range.end, now());
436
437        let yesterday = resolve_date_range(
438            &RawRangeOptions {
439                yesterday: true,
440                ..RawRangeOptions::default()
441            },
442            now(),
443        )
444        .expect("range");
445        let yesterday_start = yesterday.start.with_timezone(&Local);
446        let yesterday_end = yesterday.end.with_timezone(&Local);
447        assert_eq!(
448            (
449                yesterday_start.year(),
450                yesterday_start.month(),
451                yesterday_start.day(),
452                yesterday_start.hour(),
453                yesterday_start.minute()
454            ),
455            (2026, 5, 16, 0, 0)
456        );
457        assert_eq!(
458            (
459                yesterday_end.year(),
460                yesterday_end.month(),
461                yesterday_end.day(),
462                yesterday_end.hour(),
463                yesterday_end.minute(),
464                yesterday_end.second(),
465                yesterday_end.timestamp_subsec_millis()
466            ),
467            (2026, 5, 16, 23, 59, 59, 999)
468        );
469    }
470
471    #[test]
472    fn parses_last_durations_like_typescript() {
473        assert_eq!(parse_duration_ms("12h").expect("duration"), 43_200_000);
474        assert_eq!(parse_duration_ms("7d").expect("duration"), 604_800_000);
475        assert_eq!(parse_duration_ms("2w").expect("duration"), 1_209_600_000);
476        assert_eq!(parse_duration_ms("1mo").expect("duration"), 2_592_000_000);
477        assert!(parse_duration_ms("0d").is_err());
478        assert!(parse_duration_ms("3m").is_err());
479        assert!(parse_duration_ms("9223372036854775807d").is_err());
480    }
481
482    #[test]
483    fn invalid_date_only_bounds_return_errors() {
484        let error = parse_date_bound("2026-02-31", DateBound::Start).expect_err("invalid date");
485
486        assert_eq!(error.message(), "Invalid start time: 2026-02-31");
487        assert_eq!(error.exit_code(), 2);
488    }
489
490    #[test]
491    fn rejects_conflicting_quick_ranges() {
492        let error = resolve_date_range(
493            &RawRangeOptions {
494                today: true,
495                last: Some("12h".to_string()),
496                ..RawRangeOptions::default()
497            },
498            now(),
499        )
500        .expect_err("conflict");
501
502        assert_eq!(
503            error.message(),
504            "Use only one quick range option: --all, --today, --yesterday, --month, or --last."
505        );
506    }
507
508    #[test]
509    fn resolves_default_group_by_from_range() {
510        let raw = RawRangeOptions::default();
511        let hour_range = DateRange {
512            start: now() - Duration::hours(12),
513            end: now(),
514        };
515        let day_range = DateRange {
516            start: now() - Duration::days(7),
517            end: now(),
518        };
519        let week_range = DateRange {
520            start: now() - Duration::days(90),
521            end: now(),
522        };
523        let month_range = DateRange {
524            start: now() - Duration::days(220),
525            end: now(),
526        };
527
528        assert_eq!(
529            resolve_group_by(None, &raw, &hour_range).expect("group"),
530            StatGroupBy::Hour
531        );
532        assert_eq!(
533            resolve_group_by(None, &raw, &day_range).expect("group"),
534            StatGroupBy::Day
535        );
536        assert_eq!(
537            resolve_group_by(None, &raw, &week_range).expect("group"),
538            StatGroupBy::Week
539        );
540        assert_eq!(
541            resolve_group_by(None, &raw, &month_range).expect("group"),
542            StatGroupBy::Month
543        );
544    }
545
546    #[test]
547    fn all_and_month_override_default_group_by() {
548        let range = DateRange {
549            start: now() - Duration::hours(1),
550            end: now(),
551        };
552
553        assert_eq!(
554            resolve_group_by(
555                None,
556                &RawRangeOptions {
557                    all: true,
558                    ..RawRangeOptions::default()
559                },
560                &range
561            )
562            .expect("group"),
563            StatGroupBy::Month
564        );
565        assert_eq!(
566            resolve_group_by(
567                None,
568                &RawRangeOptions {
569                    month: true,
570                    ..RawRangeOptions::default()
571                },
572                &range
573            )
574            .expect("group"),
575            StatGroupBy::Day
576        );
577        assert_eq!(
578            resolve_group_by(Some("cwd"), &RawRangeOptions::default(), &range).expect("group"),
579            StatGroupBy::Cwd
580        );
581    }
582}