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: account_history
127 .as_ref()
128 .and_then(|_| options.account_id.clone()),
129 account_history,
130 };
131 let diagnostics =
132 process_usage_records(&resolved, |record| records.push(record.to_owned_record()))?;
133
134 Ok(UsageRecordsReport {
135 start: options.start,
136 end: options.end,
137 sessions_dir: path_to_string(&options.sessions_dir),
138 records,
139 diagnostics,
140 })
141}
142
143pub fn run_stat_command(
144 view: Option<&str>,
145 session: Option<&str>,
146 options: StatCommandOptions,
147 now: DateTime<Utc>,
148) -> Result<String, AppError> {
149 match view {
150 None => {
151 let resolved = resolve_stat_options(&options, now, false)?;
152 let report = read_usage_stats(&resolved)?;
153 format_usage_stats(&report, resolved.format, resolved.verbose)
154 }
155 Some("sessions") => {
156 let mut resolved = resolve_stat_options(&options, now, session.is_some())?;
157 if let Some(session_id) = session {
158 resolved.scan_all_files = true;
159 let report = read_usage_session_detail(&resolved, session_id)?;
160 format_usage_session_detail(
161 &report,
162 resolved.format,
163 resolved.verbose,
164 options.detail,
165 )
166 } else {
167 let top = match options.top.as_deref() {
168 Some(value) => Some(parse_positive_usize(value, "--top")?),
169 None => None,
170 }
171 .or(resolved.limit)
172 .unwrap_or(10);
173 let report = read_usage_sessions(&resolved, top)?;
174 format_usage_sessions(&report, resolved.format, resolved.verbose)
175 }
176 }
177 Some(other) => Err(AppError::new(format!("Unknown stat view: {other}"))),
178 }
179}
180
181fn raw_range_options(raw: &StatCommandOptions) -> RawRangeOptions {
182 RawRangeOptions {
183 start: raw.start.clone(),
184 end: raw.end.clone(),
185 all: raw.all,
186 today: raw.today,
187 yesterday: raw.yesterday,
188 month: raw.month,
189 last: raw.last.clone(),
190 }
191}
192
193fn resolve_stat_options(
194 raw: &StatCommandOptions,
195 now: DateTime<Utc>,
196 force_full_scan: bool,
197) -> Result<ResolvedStatOptions, AppError> {
198 let format = if raw.json {
199 StatFormat::Json
200 } else {
201 match raw.format.as_deref() {
202 Some(value) => StatFormat::parse(value)?,
203 None => StatFormat::Table,
204 }
205 };
206 let range_options = raw_range_options(raw);
207 let range = time::resolve_date_range(&range_options, now)?;
208 if range.start > range.end {
209 return Err(AppError::new(
210 "The stat start time must be earlier than or equal to the end time.",
211 ));
212 }
213
214 let group_by = match raw.group_by.as_deref() {
215 Some(value) => StatGroupBy::parse(value)?,
216 None => time::resolve_group_by(None, &range_options, &range)?,
217 };
218 let sort_by = match raw.sort.as_deref() {
219 Some(value) => Some(StatSort::parse(value)?),
220 None => None,
221 };
222 let limit = match raw.limit.as_deref() {
223 Some(value) => Some(parse_positive_usize(value, "--limit")?),
224 None => None,
225 };
226 let paths = resolve_storage_paths(&StorageOptions {
227 codex_home: raw.codex_home.clone(),
228 auth_file: raw.auth_file.clone(),
229 profile_store_dir: None,
230 account_history_file: raw.account_history_file.clone(),
231 cycle_file: None,
232 sessions_dir: raw.sessions_dir.clone(),
233 });
234 let account_id = normalize_optional_account_id(raw.account_id.as_deref());
235 let needs_account_history = account_id.is_some() || group_by == StatGroupBy::Account;
236 let account_history = if needs_account_history {
237 Some(ensure_usage_account_history(
238 &paths.account_history_file,
239 raw,
240 now,
241 )?)
242 } else {
243 None
244 };
245
246 Ok(ResolvedStatOptions {
247 start: range.start,
248 end: range.end,
249 group_by,
250 format,
251 sessions_dir: paths.sessions_dir,
252 sort_by,
253 limit,
254 include_reasoning_effort: raw.reasoning_effort,
255 scan_all_files: raw.full_scan || force_full_scan,
256 verbose: raw.verbose,
257 account_id,
258 account_history,
259 })
260}
261
262fn read_usage_stats(options: &ResolvedStatOptions) -> Result<UsageStatsReport, AppError> {
263 let accumulator = UsageStatsAccumulator::new(
264 options.start,
265 options.end,
266 options.group_by,
267 path_to_string(&options.sessions_dir),
268 options.include_reasoning_effort,
269 options.sort_by,
270 options.limit,
271 );
272 let (accumulator, diagnostics) = process_usage_records_parallel(options, accumulator)?;
273 Ok(accumulator.finish(Some(diagnostics)))
274}
275
276fn read_usage_sessions(
277 options: &ResolvedStatOptions,
278 limit: usize,
279) -> Result<UsageSessionsReport, AppError> {
280 let accumulator = UsageSessionsAccumulator::new(
281 options.start,
282 options.end,
283 path_to_string(&options.sessions_dir),
284 options.sort_by,
285 limit,
286 );
287 let (accumulator, diagnostics) = process_usage_records_parallel(options, accumulator)?;
288 Ok(accumulator.finish(Some(diagnostics)))
289}
290
291fn read_usage_session_detail(
292 options: &ResolvedStatOptions,
293 session_id: &str,
294) -> Result<UsageSessionDetailReport, AppError> {
295 let accumulator = UsageSessionDetailAccumulator::new(
296 options.start,
297 options.end,
298 path_to_string(&options.sessions_dir),
299 options.limit,
300 session_id.to_string(),
301 );
302 let (accumulator, diagnostics) = process_usage_records_parallel(options, accumulator)?;
303 Ok(accumulator.finish(Some(diagnostics)))
304}
305
306fn ensure_usage_account_history(
307 account_history_file: &Path,
308 raw: &StatCommandOptions,
309 now: DateTime<Utc>,
310) -> Result<UsageAccountHistory, AppError> {
311 let mut store = account_history::read_account_history_store(account_history_file)?;
312 if store.default_account.is_none() {
313 let report = read_codex_auth_status(
314 &AuthCommandOptions {
315 auth_file: raw.auth_file.clone(),
316 codex_home: raw.codex_home.clone(),
317 store_dir: None,
318 account_history_file: raw.account_history_file.clone(),
319 },
320 now,
321 )?;
322 let account_id = report
323 .summary
324 .chatgpt_account_id
325 .clone()
326 .or(report.summary.token_account_id.clone())
327 .ok_or_else(|| AppError::new("No account id found in auth.json."))?;
328 store = account_history::ensure_default_account_in_file(
329 account_history_file,
330 AccountHistoryAccount::auth_json(
331 account_id,
332 now,
333 report.summary.name.clone(),
334 report.summary.email.clone(),
335 report.summary.plan_type.clone(),
336 ),
337 )?;
338 }
339 account_history::usage_account_history_from_store(store)?
340 .ok_or_else(|| AppError::new("No account history default account found."))
341}
342
343fn normalize_optional_account_id(value: Option<&str>) -> Option<String> {
344 let normalized = value?.trim();
345 if normalized.is_empty() {
346 None
347 } else {
348 Some(normalized.to_string())
349 }
350}
351
352fn parse_positive_usize(value: &str, name: &str) -> Result<usize, AppError> {
353 let parsed = value.parse::<usize>().map_err(|_| {
354 AppError::invalid_input(format!(
355 "Invalid {name} value. Expected a positive integer."
356 ))
357 })?;
358 if parsed == 0 {
359 return Err(AppError::invalid_input(format!(
360 "Invalid {name} value. Expected a positive integer."
361 )));
362 }
363 Ok(parsed)
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369
370 #[test]
371 fn resolves_stat_command_options() {
372 let options = StatCommandOptions {
373 group_by: Some("model".to_string()),
374 sort: Some("credits".to_string()),
375 limit: Some("1".to_string()),
376 reasoning_effort: true,
377 all: true,
378 full_scan: true,
379 verbose: true,
380 json: true,
381 sessions_dir: Some(PathBuf::from("/tmp/sessions")),
382 ..StatCommandOptions::default()
383 };
384 let resolved = resolve_stat_options(
385 &options,
386 DateTime::parse_from_rfc3339("2026-05-17T00:00:00.000Z")
387 .expect("now")
388 .with_timezone(&Utc),
389 false,
390 )
391 .expect("resolve");
392
393 assert_eq!(resolved.group_by, StatGroupBy::Model);
394 assert_eq!(resolved.sort_by, Some(StatSort::Credits));
395 assert_eq!(resolved.limit, Some(1));
396 assert!(resolved.include_reasoning_effort);
397 assert!(resolved.scan_all_files);
398 assert_eq!(resolved.format, StatFormat::Json);
399 }
400}