Skip to main content

codex_ops/limits/
cli.rs

1use super::formatters::{
2    format_limit_current, format_limit_resets, format_limit_samples, format_limit_trend,
3    format_limit_windows,
4};
5use super::{
6    build_limit_current_report, build_limit_resets_report, build_limit_samples_report,
7    build_limit_trend_report, build_limit_windows_report, read_rate_limit_samples_report,
8    LimitReportOptions, LimitWindowSelector, RateLimitSamplesReadOptions,
9};
10use crate::auth::{ensure_usage_account_history, AuthCommandOptions};
11use crate::error::AppError;
12use crate::storage::{resolve_storage_paths, StorageOptions};
13use crate::time::{self, DateBound, RawRangeOptions};
14use chrono::{DateTime, Duration, Utc};
15use std::path::PathBuf;
16
17#[derive(Debug, Clone, Copy, Eq, PartialEq)]
18pub enum LimitCommand {
19    Current,
20    Windows,
21    Trend,
22    Resets,
23    Samples,
24}
25
26#[derive(Debug, Clone, Copy, Eq, PartialEq)]
27pub enum LimitFormat {
28    Table,
29    Json,
30    Csv,
31    Markdown,
32}
33
34impl LimitFormat {
35    fn parse(value: &str) -> Result<Self, AppError> {
36        match value {
37            "table" => Ok(Self::Table),
38            "json" => Ok(Self::Json),
39            "csv" => Ok(Self::Csv),
40            "markdown" => Ok(Self::Markdown),
41            _ => Err(AppError::invalid_input(
42                "Invalid format value. Expected one of: table, json, csv, markdown.",
43            )),
44        }
45    }
46}
47
48#[derive(Debug, Clone, Default, Eq, PartialEq)]
49pub struct LimitCommandOptions {
50    pub start: Option<String>,
51    pub end: Option<String>,
52    pub last: Option<String>,
53    pub format: Option<String>,
54    pub codex_home: Option<PathBuf>,
55    pub sessions_dir: Option<PathBuf>,
56    pub auth_file: Option<PathBuf>,
57    pub account_history_file: Option<PathBuf>,
58    pub account_id: Option<String>,
59    pub window: Option<String>,
60    pub early_only: bool,
61    pub json: bool,
62    pub verbose: bool,
63}
64
65const DEFAULT_LIMIT_RANGE_DAYS: i64 = 30;
66const DEFAULT_CURRENT_RANGE_DAYS: i64 = 7;
67
68#[derive(Debug, Clone)]
69struct ResolvedLimitOptions {
70    start: DateTime<Utc>,
71    end: DateTime<Utc>,
72    format: LimitFormat,
73    sessions_dir: PathBuf,
74    account_history_file: Option<PathBuf>,
75    account_id: Option<String>,
76    window_minutes: Option<i64>,
77    early_only: bool,
78    verbose: bool,
79}
80
81pub fn run_limit_command(
82    command: LimitCommand,
83    options: LimitCommandOptions,
84    now: DateTime<Utc>,
85) -> Result<String, AppError> {
86    let resolved = resolve_limit_options(command, &options, now)?;
87    let window_minutes = command_window_minutes(command, resolved.window_minutes);
88    let samples = read_rate_limit_samples_report(&RateLimitSamplesReadOptions {
89        start: resolved.start,
90        end: resolved.end,
91        sessions_dir: resolved.sessions_dir.clone(),
92        scan_all_files: false,
93        account_history_file: resolved.account_history_file.clone(),
94        account_id: resolved.account_id.clone(),
95        plan_type: None,
96        window_minutes,
97    })?;
98    let report_options = LimitReportOptions {
99        include_diagnostics: resolved.verbose,
100        include_source_evidence: resolved.verbose && resolved.format == LimitFormat::Json,
101    };
102
103    match command {
104        LimitCommand::Current => {
105            let report = build_limit_current_report(&samples, now, report_options);
106            format_limit_current(&report, resolved.format, resolved.verbose)
107        }
108        LimitCommand::Windows => {
109            let report = build_limit_windows_report(&samples, report_options);
110            format_limit_windows(&report, resolved.format, resolved.verbose)
111        }
112        LimitCommand::Trend => {
113            let report = build_limit_trend_report(&samples, window_minutes, report_options);
114            format_limit_trend(&report, resolved.format, resolved.verbose)
115        }
116        LimitCommand::Resets => {
117            let report = build_limit_resets_report(&samples, resolved.early_only, report_options);
118            format_limit_resets(&report, resolved.format, resolved.verbose)
119        }
120        LimitCommand::Samples => {
121            let report = build_limit_samples_report(&samples, report_options);
122            format_limit_samples(&report, resolved.format, resolved.verbose)
123        }
124    }
125}
126
127fn command_window_minutes(command: LimitCommand, window_minutes: Option<i64>) -> Option<i64> {
128    match (command, window_minutes) {
129        (LimitCommand::Current, None) => None,
130        (_, None) => Some(LimitWindowSelector::SevenDays.window_minutes()),
131        (_, Some(window_minutes)) => Some(window_minutes),
132    }
133}
134
135fn resolve_limit_options(
136    command: LimitCommand,
137    raw: &LimitCommandOptions,
138    now: DateTime<Utc>,
139) -> Result<ResolvedLimitOptions, AppError> {
140    let format = if raw.json {
141        LimitFormat::Json
142    } else {
143        match raw.format.as_deref() {
144            Some(value) => LimitFormat::parse(value)?,
145            None => LimitFormat::Table,
146        }
147    };
148    let range = resolve_limit_date_range(command, raw, now)?;
149    if range.start > range.end {
150        return Err(AppError::new(
151            "The limit start time must be earlier than or equal to the end time.",
152        ));
153    }
154
155    let paths = resolve_storage_paths(&StorageOptions {
156        codex_home: raw.codex_home.clone(),
157        auth_file: raw.auth_file.clone(),
158        profile_store_dir: None,
159        account_history_file: raw.account_history_file.clone(),
160        sessions_dir: raw.sessions_dir.clone(),
161    });
162
163    let account_id = normalize_optional_string(raw.account_id.as_deref());
164    if account_id.is_some() {
165        ensure_usage_account_history(
166            &paths.account_history_file,
167            &AuthCommandOptions {
168                auth_file: raw.auth_file.clone(),
169                codex_home: raw.codex_home.clone(),
170                store_dir: None,
171                account_history_file: raw.account_history_file.clone(),
172            },
173            now,
174        )?;
175    }
176
177    Ok(ResolvedLimitOptions {
178        start: range.start,
179        end: range.end,
180        format,
181        sessions_dir: paths.sessions_dir,
182        account_history_file: Some(paths.account_history_file),
183        account_id,
184        window_minutes: match raw.window.as_deref() {
185            Some(value) => Some(LimitWindowSelector::parse(value)?.window_minutes()),
186            None => None,
187        },
188        early_only: raw.early_only,
189        verbose: raw.verbose,
190    })
191}
192
193fn resolve_limit_date_range(
194    command: LimitCommand,
195    raw: &LimitCommandOptions,
196    now: DateTime<Utc>,
197) -> Result<time::DateRange, AppError> {
198    if command == LimitCommand::Current {
199        if raw.start.is_some() || raw.end.is_some() || raw.last.is_some() {
200            return Err(AppError::invalid_input(
201                "limit current uses a fixed recent 7-day range and does not accept --start, --end, or --last.",
202            ));
203        }
204        return Ok(time::DateRange {
205            start: now - Duration::days(DEFAULT_CURRENT_RANGE_DAYS),
206            end: now,
207        });
208    }
209
210    if raw.start.is_none() && raw.last.is_none() {
211        let end = match &raw.end {
212            Some(end) => time::parse_date_bound(end, DateBound::End)?,
213            None => now,
214        };
215        return Ok(time::DateRange {
216            start: end - Duration::days(DEFAULT_LIMIT_RANGE_DAYS),
217            end,
218        });
219    }
220
221    time::resolve_date_range(
222        &RawRangeOptions {
223            start: raw.start.clone(),
224            end: raw.end.clone(),
225            last: raw.last.clone(),
226            all: false,
227            today: false,
228            yesterday: false,
229            month: false,
230        },
231        now,
232    )
233}
234
235fn normalize_optional_string(value: Option<&str>) -> Option<String> {
236    let value = value?.trim();
237    if value.is_empty() {
238        None
239    } else {
240        Some(value.to_string())
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use chrono::TimeZone;
248
249    #[test]
250    fn json_flag_overrides_format_and_window_values_are_fixed() {
251        let resolved = resolve_limit_options(
252            LimitCommand::Windows,
253            &LimitCommandOptions {
254                window: Some("7d".to_string()),
255                format: Some("csv".to_string()),
256                json: true,
257                sessions_dir: Some(PathBuf::from("/tmp/sessions")),
258                ..LimitCommandOptions::default()
259            },
260            now(),
261        )
262        .expect("resolve options");
263
264        assert_eq!(resolved.window_minutes, Some(10080));
265        assert_eq!(resolved.format, LimitFormat::Json);
266    }
267
268    #[test]
269    fn default_range_reads_recent_thirty_days() {
270        let resolved = resolve_limit_options(
271            LimitCommand::Windows,
272            &LimitCommandOptions {
273                sessions_dir: Some(PathBuf::from("/tmp/sessions")),
274                ..LimitCommandOptions::default()
275            },
276            now(),
277        )
278        .expect("resolve options");
279
280        assert_eq!(resolved.start, now() - Duration::days(30));
281        assert_eq!(resolved.end, now());
282    }
283
284    #[test]
285    fn end_without_start_uses_thirty_day_lookback() {
286        let resolved = resolve_limit_options(
287            LimitCommand::Windows,
288            &LimitCommandOptions {
289                end: Some("2026-05-10T00:00:00Z".to_string()),
290                sessions_dir: Some(PathBuf::from("/tmp/sessions")),
291                ..LimitCommandOptions::default()
292            },
293            now(),
294        )
295        .expect("resolve options");
296        let end = Utc
297            .with_ymd_and_hms(2026, 5, 10, 0, 0, 0)
298            .single()
299            .expect("valid end");
300
301        assert_eq!(resolved.start, end - Duration::days(30));
302        assert_eq!(resolved.end, end);
303    }
304
305    #[test]
306    fn explicit_last_keeps_requested_duration() {
307        let resolved = resolve_limit_options(
308            LimitCommand::Windows,
309            &LimitCommandOptions {
310                last: Some("7d".to_string()),
311                sessions_dir: Some(PathBuf::from("/tmp/sessions")),
312                ..LimitCommandOptions::default()
313            },
314            now(),
315        )
316        .expect("resolve options");
317
318        assert_eq!(resolved.start, now() - Duration::days(7));
319        assert_eq!(resolved.end, now());
320    }
321
322    #[test]
323    fn current_range_is_fixed_to_recent_seven_days() {
324        let resolved = resolve_limit_options(
325            LimitCommand::Current,
326            &LimitCommandOptions {
327                sessions_dir: Some(PathBuf::from("/tmp/sessions")),
328                ..LimitCommandOptions::default()
329            },
330            now(),
331        )
332        .expect("resolve current options");
333
334        assert_eq!(resolved.start, now() - Duration::days(7));
335        assert_eq!(resolved.end, now());
336    }
337
338    #[test]
339    fn current_rejects_explicit_date_ranges() {
340        let error = resolve_limit_options(
341            LimitCommand::Current,
342            &LimitCommandOptions {
343                last: Some("30d".to_string()),
344                sessions_dir: Some(PathBuf::from("/tmp/sessions")),
345                ..LimitCommandOptions::default()
346            },
347            now(),
348        )
349        .expect_err("current range override");
350
351        assert_eq!(error.exit_code(), 2);
352        assert!(error.message().contains("does not accept"));
353    }
354
355    #[test]
356    fn invalid_window_is_rejected() {
357        let bad_window = resolve_limit_options(
358            LimitCommand::Windows,
359            &LimitCommandOptions {
360                window: Some("1d".to_string()),
361                ..LimitCommandOptions::default()
362            },
363            now(),
364        )
365        .expect_err("bad window");
366        assert_eq!(bad_window.exit_code(), 2);
367        assert!(bad_window.message().contains("5h"));
368        assert!(bad_window.message().contains("7d"));
369    }
370
371    fn now() -> DateTime<Utc> {
372        Utc.with_ymd_and_hms(2026, 5, 17, 0, 0, 0)
373            .single()
374            .expect("valid time")
375    }
376}