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}