1use super::accumulators::{
2 UsageSessionDetailAccumulator, UsageSessionsAccumulator, UsageStatsAccumulator,
3};
4use super::formatters::{format_usage_session_detail, format_usage_sessions, format_usage_stats};
5use super::reports::{
6 UsageRecordsReadOptions, UsageRecordsReport, UsageSessionDetailReport, UsageSessionsReport,
7 UsageStatsReport,
8};
9use super::scan::{process_usage_records, process_usage_records_parallel};
10use super::{StatFormat, StatSort};
11use crate::account_history::{self, AccountHistoryAccount, UsageAccountHistory};
12use crate::auth::{read_codex_auth_status, AuthCommandOptions};
13use crate::error::AppError;
14use crate::storage::{path_to_string, resolve_storage_paths, StorageOptions};
15use crate::time::{self, RawRangeOptions, StatGroupBy};
16use chrono::{DateTime, Utc};
17use std::path::{Path, PathBuf};
18
19#[derive(Debug, Clone, Default, Eq, PartialEq)]
20pub struct StatCommandOptions {
21 pub start: Option<String>,
22 pub end: Option<String>,
23 pub group_by: Option<String>,
24 pub format: Option<String>,
25 pub codex_home: Option<PathBuf>,
26 pub sessions_dir: Option<PathBuf>,
27 pub auth_file: Option<PathBuf>,
28 pub account_history_file: Option<PathBuf>,
29 pub today: bool,
30 pub yesterday: bool,
31 pub month: bool,
32 pub all: bool,
33 pub reasoning_effort: bool,
34 pub account_id: Option<String>,
35 pub last: Option<String>,
36 pub sort: Option<String>,
37 pub limit: Option<String>,
38 pub top: Option<String>,
39 pub detail: bool,
40 pub full_scan: bool,
41 pub verbose: bool,
42 pub json: bool,
43}
44
45#[derive(Debug, Clone)]
46pub(super) struct ResolvedStatOptions {
47 pub(super) start: DateTime<Utc>,
48 pub(super) end: DateTime<Utc>,
49 pub(super) group_by: StatGroupBy,
50 pub(super) format: StatFormat,
51 pub(super) sessions_dir: PathBuf,
52 pub(super) sort_by: Option<StatSort>,
53 pub(super) limit: Option<usize>,
54 pub(super) include_reasoning_effort: bool,
55 pub(super) scan_all_files: bool,
56 pub(super) verbose: bool,
57 pub(super) account_id: Option<String>,
58 pub(super) account_history: Option<UsageAccountHistory>,
59}
60
61#[derive(Debug, Clone)]
62pub struct ResolvedStatRangeOptions {
63 pub start: DateTime<Utc>,
64 pub end: DateTime<Utc>,
65 pub format: StatFormat,
66 pub sessions_dir: PathBuf,
67 pub verbose: bool,
68}
69
70pub fn resolve_stat_range_options_from_raw(
71 raw: &StatCommandOptions,
72 now: DateTime<Utc>,
73) -> Result<ResolvedStatRangeOptions, AppError> {
74 let format = if raw.json {
75 StatFormat::Json
76 } else {
77 match raw.format.as_deref() {
78 Some(value) => StatFormat::parse(value)?,
79 None => StatFormat::Table,
80 }
81 };
82 let range_options = raw_range_options(raw);
83 let range = time::resolve_date_range(&range_options, now)?;
84 if range.start > range.end {
85 return Err(AppError::new(
86 "The stat start time must be earlier than or equal to the end time.",
87 ));
88 }
89 let paths = resolve_storage_paths(&StorageOptions {
90 codex_home: raw.codex_home.clone(),
91 auth_file: raw.auth_file.clone(),
92 profile_store_dir: None,
93 account_history_file: raw.account_history_file.clone(),
94 cycle_file: None,
95 sessions_dir: raw.sessions_dir.clone(),
96 });
97
98 Ok(ResolvedStatRangeOptions {
99 start: range.start,
100 end: range.end,
101 format,
102 sessions_dir: paths.sessions_dir,
103 verbose: raw.verbose,
104 })
105}
106
107pub fn read_usage_records_report(
108 options: &UsageRecordsReadOptions,
109) -> Result<UsageRecordsReport, AppError> {
110 let account_history = match &options.account_history_file {
111 Some(path) => account_history::read_optional_usage_account_history(path)?,
112 None => None,
113 };
114 let mut records = Vec::new();
115 let resolved = ResolvedStatOptions {
116 start: options.start,
117 end: options.end,
118 group_by: StatGroupBy::Day,
119 format: StatFormat::Json,
120 sessions_dir: options.sessions_dir.clone(),
121 sort_by: None,
122 limit: None,
123 include_reasoning_effort: false,
124 scan_all_files: options.scan_all_files,
125 verbose: false,
126 account_id: options.account_id.clone(),
127 account_history,
128 };
129 let diagnostics =
130 process_usage_records(&resolved, |record| records.push(record.to_owned_record()))?;
131
132 Ok(UsageRecordsReport {
133 start: options.start,
134 end: options.end,
135 sessions_dir: path_to_string(&options.sessions_dir),
136 records,
137 diagnostics,
138 })
139}
140
141pub fn run_stat_command(
142 view: Option<&str>,
143 session: Option<&str>,
144 options: StatCommandOptions,
145 now: DateTime<Utc>,
146) -> Result<String, AppError> {
147 match view {
148 None => {
149 let resolved = resolve_stat_options(&options, now, false)?;
150 let report = read_usage_stats(&resolved)?;
151 format_usage_stats(&report, resolved.format, resolved.verbose)
152 }
153 Some("sessions") => {
154 let mut resolved = resolve_stat_options(&options, now, session.is_some())?;
155 if let Some(session_id) = session {
156 resolved.scan_all_files = true;
157 let report = read_usage_session_detail(&resolved, session_id)?;
158 format_usage_session_detail(
159 &report,
160 resolved.format,
161 resolved.verbose,
162 options.detail,
163 )
164 } else {
165 let top = match options.top.as_deref() {
166 Some(value) => Some(parse_positive_usize(value, "--top")?),
167 None => None,
168 }
169 .or(resolved.limit)
170 .unwrap_or(10);
171 let report = read_usage_sessions(&resolved, top)?;
172 format_usage_sessions(&report, resolved.format, resolved.verbose)
173 }
174 }
175 Some(other) => Err(AppError::new(format!("Unknown stat view: {other}"))),
176 }
177}
178
179fn raw_range_options(raw: &StatCommandOptions) -> RawRangeOptions {
180 RawRangeOptions {
181 start: raw.start.clone(),
182 end: raw.end.clone(),
183 all: raw.all,
184 today: raw.today,
185 yesterday: raw.yesterday,
186 month: raw.month,
187 last: raw.last.clone(),
188 }
189}
190
191fn resolve_stat_options(
192 raw: &StatCommandOptions,
193 now: DateTime<Utc>,
194 force_full_scan: bool,
195) -> Result<ResolvedStatOptions, AppError> {
196 let format = if raw.json {
197 StatFormat::Json
198 } else {
199 match raw.format.as_deref() {
200 Some(value) => StatFormat::parse(value)?,
201 None => StatFormat::Table,
202 }
203 };
204 let range_options = raw_range_options(raw);
205 let range = time::resolve_date_range(&range_options, now)?;
206 if range.start > range.end {
207 return Err(AppError::new(
208 "The stat start time must be earlier than or equal to the end time.",
209 ));
210 }
211
212 let group_by = match raw.group_by.as_deref() {
213 Some(value) => StatGroupBy::parse(value)?,
214 None => time::resolve_group_by(None, &range_options, &range)?,
215 };
216 let sort_by = match raw.sort.as_deref() {
217 Some(value) => Some(StatSort::parse(value)?),
218 None => None,
219 };
220 let limit = match raw.limit.as_deref() {
221 Some(value) => Some(parse_positive_usize(value, "--limit")?),
222 None => None,
223 };
224 let paths = resolve_storage_paths(&StorageOptions {
225 codex_home: raw.codex_home.clone(),
226 auth_file: raw.auth_file.clone(),
227 profile_store_dir: None,
228 account_history_file: raw.account_history_file.clone(),
229 cycle_file: None,
230 sessions_dir: raw.sessions_dir.clone(),
231 });
232 let account_id = normalize_optional_account_id(raw.account_id.as_deref());
233 let needs_account_history = account_id.is_some() || group_by == StatGroupBy::Account;
234 let account_history = if needs_account_history {
235 Some(ensure_usage_account_history(
236 &paths.account_history_file,
237 raw,
238 now,
239 )?)
240 } else {
241 None
242 };
243
244 Ok(ResolvedStatOptions {
245 start: range.start,
246 end: range.end,
247 group_by,
248 format,
249 sessions_dir: paths.sessions_dir,
250 sort_by,
251 limit,
252 include_reasoning_effort: raw.reasoning_effort,
253 scan_all_files: raw.full_scan || force_full_scan,
254 verbose: raw.verbose,
255 account_id,
256 account_history,
257 })
258}
259
260fn read_usage_stats(options: &ResolvedStatOptions) -> Result<UsageStatsReport, AppError> {
261 let accumulator = UsageStatsAccumulator::new(
262 options.start,
263 options.end,
264 options.group_by,
265 path_to_string(&options.sessions_dir),
266 options.include_reasoning_effort,
267 options.sort_by,
268 options.limit,
269 );
270 let (accumulator, diagnostics) = process_usage_records_parallel(options, accumulator)?;
271 Ok(accumulator.finish(Some(diagnostics)))
272}
273
274fn read_usage_sessions(
275 options: &ResolvedStatOptions,
276 limit: usize,
277) -> Result<UsageSessionsReport, AppError> {
278 let accumulator = UsageSessionsAccumulator::new(
279 options.start,
280 options.end,
281 path_to_string(&options.sessions_dir),
282 options.sort_by,
283 limit,
284 );
285 let (accumulator, diagnostics) = process_usage_records_parallel(options, accumulator)?;
286 Ok(accumulator.finish(Some(diagnostics)))
287}
288
289fn read_usage_session_detail(
290 options: &ResolvedStatOptions,
291 session_id: &str,
292) -> Result<UsageSessionDetailReport, AppError> {
293 let accumulator = UsageSessionDetailAccumulator::new(
294 options.start,
295 options.end,
296 path_to_string(&options.sessions_dir),
297 options.limit,
298 session_id.to_string(),
299 );
300 let (accumulator, diagnostics) = process_usage_records_parallel(options, accumulator)?;
301 Ok(accumulator.finish(Some(diagnostics)))
302}
303
304fn ensure_usage_account_history(
305 account_history_file: &Path,
306 raw: &StatCommandOptions,
307 now: DateTime<Utc>,
308) -> Result<UsageAccountHistory, AppError> {
309 let mut store = account_history::read_account_history_store(account_history_file)?;
310 if store.default_account.is_none() {
311 let report = read_codex_auth_status(
312 &AuthCommandOptions {
313 auth_file: raw.auth_file.clone(),
314 codex_home: raw.codex_home.clone(),
315 store_dir: None,
316 account_history_file: raw.account_history_file.clone(),
317 },
318 now,
319 )?;
320 let account_id = report
321 .summary
322 .chatgpt_account_id
323 .clone()
324 .or(report.summary.token_account_id.clone())
325 .ok_or_else(|| AppError::new("No account id found in auth.json."))?;
326 store = account_history::ensure_default_account_in_file(
327 account_history_file,
328 AccountHistoryAccount::auth_json(
329 account_id,
330 now,
331 report.summary.name.clone(),
332 report.summary.email.clone(),
333 report.summary.plan_type.clone(),
334 ),
335 )?;
336 }
337 account_history::usage_account_history_from_store(store)?
338 .ok_or_else(|| AppError::new("No account history default account found."))
339}
340
341fn normalize_optional_account_id(value: Option<&str>) -> Option<String> {
342 let normalized = value?.trim();
343 if normalized.is_empty() {
344 None
345 } else {
346 Some(normalized.to_string())
347 }
348}
349
350fn parse_positive_usize(value: &str, name: &str) -> Result<usize, AppError> {
351 let parsed = value.parse::<usize>().map_err(|_| {
352 AppError::invalid_input(format!(
353 "Invalid {name} value. Expected a positive integer."
354 ))
355 })?;
356 if parsed == 0 {
357 return Err(AppError::invalid_input(format!(
358 "Invalid {name} value. Expected a positive integer."
359 )));
360 }
361 Ok(parsed)
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367
368 #[test]
369 fn resolves_stat_command_options() {
370 let options = StatCommandOptions {
371 group_by: Some("model".to_string()),
372 sort: Some("credits".to_string()),
373 limit: Some("1".to_string()),
374 reasoning_effort: true,
375 all: true,
376 full_scan: true,
377 verbose: true,
378 json: true,
379 sessions_dir: Some(PathBuf::from("/tmp/sessions")),
380 ..StatCommandOptions::default()
381 };
382 let resolved = resolve_stat_options(
383 &options,
384 DateTime::parse_from_rfc3339("2026-05-17T00:00:00.000Z")
385 .expect("now")
386 .with_timezone(&Utc),
387 false,
388 )
389 .expect("resolve");
390
391 assert_eq!(resolved.group_by, StatGroupBy::Model);
392 assert_eq!(resolved.sort_by, Some(StatSort::Credits));
393 assert_eq!(resolved.limit, Some(1));
394 assert!(resolved.include_reasoning_effort);
395 assert!(resolved.scan_all_files);
396 assert_eq!(resolved.format, StatFormat::Json);
397 }
398}