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}