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: 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}