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}