Skip to main content

codex_ops/stats/
cli.rs

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}