1use super::accumulators::{
2 LimitUsageAccumulator, LimitUsageAccumulatorConfig, UsageSessionDetailAccumulator,
3 UsageSessionsAccumulator, UsageStatsAccumulator,
4};
5use super::formatters::{
6 format_limit_usage, format_usage_session_detail, format_usage_sessions, format_usage_stats,
7};
8use super::reports::{
9 LimitUsageGroupBy, LimitUsageReport, UsageRecordsReadOptions, UsageRecordsReport,
10 UsageSessionDetailReport, UsageSessionsReport, UsageStatsReport,
11};
12use super::scan::{process_usage_records, process_usage_records_parallel};
13use super::{StatFormat, StatSort};
14use crate::account_history::{self, UsageAccountHistory};
15use crate::auth::{ensure_usage_account_history, AuthCommandOptions};
16use crate::error::AppError;
17use crate::limits::{
18 build_limit_windows_report, read_rate_limit_samples_report, LimitReportOptions,
19 LimitWindowSelector, RateLimitSamplesReadOptions,
20};
21use crate::storage::{path_to_string, resolve_storage_paths, StorageOptions};
22use crate::time::{self, RawRangeOptions, StatGroupBy};
23use chrono::{DateTime, Duration, Utc};
24use std::path::PathBuf;
25
26#[derive(Debug, Clone, Default, Eq, PartialEq)]
27pub struct StatCommandOptions {
28 pub start: Option<String>,
29 pub end: Option<String>,
30 pub group_by: Option<String>,
31 pub limit_window: Option<String>,
32 pub format: Option<String>,
33 pub codex_home: Option<PathBuf>,
34 pub sessions_dir: Option<PathBuf>,
35 pub auth_file: Option<PathBuf>,
36 pub account_history_file: Option<PathBuf>,
37 pub today: bool,
38 pub yesterday: bool,
39 pub month: bool,
40 pub all: bool,
41 pub reasoning_effort: bool,
42 pub account_id: Option<String>,
43 pub last: Option<String>,
44 pub sort: Option<String>,
45 pub limit: Option<String>,
46 pub top: Option<String>,
47 pub detail: bool,
48 pub full_scan: bool,
49 pub verbose: bool,
50 pub json: bool,
51}
52
53#[derive(Debug, Clone)]
54pub(super) struct ResolvedStatOptions {
55 pub(super) start: DateTime<Utc>,
56 pub(super) end: DateTime<Utc>,
57 pub(super) group_by: StatGroupBy,
58 pub(super) limit_window: Option<LimitWindowSelector>,
59 pub(super) limit_group_by: Option<StatGroupBy>,
60 pub(super) format: StatFormat,
61 pub(super) sessions_dir: PathBuf,
62 pub(super) account_history_file: Option<PathBuf>,
63 pub(super) sort_by: Option<StatSort>,
64 pub(super) limit: Option<usize>,
65 pub(super) include_reasoning_effort: bool,
66 pub(super) scan_all_files: bool,
67 pub(super) verbose: bool,
68 pub(super) account_id: Option<String>,
69 pub(super) account_history: Option<UsageAccountHistory>,
70}
71
72#[derive(Debug, Clone)]
73pub struct ResolvedStatRangeOptions {
74 pub start: DateTime<Utc>,
75 pub end: DateTime<Utc>,
76 pub format: StatFormat,
77 pub sessions_dir: PathBuf,
78 pub verbose: bool,
79}
80
81pub fn resolve_stat_range_options_from_raw(
82 raw: &StatCommandOptions,
83 now: DateTime<Utc>,
84) -> Result<ResolvedStatRangeOptions, AppError> {
85 let format = if raw.json {
86 StatFormat::Json
87 } else {
88 match raw.format.as_deref() {
89 Some(value) => StatFormat::parse(value)?,
90 None => StatFormat::Table,
91 }
92 };
93 let range_options = raw_range_options(raw);
94 let range = time::resolve_date_range(&range_options, now)?;
95 if range.start > range.end {
96 return Err(AppError::new(
97 "The stat start time must be earlier than or equal to the end time.",
98 ));
99 }
100 let paths = resolve_storage_paths(&StorageOptions {
101 codex_home: raw.codex_home.clone(),
102 auth_file: raw.auth_file.clone(),
103 profile_store_dir: None,
104 account_history_file: raw.account_history_file.clone(),
105 sessions_dir: raw.sessions_dir.clone(),
106 });
107
108 Ok(ResolvedStatRangeOptions {
109 start: range.start,
110 end: range.end,
111 format,
112 sessions_dir: paths.sessions_dir,
113 verbose: raw.verbose,
114 })
115}
116
117pub fn read_usage_records_report(
118 options: &UsageRecordsReadOptions,
119) -> Result<UsageRecordsReport, AppError> {
120 let account_history = match &options.account_history_file {
121 Some(path) => account_history::read_optional_usage_account_history(path)?,
122 None => None,
123 };
124 let mut records = Vec::new();
125 let resolved = ResolvedStatOptions {
126 start: options.start,
127 end: options.end,
128 group_by: StatGroupBy::Day,
129 limit_window: None,
130 limit_group_by: None,
131 format: StatFormat::Json,
132 sessions_dir: options.sessions_dir.clone(),
133 account_history_file: options.account_history_file.clone(),
134 sort_by: None,
135 limit: None,
136 include_reasoning_effort: false,
137 scan_all_files: options.scan_all_files,
138 verbose: false,
139 account_id: options.account_id.clone(),
140 account_history,
141 };
142 let diagnostics =
143 process_usage_records(&resolved, |record| records.push(record.to_owned_record()))?;
144
145 Ok(UsageRecordsReport {
146 start: options.start,
147 end: options.end,
148 sessions_dir: path_to_string(&options.sessions_dir),
149 records,
150 diagnostics,
151 })
152}
153
154pub fn run_stat_command(
155 view: Option<&str>,
156 session: Option<&str>,
157 options: StatCommandOptions,
158 now: DateTime<Utc>,
159) -> Result<String, AppError> {
160 match view {
161 None => {
162 let resolved = resolve_stat_options(&options, now, false)?;
163 if resolved.limit_window.is_some() {
164 let report = read_limit_usage_stats(&resolved)?;
165 format_limit_usage(&report, resolved.format, resolved.verbose)
166 } else {
167 let report = read_usage_stats(&resolved)?;
168 format_usage_stats(&report, resolved.format, resolved.verbose)
169 }
170 }
171 Some("sessions") => {
172 if options.limit_window.is_some() {
173 return Err(AppError::invalid_input(
174 "stat sessions does not support --limit-window. Use stat --limit-window 5h or 7d without a view.",
175 ));
176 }
177 let mut resolved = resolve_stat_options(&options, now, session.is_some())?;
178 if let Some(session_id) = session {
179 resolved.scan_all_files = true;
180 let report = read_usage_session_detail(&resolved, session_id)?;
181 format_usage_session_detail(
182 &report,
183 resolved.format,
184 resolved.verbose,
185 options.detail,
186 )
187 } else {
188 let top = match options.top.as_deref() {
189 Some(value) => Some(parse_positive_usize(value, "--top")?),
190 None => None,
191 }
192 .or(resolved.limit)
193 .unwrap_or(10);
194 let report = read_usage_sessions(&resolved, top)?;
195 format_usage_sessions(&report, resolved.format, resolved.verbose)
196 }
197 }
198 Some(other) => Err(AppError::new(format!("Unknown stat view: {other}"))),
199 }
200}
201
202fn raw_range_options(raw: &StatCommandOptions) -> RawRangeOptions {
203 RawRangeOptions {
204 start: raw.start.clone(),
205 end: raw.end.clone(),
206 all: raw.all,
207 today: raw.today,
208 yesterday: raw.yesterday,
209 month: raw.month,
210 last: raw.last.clone(),
211 }
212}
213
214fn resolve_stat_options(
215 raw: &StatCommandOptions,
216 now: DateTime<Utc>,
217 force_full_scan: bool,
218) -> Result<ResolvedStatOptions, AppError> {
219 let format = if raw.json {
220 StatFormat::Json
221 } else {
222 match raw.format.as_deref() {
223 Some(value) => StatFormat::parse(value)?,
224 None => StatFormat::Table,
225 }
226 };
227 let range_options = raw_range_options(raw);
228 let range = time::resolve_date_range(&range_options, now)?;
229 if range.start > range.end {
230 return Err(AppError::new(
231 "The stat start time must be earlier than or equal to the end time.",
232 ));
233 }
234
235 let explicit_group_by = match raw.group_by.as_deref() {
236 Some(value) => Some(StatGroupBy::parse(value)?),
237 None => None,
238 };
239 let limit_window = match raw.limit_window.as_deref() {
240 Some(value) => Some(LimitWindowSelector::parse(value)?),
241 None => None,
242 };
243 if limit_window.is_some() {
244 validate_limit_window_group_by(explicit_group_by)?;
245 }
246 let group_by = match explicit_group_by {
247 Some(value) => value,
248 None => time::resolve_group_by(None, &range_options, &range)?,
249 };
250 let sort_by = match raw.sort.as_deref() {
251 Some(value) => Some(StatSort::parse(value)?),
252 None => None,
253 };
254 let limit = match raw.limit.as_deref() {
255 Some(value) => Some(parse_positive_usize(value, "--limit")?),
256 None => None,
257 };
258 let paths = resolve_storage_paths(&StorageOptions {
259 codex_home: raw.codex_home.clone(),
260 auth_file: raw.auth_file.clone(),
261 profile_store_dir: None,
262 account_history_file: raw.account_history_file.clone(),
263 sessions_dir: raw.sessions_dir.clone(),
264 });
265 let account_id = normalize_optional_account_id(raw.account_id.as_deref());
266 let needs_required_account_history = account_id.is_some() || group_by == StatGroupBy::Account;
267 let account_history = if needs_required_account_history {
268 Some(ensure_usage_account_history(
269 &paths.account_history_file,
270 &AuthCommandOptions {
271 auth_file: raw.auth_file.clone(),
272 codex_home: raw.codex_home.clone(),
273 store_dir: None,
274 account_history_file: raw.account_history_file.clone(),
275 },
276 now,
277 )?)
278 } else if limit_window.is_some() {
279 account_history::read_optional_usage_account_history(&paths.account_history_file)?
280 } else {
281 None
282 };
283
284 Ok(ResolvedStatOptions {
285 start: range.start,
286 end: range.end,
287 group_by,
288 limit_window,
289 limit_group_by: limit_window.and(explicit_group_by),
290 format,
291 sessions_dir: paths.sessions_dir,
292 account_history_file: Some(paths.account_history_file),
293 sort_by,
294 limit,
295 include_reasoning_effort: raw.reasoning_effort,
296 scan_all_files: raw.full_scan || force_full_scan,
297 verbose: raw.verbose,
298 account_id,
299 account_history,
300 })
301}
302
303fn validate_limit_window_group_by(group_by: Option<StatGroupBy>) -> Result<(), AppError> {
304 match group_by {
305 Some(StatGroupBy::Hour | StatGroupBy::Day | StatGroupBy::Week | StatGroupBy::Month) => {
306 Err(AppError::invalid_input(
307 "--limit-window can only be combined with --group-by model, cwd, or account. Time groupings hour, day, week, and month are not supported.",
308 ))
309 }
310 Some(StatGroupBy::Model | StatGroupBy::Cwd | StatGroupBy::Account) | None => Ok(()),
311 }
312}
313
314fn read_limit_usage_stats(options: &ResolvedStatOptions) -> Result<LimitUsageReport, AppError> {
315 let selector = options
316 .limit_window
317 .expect("limit usage report requires limit window");
318 let sample_start = options
319 .start
320 .checked_sub_signed(Duration::minutes(selector.window_minutes()))
321 .unwrap_or(options.start);
322 let samples = read_rate_limit_samples_report(&RateLimitSamplesReadOptions {
323 start: sample_start,
324 end: options.end,
325 sessions_dir: options.sessions_dir.clone(),
326 scan_all_files: options.scan_all_files,
327 account_history_file: options.account_history_file.clone(),
328 account_id: options.account_id.clone(),
329 plan_type: None,
330 window_minutes: Some(selector.window_minutes()),
331 })?;
332 let windows = build_limit_windows_report(&samples, LimitReportOptions::default())
333 .windows
334 .into_iter()
335 .filter(|window| window.reset_at > options.start && window.estimated_start <= options.end)
336 .collect();
337 let accumulator = LimitUsageAccumulator::new(LimitUsageAccumulatorConfig {
338 start: options.start,
339 end: options.end,
340 selector,
341 group_by: LimitUsageGroupBy::from_stat(options.limit_group_by),
342 sessions_dir: path_to_string(&options.sessions_dir),
343 include_reasoning_effort: options.include_reasoning_effort,
344 sort_by: options.sort_by,
345 limit: options.limit,
346 windows,
347 });
348 let rate_limit_diagnostics = samples.diagnostics;
349 let (accumulator, usage_diagnostics) = process_usage_records_parallel(options, accumulator)?;
350 Ok(accumulator.finish(usage_diagnostics, rate_limit_diagnostics))
351}
352
353fn read_usage_stats(options: &ResolvedStatOptions) -> Result<UsageStatsReport, AppError> {
354 let accumulator = UsageStatsAccumulator::new(
355 options.start,
356 options.end,
357 options.group_by,
358 path_to_string(&options.sessions_dir),
359 options.include_reasoning_effort,
360 options.sort_by,
361 options.limit,
362 );
363 let (accumulator, diagnostics) = process_usage_records_parallel(options, accumulator)?;
364 Ok(accumulator.finish(Some(diagnostics)))
365}
366
367fn read_usage_sessions(
368 options: &ResolvedStatOptions,
369 limit: usize,
370) -> Result<UsageSessionsReport, AppError> {
371 let accumulator = UsageSessionsAccumulator::new(
372 options.start,
373 options.end,
374 path_to_string(&options.sessions_dir),
375 options.sort_by,
376 limit,
377 );
378 let (accumulator, diagnostics) = process_usage_records_parallel(options, accumulator)?;
379 Ok(accumulator.finish(Some(diagnostics)))
380}
381
382fn read_usage_session_detail(
383 options: &ResolvedStatOptions,
384 session_id: &str,
385) -> Result<UsageSessionDetailReport, AppError> {
386 let accumulator = UsageSessionDetailAccumulator::new(
387 options.start,
388 options.end,
389 path_to_string(&options.sessions_dir),
390 options.limit,
391 session_id.to_string(),
392 );
393 let (accumulator, diagnostics) = process_usage_records_parallel(options, accumulator)?;
394 Ok(accumulator.finish(Some(diagnostics)))
395}
396
397fn normalize_optional_account_id(value: Option<&str>) -> Option<String> {
398 let normalized = value?.trim();
399 if normalized.is_empty() {
400 None
401 } else {
402 Some(normalized.to_string())
403 }
404}
405
406fn parse_positive_usize(value: &str, name: &str) -> Result<usize, AppError> {
407 let parsed = value.parse::<usize>().map_err(|_| {
408 AppError::invalid_input(format!(
409 "Invalid {name} value. Expected a positive integer."
410 ))
411 })?;
412 if parsed == 0 {
413 return Err(AppError::invalid_input(format!(
414 "Invalid {name} value. Expected a positive integer."
415 )));
416 }
417 Ok(parsed)
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423
424 #[test]
425 fn resolves_stat_command_options() {
426 let options = StatCommandOptions {
427 group_by: Some("model".to_string()),
428 sort: Some("credits".to_string()),
429 limit: Some("1".to_string()),
430 reasoning_effort: true,
431 all: true,
432 full_scan: true,
433 verbose: true,
434 json: true,
435 sessions_dir: Some(PathBuf::from("/tmp/sessions")),
436 ..StatCommandOptions::default()
437 };
438 let resolved = resolve_stat_options(
439 &options,
440 DateTime::parse_from_rfc3339("2026-05-17T00:00:00.000Z")
441 .expect("now")
442 .with_timezone(&Utc),
443 false,
444 )
445 .expect("resolve");
446
447 assert_eq!(resolved.group_by, StatGroupBy::Model);
448 assert_eq!(resolved.sort_by, Some(StatSort::Credits));
449 assert_eq!(resolved.limit, Some(1));
450 assert!(resolved.include_reasoning_effort);
451 assert!(resolved.scan_all_files);
452 assert_eq!(resolved.format, StatFormat::Json);
453 }
454
455 #[test]
456 fn validates_limit_window_contract_without_changing_default_group_by() {
457 let now = DateTime::parse_from_rfc3339("2026-05-17T00:00:00.000Z")
458 .expect("now")
459 .with_timezone(&Utc);
460 let default_group = resolve_stat_options(&StatCommandOptions::default(), now, false)
461 .expect("default resolve")
462 .group_by;
463
464 let limit_default_group = resolve_stat_options(
465 &StatCommandOptions {
466 limit_window: Some("7d".to_string()),
467 ..StatCommandOptions::default()
468 },
469 now,
470 false,
471 )
472 .expect("limit window default resolve")
473 .group_by;
474
475 assert_eq!(limit_default_group, default_group);
476
477 let model_group = resolve_stat_options(
478 &StatCommandOptions {
479 limit_window: Some("7d".to_string()),
480 group_by: Some("model".to_string()),
481 ..StatCommandOptions::default()
482 },
483 now,
484 false,
485 )
486 .expect("model group is compatible");
487 assert_eq!(model_group.group_by, StatGroupBy::Model);
488
489 let bad_group = resolve_stat_options(
490 &StatCommandOptions {
491 limit_window: Some("7d".to_string()),
492 group_by: Some("day".to_string()),
493 ..StatCommandOptions::default()
494 },
495 now,
496 false,
497 )
498 .expect_err("time group is incompatible");
499 assert!(bad_group.message().contains("model, cwd, or account"));
500
501 let bad_window = resolve_stat_options(
502 &StatCommandOptions {
503 limit_window: Some("bogus".to_string()),
504 ..StatCommandOptions::default()
505 },
506 now,
507 false,
508 )
509 .expect_err("unknown limit window");
510 assert!(bad_window.message().contains("5h"));
511 assert!(bad_window.message().contains("7d"));
512 }
513
514 #[test]
515 fn limit_window_group_by_compatibility_is_explicit() {
516 for group_by in [StatGroupBy::Model, StatGroupBy::Cwd, StatGroupBy::Account] {
517 validate_limit_window_group_by(Some(group_by)).expect("allowed stat group");
518 }
519
520 for group_by in [
521 StatGroupBy::Hour,
522 StatGroupBy::Day,
523 StatGroupBy::Week,
524 StatGroupBy::Month,
525 ] {
526 let error =
527 validate_limit_window_group_by(Some(group_by)).expect_err("time group rejected");
528 assert!(error.message().contains("model, cwd, or account"));
529 }
530
531 validate_limit_window_group_by(None).expect("omitted group-by is valid");
532 }
533}