Skip to main content

codex_ops/stats/
cli.rs

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}