1use anyhow::Result;
2use chrono::Utc;
3use serde::Serialize;
4use serde_json::Value;
5use std::io::{IsTerminal, Write};
6use std::path::{Path, PathBuf};
7use std::sync::{Arc, mpsc};
8use std::thread;
9use std::time::Duration;
10
11use crate::auth;
12use crate::diag_output;
13use crate::provider_profile::CODEX_PROVIDER_PROFILE;
14use crate::rate_limits::client::{UsageRequest, fetch_usage};
15use nils_common::env as shared_env;
16use nils_common::fs;
17use nils_common::provider_runtime::persistence::{
18 SyncSecretsError, TimestampPolicy, sync_auth_to_matching_secrets,
19};
20use nils_term::progress::{Progress, ProgressFinish, ProgressOptions};
21
22pub use nils_common::rate_limits_ansi as ansi;
23pub mod cache;
24pub mod client;
25pub mod render;
26pub mod writeback;
27
28#[derive(Clone, Debug)]
29pub struct RateLimitsOptions {
30 pub clear_cache: bool,
31 pub debug: bool,
32 pub cached: bool,
33 pub no_refresh_auth: bool,
34 pub json: bool,
35 pub one_line: bool,
36 pub all: bool,
37 pub async_mode: bool,
38 pub watch: bool,
39 pub jobs: Option<String>,
40 pub secret: Option<String>,
41}
42
43const DIAG_SCHEMA_VERSION: &str = "codex-cli.diag.rate-limits.v1";
44const DIAG_COMMAND: &str = "diag rate-limits";
45const WATCH_INTERVAL_SECONDS: u64 = 60;
46const ANSI_CLEAR_SCREEN_AND_HOME: &str = "\x1b[2J\x1b[H";
47
48#[derive(Debug, Clone, Serialize)]
49struct RateLimitSummary {
50 non_weekly_label: String,
51 non_weekly_remaining: i64,
52 #[serde(skip_serializing_if = "Option::is_none")]
53 non_weekly_reset_epoch: Option<i64>,
54 weekly_remaining: i64,
55 weekly_reset_epoch: i64,
56 #[serde(skip_serializing_if = "Option::is_none")]
57 weekly_reset_local: Option<String>,
58}
59
60#[derive(Debug, Clone, Serialize)]
61struct RateLimitJsonResult {
62 name: String,
63 target_file: String,
64 status: String,
65 ok: bool,
66 source: String,
67 #[serde(skip_serializing_if = "Option::is_none")]
68 summary: Option<RateLimitSummary>,
69 #[serde(skip_serializing_if = "Option::is_none")]
70 raw_usage: Option<Value>,
71 #[serde(skip_serializing_if = "Option::is_none")]
72 error: Option<diag_output::ErrorEnvelope>,
73}
74
75#[derive(Debug, Clone, Serialize)]
76struct RateLimitSingleEnvelope {
77 schema_version: String,
78 command: String,
79 mode: String,
80 ok: bool,
81 result: RateLimitJsonResult,
82}
83
84#[derive(Debug, Clone, Serialize)]
85struct RateLimitCollectionEnvelope {
86 schema_version: String,
87 command: String,
88 mode: String,
89 ok: bool,
90 results: Vec<RateLimitJsonResult>,
91}
92
93pub fn run(args: &RateLimitsOptions) -> Result<i32> {
94 let cached_mode = args.cached;
95 let mut one_line = args.one_line;
96 let mut all_mode = args.all;
97 let output_json = args.json;
98
99 let mut debug_mode = args.debug;
100 if !debug_mode
101 && let Ok(raw) = std::env::var("ZSH_DEBUG")
102 && raw.parse::<i64>().unwrap_or(0) >= 2
103 {
104 debug_mode = true;
105 }
106
107 if args.watch && !args.async_mode {
108 if output_json {
109 diag_output::emit_error(
110 DIAG_SCHEMA_VERSION,
111 DIAG_COMMAND,
112 "invalid-flag-combination",
113 "codex-rate-limits: --watch requires --async",
114 Some(serde_json::json!({
115 "flags": ["--watch", "--async"],
116 })),
117 )?;
118 } else {
119 eprintln!("codex-rate-limits: --watch requires --async");
120 }
121 return Ok(64);
122 }
123
124 if args.async_mode {
125 if !args.cached {
126 maybe_sync_all_mode_auth_silent(debug_mode);
127 }
128 if args.json {
129 return run_async_json_mode(args, debug_mode);
130 }
131 if args.watch {
132 return run_async_watch_mode(args, debug_mode);
133 }
134 return run_async_mode(args, debug_mode);
135 }
136
137 if cached_mode {
138 one_line = true;
139 if output_json {
140 diag_output::emit_error(
141 DIAG_SCHEMA_VERSION,
142 DIAG_COMMAND,
143 "invalid-flag-combination",
144 "codex-rate-limits: --json is not supported with --cached",
145 Some(serde_json::json!({
146 "flags": ["--json", "--cached"],
147 })),
148 )?;
149 return Ok(64);
150 }
151 if args.clear_cache {
152 eprintln!("codex-rate-limits: -c is not compatible with --cached");
153 return Ok(64);
154 }
155 }
156
157 if output_json && one_line {
158 diag_output::emit_error(
159 DIAG_SCHEMA_VERSION,
160 DIAG_COMMAND,
161 "invalid-flag-combination",
162 "codex-rate-limits: --one-line is not compatible with --json",
163 Some(serde_json::json!({
164 "flags": ["--one-line", "--json"],
165 })),
166 )?;
167 return Ok(64);
168 }
169
170 if args.clear_cache
171 && let Err(err) = cache::clear_prompt_segment_cache()
172 {
173 if output_json {
174 diag_output::emit_error(
175 DIAG_SCHEMA_VERSION,
176 DIAG_COMMAND,
177 "cache-clear-failed",
178 err.to_string(),
179 None,
180 )?;
181 } else {
182 eprintln!("{err}");
183 }
184 return Ok(1);
185 }
186
187 if !all_mode
188 && !output_json
189 && !cached_mode
190 && args.secret.is_none()
191 && shared_env::env_truthy("CODEX_RATE_LIMITS_DEFAULT_ALL_ENABLED")
192 {
193 all_mode = true;
194 }
195
196 if all_mode {
197 if !cached_mode {
198 maybe_sync_all_mode_auth_silent(debug_mode);
199 }
200 if args.secret.is_some() {
201 eprintln!(
202 "codex-rate-limits: usage: codex-rate-limits [-c] [-d] [--cached] [--no-refresh-auth] [--json] [--one-line] [--all] [secret.json]"
203 );
204 return Ok(64);
205 }
206 if output_json {
207 return run_all_json_mode(args, cached_mode, debug_mode);
208 }
209 return run_all_mode(args, cached_mode, debug_mode);
210 }
211
212 run_single_mode(args, cached_mode, one_line, output_json)
213}
214
215fn run_async_json_mode(args: &RateLimitsOptions, _debug_mode: bool) -> Result<i32> {
216 if args.one_line {
217 let message = "codex-rate-limits: --async does not support --one-line";
218 diag_output::emit_error(
219 DIAG_SCHEMA_VERSION,
220 DIAG_COMMAND,
221 "invalid-flag-combination",
222 message,
223 Some(serde_json::json!({
224 "flag": "--one-line",
225 "mode": "async",
226 })),
227 )?;
228 return Ok(64);
229 }
230 if let Some(secret) = args.secret.as_deref() {
231 let message = format!(
232 "codex-rate-limits: --async does not accept positional args: {}",
233 secret
234 );
235 diag_output::emit_error(
236 DIAG_SCHEMA_VERSION,
237 DIAG_COMMAND,
238 "invalid-positional-arg",
239 message,
240 Some(serde_json::json!({
241 "secret": secret,
242 "mode": "async",
243 })),
244 )?;
245 return Ok(64);
246 }
247 if args.clear_cache && args.cached {
248 let message = "codex-rate-limits: --async: -c is not compatible with --cached";
249 diag_output::emit_error(
250 DIAG_SCHEMA_VERSION,
251 DIAG_COMMAND,
252 "invalid-flag-combination",
253 message,
254 Some(serde_json::json!({
255 "flags": ["--async", "--cached", "-c"],
256 })),
257 )?;
258 return Ok(64);
259 }
260 if args.clear_cache
261 && let Err(err) = cache::clear_prompt_segment_cache()
262 {
263 diag_output::emit_error(
264 DIAG_SCHEMA_VERSION,
265 DIAG_COMMAND,
266 "cache-clear-failed",
267 err.to_string(),
268 None,
269 )?;
270 return Ok(1);
271 }
272
273 let secret_files = match collect_secret_files() {
274 Ok(value) => value,
275 Err((code, message, details)) => {
276 diag_output::emit_error(
277 DIAG_SCHEMA_VERSION,
278 DIAG_COMMAND,
279 "secret-discovery-failed",
280 message,
281 details,
282 )?;
283 return Ok(code);
284 }
285 };
286
287 let jobs = resolve_async_jobs(args.jobs.as_deref());
288 let cached_mode = args.cached;
289 let no_refresh_auth = args.no_refresh_auth;
290 let mut results_by_secret = collect_async_items(&secret_files, jobs, None, move |path, _| {
291 collect_json_result_for_secret(&path, cached_mode, no_refresh_auth, true)
292 });
293 let mut results = Vec::new();
294 let mut rc = 0;
295 for secret_file in &secret_files {
296 let secret_name = secret_file
297 .file_name()
298 .and_then(|name| name.to_str())
299 .unwrap_or("")
300 .to_string();
301 let result = results_by_secret.remove(&secret_name).unwrap_or_else(|| {
302 json_result_error(
303 secret_file,
304 "network",
305 "request-failed",
306 format!(
307 "codex-rate-limits: async worker did not return a result for {}",
308 secret_file.display()
309 ),
310 None,
311 )
312 });
313 if !args.cached && !result.ok {
314 rc = 1;
315 }
316 results.push(result);
317 }
318 results.sort_by(|a, b| a.name.cmp(&b.name));
319 emit_collection_envelope("async", rc == 0, results)?;
320 Ok(rc)
321}
322
323fn run_all_json_mode(
324 args: &RateLimitsOptions,
325 cached_mode: bool,
326 _debug_mode: bool,
327) -> Result<i32> {
328 let secret_files = match collect_secret_files() {
329 Ok(value) => value,
330 Err((code, message, details)) => {
331 diag_output::emit_error(
332 DIAG_SCHEMA_VERSION,
333 DIAG_COMMAND,
334 "secret-discovery-failed",
335 message,
336 details,
337 )?;
338 return Ok(code);
339 }
340 };
341
342 let mut results = Vec::new();
343 let mut rc = 0;
344 for secret_file in &secret_files {
345 let result =
346 collect_json_result_for_secret(secret_file, cached_mode, args.no_refresh_auth, false);
347 if !cached_mode && !result.ok {
348 rc = 1;
349 }
350 results.push(result);
351 }
352 results.sort_by(|a, b| a.name.cmp(&b.name));
353 emit_collection_envelope("all", rc == 0, results)?;
354 Ok(rc)
355}
356
357fn emit_collection_envelope(mode: &str, ok: bool, results: Vec<RateLimitJsonResult>) -> Result<()> {
358 diag_output::emit_json(&RateLimitCollectionEnvelope {
359 schema_version: DIAG_SCHEMA_VERSION.to_string(),
360 command: DIAG_COMMAND.to_string(),
361 mode: mode.to_string(),
362 ok,
363 results,
364 })
365}
366
367fn collect_secret_files() -> std::result::Result<Vec<PathBuf>, (i32, String, Option<Value>)> {
368 let secret_dir = crate::paths::resolve_secret_dir().unwrap_or_default();
369 if !secret_dir.is_dir() {
370 return Err((
371 1,
372 format!(
373 "codex-rate-limits: CODEX_SECRET_DIR not found: {}",
374 secret_dir.display()
375 ),
376 Some(serde_json::json!({
377 "secret_dir": secret_dir.display().to_string(),
378 })),
379 ));
380 }
381
382 let mut secret_files: Vec<PathBuf> = std::fs::read_dir(&secret_dir)
383 .map_err(|err| {
384 (
385 1,
386 format!("codex-rate-limits: failed to read CODEX_SECRET_DIR: {err}"),
387 Some(serde_json::json!({
388 "secret_dir": secret_dir.display().to_string(),
389 })),
390 )
391 })?
392 .flatten()
393 .map(|entry| entry.path())
394 .filter(|path| path.extension().and_then(|s| s.to_str()) == Some("json"))
395 .collect();
396
397 if secret_files.is_empty() {
398 return Err((
399 1,
400 format!(
401 "codex-rate-limits: no secrets found in {}",
402 secret_dir.display()
403 ),
404 Some(serde_json::json!({
405 "secret_dir": secret_dir.display().to_string(),
406 })),
407 ));
408 }
409
410 secret_files.sort();
411 Ok(secret_files)
412}
413
414fn collect_json_result_for_secret(
415 target_file: &Path,
416 cached_mode: bool,
417 no_refresh_auth: bool,
418 allow_cache_fallback: bool,
419) -> RateLimitJsonResult {
420 if cached_mode {
421 return collect_json_from_cache(target_file, "cache", true);
422 }
423
424 let base_url = std::env::var("CODEX_CHATGPT_BASE_URL")
425 .unwrap_or_else(|_| "https://chatgpt.com/backend-api/".to_string());
426 let connect_timeout = env_timeout("CODEX_RATE_LIMITS_CURL_CONNECT_TIMEOUT_SECONDS", 2);
427 let max_time = env_timeout("CODEX_RATE_LIMITS_CURL_MAX_TIME_SECONDS", 8);
428 let usage_request = UsageRequest {
429 target_file: target_file.to_path_buf(),
430 refresh_on_401: !no_refresh_auth,
431 base_url,
432 connect_timeout_seconds: connect_timeout,
433 max_time_seconds: max_time,
434 };
435
436 match fetch_usage(&usage_request) {
437 Ok(usage) => {
438 if let Err(err) = writeback::write_weekly(target_file, &usage.json) {
439 return json_result_error(
440 target_file,
441 "network",
442 "writeback-failed",
443 err.to_string(),
444 None,
445 );
446 }
447 if is_auth_file(target_file)
448 && let Ok(sync_rc) = auth::sync::run_with_json(false)
449 && sync_rc != 0
450 {
451 return json_result_error(
452 target_file,
453 "network",
454 "sync-failed",
455 "codex-rate-limits: failed to sync auth after usage fetch".to_string(),
456 None,
457 );
458 }
459 match summary_from_usage(&usage.json) {
460 Some(summary) => {
461 let fetched_at_epoch = Utc::now().timestamp();
462 if fetched_at_epoch > 0 {
463 let _ = cache::write_prompt_segment_cache(
464 target_file,
465 fetched_at_epoch,
466 &summary.non_weekly_label,
467 summary.non_weekly_remaining,
468 summary.weekly_remaining,
469 summary.weekly_reset_epoch,
470 summary.non_weekly_reset_epoch,
471 );
472 }
473 RateLimitJsonResult {
474 name: secret_display_name(target_file),
475 target_file: target_file_name(target_file),
476 status: "ok".to_string(),
477 ok: true,
478 source: "network".to_string(),
479 summary: Some(summary),
480 raw_usage: Some(redact_sensitive_json(&usage.json)),
481 error: None,
482 }
483 }
484 None => json_result_error(
485 target_file,
486 "network",
487 "invalid-usage-payload",
488 "codex-rate-limits: invalid usage payload".to_string(),
489 Some(serde_json::json!({
490 "raw_usage": redact_sensitive_json(&usage.json),
491 })),
492 ),
493 }
494 }
495 Err(err) => {
496 if allow_cache_fallback {
497 let fallback = collect_json_from_cache(target_file, "cache-fallback", false);
498 if fallback.ok {
499 return fallback;
500 }
501 }
502 let msg = err.to_string();
503 let code = if msg.contains("missing access_token") {
504 "missing-access-token"
505 } else {
506 "request-failed"
507 };
508 json_result_error(target_file, "network", code, msg, None)
509 }
510 }
511}
512
513fn collect_json_from_cache(
514 target_file: &Path,
515 source: &str,
516 enforce_ttl: bool,
517) -> RateLimitJsonResult {
518 let cache_entry = if enforce_ttl {
519 cache::read_cache_entry_for_cached_mode(target_file)
520 } else {
521 cache::read_cache_entry(target_file)
522 };
523
524 match cache_entry {
525 Ok(entry) => RateLimitJsonResult {
526 name: secret_display_name(target_file),
527 target_file: target_file_name(target_file),
528 status: "ok".to_string(),
529 ok: true,
530 source: source.to_string(),
531 summary: Some(summary_from_cache(&entry)),
532 raw_usage: None,
533 error: None,
534 },
535 Err(err) => json_result_error(
536 target_file,
537 source,
538 "cache-read-failed",
539 err.to_string(),
540 None,
541 ),
542 }
543}
544
545fn json_result_error(
546 target_file: &Path,
547 source: &str,
548 code: &str,
549 message: String,
550 details: Option<Value>,
551) -> RateLimitJsonResult {
552 RateLimitJsonResult {
553 name: secret_display_name(target_file),
554 target_file: target_file_name(target_file),
555 status: "error".to_string(),
556 ok: false,
557 source: source.to_string(),
558 summary: None,
559 raw_usage: None,
560 error: Some(diag_output::ErrorEnvelope {
561 code: code.to_string(),
562 message,
563 details,
564 }),
565 }
566}
567
568fn secret_display_name(target_file: &Path) -> String {
569 cache::secret_name_for_target(target_file).unwrap_or_else(|| {
570 target_file
571 .file_name()
572 .and_then(|name| name.to_str())
573 .unwrap_or_default()
574 .trim_end_matches(".json")
575 .to_string()
576 })
577}
578
579fn target_file_name(target_file: &Path) -> String {
580 target_file
581 .file_name()
582 .and_then(|name| name.to_str())
583 .unwrap_or_default()
584 .to_string()
585}
586
587fn summary_from_usage(usage_json: &Value) -> Option<RateLimitSummary> {
588 let usage_data = render::parse_usage(usage_json)?;
589 let values = render::render_values(&usage_data);
590 let weekly = render::weekly_values(&values);
591 Some(RateLimitSummary {
592 non_weekly_label: weekly.non_weekly_label,
593 non_weekly_remaining: weekly.non_weekly_remaining,
594 non_weekly_reset_epoch: weekly.non_weekly_reset_epoch,
595 weekly_remaining: weekly.weekly_remaining,
596 weekly_reset_epoch: weekly.weekly_reset_epoch,
597 weekly_reset_local: render::format_epoch_local_datetime_with_offset(
598 weekly.weekly_reset_epoch,
599 ),
600 })
601}
602
603fn summary_from_cache(entry: &cache::CacheEntry) -> RateLimitSummary {
604 RateLimitSummary {
605 non_weekly_label: entry.non_weekly_label.clone(),
606 non_weekly_remaining: entry.non_weekly_remaining,
607 non_weekly_reset_epoch: entry.non_weekly_reset_epoch,
608 weekly_remaining: entry.weekly_remaining,
609 weekly_reset_epoch: entry.weekly_reset_epoch,
610 weekly_reset_local: render::format_epoch_local_datetime_with_offset(
611 entry.weekly_reset_epoch,
612 ),
613 }
614}
615
616fn redact_sensitive_json(value: &Value) -> Value {
617 match value {
618 Value::Object(map) => {
619 let mut next = serde_json::Map::new();
620 for (key, val) in map {
621 if is_sensitive_key(key) {
622 continue;
623 }
624 next.insert(key.clone(), redact_sensitive_json(val));
625 }
626 Value::Object(next)
627 }
628 Value::Array(items) => Value::Array(items.iter().map(redact_sensitive_json).collect()),
629 _ => value.clone(),
630 }
631}
632
633fn is_sensitive_key(key: &str) -> bool {
634 matches!(
635 key,
636 "access_token" | "refresh_token" | "id_token" | "authorization" | "Authorization"
637 )
638}
639
640struct AsyncFetchResult {
641 line: Option<String>,
642 rc: i32,
643 err: String,
644}
645
646struct AsyncCollectedItem<T> {
647 secret_name: String,
648 value: T,
649}
650
651fn run_async_mode(args: &RateLimitsOptions, debug_mode: bool) -> Result<i32> {
652 run_async_mode_impl(args, debug_mode, false)
653}
654
655fn run_async_watch_mode(args: &RateLimitsOptions, debug_mode: bool) -> Result<i32> {
656 run_async_mode_impl(args, debug_mode, true)
657}
658
659fn run_async_mode_impl(
660 args: &RateLimitsOptions,
661 debug_mode: bool,
662 watch_mode: bool,
663) -> Result<i32> {
664 if args.json {
665 eprintln!("codex-rate-limits: --async does not support --json");
666 return Ok(64);
667 }
668 if args.one_line {
669 eprintln!("codex-rate-limits: --async does not support --one-line");
670 return Ok(64);
671 }
672 if let Some(secret) = args.secret.as_deref() {
673 eprintln!(
674 "codex-rate-limits: --async does not accept positional args: {}",
675 secret
676 );
677 eprintln!(
678 "codex-rate-limits: hint: async always queries all secrets under CODEX_SECRET_DIR"
679 );
680 return Ok(64);
681 }
682 if args.clear_cache && args.cached {
683 eprintln!("codex-rate-limits: --async: -c is not compatible with --cached");
684 return Ok(64);
685 }
686
687 let jobs = resolve_async_jobs(args.jobs.as_deref());
688
689 if args.clear_cache
690 && let Err(err) = cache::clear_prompt_segment_cache()
691 {
692 eprintln!("{err}");
693 return Ok(1);
694 }
695
696 let secret_files = match collect_secret_files_for_async_text() {
697 Ok(value) => value,
698 Err(err) => {
699 eprintln!("{err}");
700 return Ok(1);
701 }
702 };
703
704 if !watch_mode {
705 if secret_files.is_empty() {
706 let secret_dir = crate::paths::resolve_secret_dir().unwrap_or_default();
707 eprintln!(
708 "codex-rate-limits-async: no secrets found in {}",
709 secret_dir.display()
710 );
711 return Ok(1);
712 }
713
714 let current_name = current_secret_basename(&secret_files);
715 let round = collect_async_round(&secret_files, args.cached, args.no_refresh_auth, jobs);
716 render_all_accounts_table(
717 round.rows,
718 &round.window_labels,
719 current_name.as_deref(),
720 None,
721 );
722 emit_async_debug(debug_mode, &secret_files, &round.stderr_map);
723 return Ok(round.rc);
724 }
725
726 let mut overall_rc = 0;
727 let mut rendered_rounds = 0u64;
728 let max_rounds = watch_max_rounds_for_test();
729 let watch_interval_seconds = watch_interval_seconds();
730 let is_terminal_stdout = std::io::stdout().is_terminal();
731
732 loop {
733 let secret_files = match collect_secret_files_for_async_text() {
734 Ok(value) => value,
735 Err(err) => {
736 overall_rc = 1;
737 if is_terminal_stdout {
738 print!("{ANSI_CLEAR_SCREEN_AND_HOME}");
739 }
740 eprintln!("{err}");
741 let _ = std::io::stdout().flush();
742
743 rendered_rounds += 1;
744 if let Some(limit) = max_rounds
745 && rendered_rounds >= limit
746 {
747 break;
748 }
749
750 thread::sleep(Duration::from_secs(watch_interval_seconds));
751 continue;
752 }
753 };
754 let current_name = current_secret_basename(&secret_files);
755 let round = collect_async_round(&secret_files, args.cached, args.no_refresh_auth, jobs);
756 if round.rc != 0 {
757 overall_rc = 1;
758 }
759
760 if is_terminal_stdout {
761 print!("{ANSI_CLEAR_SCREEN_AND_HOME}");
762 }
763
764 let now_epoch = Utc::now().timestamp();
765 let update_time = format_watch_update_time(now_epoch);
766 render_all_accounts_table(
767 round.rows,
768 &round.window_labels,
769 current_name.as_deref(),
770 Some(update_time.as_str()),
771 );
772 emit_async_debug(debug_mode, &secret_files, &round.stderr_map);
773 let _ = std::io::stdout().flush();
774
775 rendered_rounds += 1;
776 if let Some(limit) = max_rounds
777 && rendered_rounds >= limit
778 {
779 break;
780 }
781
782 thread::sleep(Duration::from_secs(watch_interval_seconds));
783 }
784
785 Ok(overall_rc)
786}
787
788fn collect_secret_files_for_async_text() -> std::result::Result<Vec<PathBuf>, String> {
789 let secret_dir = crate::paths::resolve_secret_dir().unwrap_or_default();
790 if !secret_dir.is_dir() {
791 return Err(format!(
792 "codex-rate-limits-async: CODEX_SECRET_DIR not found: {}",
793 secret_dir.display()
794 ));
795 }
796
797 let mut secret_files: Vec<PathBuf> = std::fs::read_dir(&secret_dir)
798 .map_err(|err| format!("codex-rate-limits-async: failed to read CODEX_SECRET_DIR: {err}"))?
799 .flatten()
800 .map(|entry| entry.path())
801 .filter(|path| path.extension().and_then(|s| s.to_str()) == Some("json"))
802 .collect();
803
804 secret_files.sort();
805 Ok(secret_files)
806}
807
808struct AsyncRound {
809 rc: i32,
810 rows: Vec<Row>,
811 window_labels: std::collections::HashSet<String>,
812 stderr_map: std::collections::HashMap<String, String>,
813}
814
815fn resolve_async_jobs(jobs: Option<&str>) -> usize {
816 jobs.and_then(|raw| raw.parse::<i64>().ok())
817 .filter(|value| *value > 0)
818 .map(|value| value as usize)
819 .unwrap_or(5)
820}
821
822fn collect_async_items<T, F>(
823 secret_files: &[PathBuf],
824 jobs: usize,
825 progress_prefix: Option<&str>,
826 worker: F,
827) -> std::collections::HashMap<String, T>
828where
829 T: Send + 'static,
830 F: Fn(PathBuf, String) -> T + Send + Sync + 'static,
831{
832 let total = secret_files.len();
833 if total == 0 {
834 return std::collections::HashMap::new();
835 }
836
837 let progress = if total > 1 {
838 progress_prefix.map(|prefix| {
839 Progress::new(
840 total as u64,
841 ProgressOptions::default()
842 .with_prefix(prefix)
843 .with_finish(ProgressFinish::Clear),
844 )
845 })
846 } else {
847 None
848 };
849
850 let worker_count = jobs.clamp(1, total);
851 let worker = Arc::new(worker);
852 let (tx, rx) = mpsc::channel();
853 let mut handles = Vec::new();
854 let mut index = 0usize;
855
856 while index < total && handles.len() < worker_count {
857 let path = secret_files[index].clone();
858 index += 1;
859 let tx = tx.clone();
860 let worker = Arc::clone(&worker);
861 handles.push(thread::spawn(move || {
862 let secret_name = path
863 .file_name()
864 .and_then(|name| name.to_str())
865 .unwrap_or("")
866 .to_string();
867 let value = worker(path, secret_name.clone());
868 let _ = tx.send(AsyncCollectedItem { secret_name, value });
869 }));
870 }
871
872 let mut items = std::collections::HashMap::new();
873 while items.len() < total {
874 let item = match rx.recv() {
875 Ok(item) => item,
876 Err(_) => break,
877 };
878 if let Some(progress) = &progress {
879 progress.set_message(item.secret_name.clone());
880 progress.inc(1);
881 }
882 items.insert(item.secret_name.clone(), item.value);
883
884 if index < total {
885 let path = secret_files[index].clone();
886 index += 1;
887 let tx = tx.clone();
888 let worker = Arc::clone(&worker);
889 handles.push(thread::spawn(move || {
890 let secret_name = path
891 .file_name()
892 .and_then(|name| name.to_str())
893 .unwrap_or("")
894 .to_string();
895 let value = worker(path, secret_name.clone());
896 let _ = tx.send(AsyncCollectedItem { secret_name, value });
897 }));
898 }
899 }
900
901 if let Some(progress) = progress {
902 progress.finish_and_clear();
903 }
904
905 drop(tx);
906 for handle in handles {
907 let _ = handle.join();
908 }
909
910 items
911}
912
913fn collect_async_round(
914 secret_files: &[PathBuf],
915 cached_mode: bool,
916 no_refresh_auth: bool,
917 jobs: usize,
918) -> AsyncRound {
919 let mut events = collect_async_items(
920 secret_files,
921 jobs,
922 Some("codex-rate-limits "),
923 move |path, secret_name| {
924 async_fetch_one_line(&path, cached_mode, no_refresh_auth, &secret_name)
925 },
926 );
927
928 let mut rc = 0;
929 let mut rows: Vec<Row> = Vec::new();
930 let mut window_labels = std::collections::HashSet::new();
931 let mut stderr_map: std::collections::HashMap<String, String> =
932 std::collections::HashMap::new();
933
934 for secret_file in secret_files {
935 let secret_name = secret_file
936 .file_name()
937 .and_then(|name| name.to_str())
938 .unwrap_or("")
939 .to_string();
940
941 let mut row = Row::empty(secret_name.trim_end_matches(".json").to_string());
942 let event = events.remove(&secret_name);
943 if let Some(event) = event {
944 if !event.err.is_empty() {
945 stderr_map.insert(secret_name.clone(), event.err.clone());
946 }
947 if !cached_mode && event.rc != 0 {
948 rc = 1;
949 }
950
951 if let Some(line) = &event.line
952 && let Some(parsed) = parse_one_line_output(line)
953 {
954 row.window_label = parsed.window_label.clone();
955 row.non_weekly_remaining = parsed.non_weekly_remaining;
956 row.weekly_remaining = parsed.weekly_remaining;
957 row.weekly_reset_iso = parsed.weekly_reset_iso.clone();
958
959 if cached_mode {
960 if let Ok(cache_entry) = cache::read_cache_entry_for_cached_mode(secret_file) {
961 row.non_weekly_reset_epoch = cache_entry.non_weekly_reset_epoch;
962 row.weekly_reset_epoch = Some(cache_entry.weekly_reset_epoch);
963 }
964 } else {
965 let values = crate::json::read_json(secret_file).ok();
966 if let Some(values) = values {
967 row.non_weekly_reset_epoch = crate::json::i64_at(
968 &values,
969 &["codex_rate_limits", "non_weekly_reset_at_epoch"],
970 );
971 row.weekly_reset_epoch = crate::json::i64_at(
972 &values,
973 &["codex_rate_limits", "weekly_reset_at_epoch"],
974 );
975 }
976 if (row.non_weekly_reset_epoch.is_none() || row.weekly_reset_epoch.is_none())
977 && let Ok(cache_entry) = cache::read_cache_entry(secret_file)
978 {
979 if row.non_weekly_reset_epoch.is_none() {
980 row.non_weekly_reset_epoch = cache_entry.non_weekly_reset_epoch;
981 }
982 if row.weekly_reset_epoch.is_none() {
983 row.weekly_reset_epoch = Some(cache_entry.weekly_reset_epoch);
984 }
985 }
986 }
987
988 window_labels.insert(row.window_label.clone());
989 rows.push(row);
990 continue;
991 }
992 }
993
994 if !cached_mode {
995 rc = 1;
996 }
997 rows.push(row);
998 }
999
1000 AsyncRound {
1001 rc,
1002 rows,
1003 window_labels,
1004 stderr_map,
1005 }
1006}
1007
1008fn render_all_accounts_table(
1009 mut rows: Vec<Row>,
1010 window_labels: &std::collections::HashSet<String>,
1011 current_name: Option<&str>,
1012 update_time: Option<&str>,
1013) {
1014 println!("\n🚦 Codex rate limits for all accounts\n");
1015
1016 let mut non_weekly_header = "Non-weekly".to_string();
1017 let multiple_labels = window_labels.len() != 1;
1018 if !multiple_labels && let Some(label) = window_labels.iter().next() {
1019 non_weekly_header = label.clone();
1020 }
1021
1022 let now_epoch = Utc::now().timestamp();
1023
1024 println!(
1025 "{:<15} {:>8} {:>7} {:>8} {:>7} {:<18}",
1026 "Name", non_weekly_header, "Left", "Weekly", "Left", "Reset"
1027 );
1028 println!("----------------------------------------------------------------------------");
1029
1030 rows.sort_by_key(|row| row.sort_key());
1031
1032 for row in rows {
1033 let display_non_weekly = if multiple_labels && !row.window_label.is_empty() {
1034 if row.non_weekly_remaining >= 0 {
1035 format!("{}:{}%", row.window_label, row.non_weekly_remaining)
1036 } else {
1037 "-".to_string()
1038 }
1039 } else if row.non_weekly_remaining >= 0 {
1040 format!("{}%", row.non_weekly_remaining)
1041 } else {
1042 "-".to_string()
1043 };
1044
1045 let non_weekly_left = row
1046 .non_weekly_reset_epoch
1047 .and_then(|epoch| render::format_until_epoch_compact(epoch, now_epoch))
1048 .unwrap_or_else(|| "-".to_string());
1049 let weekly_left = row
1050 .weekly_reset_epoch
1051 .and_then(|epoch| render::format_until_epoch_compact(epoch, now_epoch))
1052 .unwrap_or_else(|| "-".to_string());
1053 let reset_display = row
1054 .weekly_reset_epoch
1055 .and_then(render::format_epoch_local_datetime_with_offset)
1056 .unwrap_or_else(|| "-".to_string());
1057
1058 let non_weekly_display = ansi::format_percent_cell(&display_non_weekly, 8, None);
1059 let weekly_display = if row.weekly_remaining >= 0 {
1060 ansi::format_percent_cell(&format!("{}%", row.weekly_remaining), 8, None)
1061 } else {
1062 ansi::format_percent_cell("-", 8, None)
1063 };
1064
1065 let is_current = current_name == Some(row.name.as_str());
1066 let name_display = ansi::format_name_cell(&row.name, 15, is_current, None);
1067
1068 println!(
1069 "{} {} {:>7} {} {:>7} {:<18}",
1070 name_display,
1071 non_weekly_display,
1072 non_weekly_left,
1073 weekly_display,
1074 weekly_left,
1075 reset_display
1076 );
1077 }
1078
1079 if let Some(update_time) = update_time {
1080 println!();
1081 println!("Last update: {update_time}");
1082 }
1083}
1084
1085fn emit_async_debug(
1086 debug_mode: bool,
1087 secret_files: &[PathBuf],
1088 stderr_map: &std::collections::HashMap<String, String>,
1089) {
1090 if !debug_mode {
1091 return;
1092 }
1093
1094 let mut printed = false;
1095 for secret_file in secret_files {
1096 let secret_name = secret_file
1097 .file_name()
1098 .and_then(|name| name.to_str())
1099 .unwrap_or("")
1100 .to_string();
1101 if let Some(err) = stderr_map.get(&secret_name) {
1102 if err.is_empty() {
1103 continue;
1104 }
1105 if !printed {
1106 printed = true;
1107 eprintln!();
1108 eprintln!("codex-rate-limits-async: per-account stderr (captured):");
1109 }
1110 eprintln!("---- {} ----", secret_name);
1111 eprintln!("{err}");
1112 }
1113 }
1114}
1115
1116fn watch_max_rounds_for_test() -> Option<u64> {
1117 std::env::var("CODEX_RATE_LIMITS_WATCH_MAX_ROUNDS")
1118 .ok()
1119 .and_then(|raw| raw.parse::<u64>().ok())
1120 .filter(|value| *value > 0)
1121}
1122
1123fn watch_interval_seconds() -> u64 {
1124 std::env::var("CODEX_RATE_LIMITS_WATCH_INTERVAL_SECONDS")
1125 .ok()
1126 .and_then(|raw| raw.parse::<u64>().ok())
1127 .filter(|value| *value > 0)
1128 .unwrap_or(WATCH_INTERVAL_SECONDS)
1129}
1130
1131fn format_watch_update_time(now_epoch: i64) -> String {
1132 render::format_epoch_local(now_epoch, "%Y-%m-%d %H:%M:%S %:z")
1133 .unwrap_or_else(|| now_epoch.to_string())
1134}
1135
1136fn async_fetch_one_line(
1137 target_file: &Path,
1138 cached_mode: bool,
1139 no_refresh_auth: bool,
1140 secret_name: &str,
1141) -> AsyncFetchResult {
1142 if cached_mode {
1143 return fetch_one_line_cached(target_file);
1144 }
1145
1146 let mut attempt = 1;
1147 let max_attempts = 2;
1148 let mut network_err: Option<String> = None;
1149
1150 let mut result = fetch_one_line_network(target_file, no_refresh_auth);
1151 if !result.err.is_empty() {
1152 network_err = Some(result.err.clone());
1153 }
1154
1155 while attempt < max_attempts && result.rc == 3 {
1156 thread::sleep(Duration::from_millis(250));
1157 let next = fetch_one_line_network(target_file, no_refresh_auth);
1158 if !next.err.is_empty() {
1159 network_err = Some(next.err.clone());
1160 }
1161 result = next;
1162 attempt += 1;
1163 if result.rc != 3 {
1164 break;
1165 }
1166 }
1167
1168 let mut errors: Vec<String> = Vec::new();
1169 if let Some(err) = network_err {
1170 errors.push(err);
1171 }
1172
1173 let missing_line = result
1174 .line
1175 .as_ref()
1176 .map(|line| line.trim().is_empty())
1177 .unwrap_or(true);
1178
1179 if result.rc != 0 || missing_line {
1180 let cached = fetch_one_line_cached(target_file);
1181 if !cached.err.is_empty() {
1182 errors.push(cached.err.clone());
1183 }
1184 if cached.rc == 0
1185 && cached
1186 .line
1187 .as_ref()
1188 .map(|line| !line.trim().is_empty())
1189 .unwrap_or(false)
1190 {
1191 if result.rc != 0 {
1192 errors.push(format!(
1193 "codex-rate-limits-async: falling back to cache for {} (rc={})",
1194 secret_name, result.rc
1195 ));
1196 }
1197 result = AsyncFetchResult {
1198 line: cached.line,
1199 rc: 0,
1200 err: String::new(),
1201 };
1202 }
1203 }
1204
1205 let line = result.line.map(normalize_one_line);
1206 let err = errors.join("\n");
1207 AsyncFetchResult {
1208 line,
1209 rc: result.rc,
1210 err,
1211 }
1212}
1213
1214fn fetch_one_line_network(target_file: &Path, no_refresh_auth: bool) -> AsyncFetchResult {
1215 if !target_file.is_file() {
1216 return AsyncFetchResult {
1217 line: None,
1218 rc: 1,
1219 err: format!("codex-rate-limits: {} not found", target_file.display()),
1220 };
1221 }
1222
1223 let base_url = std::env::var("CODEX_CHATGPT_BASE_URL")
1224 .unwrap_or_else(|_| "https://chatgpt.com/backend-api/".to_string());
1225 let connect_timeout = env_timeout("CODEX_RATE_LIMITS_CURL_CONNECT_TIMEOUT_SECONDS", 2);
1226 let max_time = env_timeout("CODEX_RATE_LIMITS_CURL_MAX_TIME_SECONDS", 8);
1227
1228 let usage_request = UsageRequest {
1229 target_file: target_file.to_path_buf(),
1230 refresh_on_401: !no_refresh_auth,
1231 base_url,
1232 connect_timeout_seconds: connect_timeout,
1233 max_time_seconds: max_time,
1234 };
1235
1236 let usage = match fetch_usage(&usage_request) {
1237 Ok(value) => value,
1238 Err(err) => {
1239 let msg = err.to_string();
1240 if msg.contains("missing access_token") {
1241 return AsyncFetchResult {
1242 line: None,
1243 rc: 2,
1244 err: format!(
1245 "codex-rate-limits: missing access_token in {}",
1246 target_file.display()
1247 ),
1248 };
1249 }
1250 return AsyncFetchResult {
1251 line: None,
1252 rc: 3,
1253 err: msg,
1254 };
1255 }
1256 };
1257
1258 if let Err(err) = writeback::write_weekly(target_file, &usage.json) {
1259 return AsyncFetchResult {
1260 line: None,
1261 rc: 4,
1262 err: err.to_string(),
1263 };
1264 }
1265
1266 if is_auth_file(target_file) {
1267 match sync_auth_silent() {
1268 Ok((sync_rc, sync_err)) => {
1269 if sync_rc != 0 {
1270 return AsyncFetchResult {
1271 line: None,
1272 rc: 5,
1273 err: sync_err.unwrap_or_default(),
1274 };
1275 }
1276 }
1277 Err(_) => {
1278 return AsyncFetchResult {
1279 line: None,
1280 rc: 1,
1281 err: String::new(),
1282 };
1283 }
1284 }
1285 }
1286
1287 let usage_data = match render::parse_usage(&usage.json) {
1288 Some(value) => value,
1289 None => {
1290 return AsyncFetchResult {
1291 line: None,
1292 rc: 3,
1293 err: "codex-rate-limits: invalid usage payload".to_string(),
1294 };
1295 }
1296 };
1297
1298 let values = render::render_values(&usage_data);
1299 let weekly = render::weekly_values(&values);
1300
1301 let fetched_at_epoch = Utc::now().timestamp();
1302 if fetched_at_epoch > 0 {
1303 let _ = cache::write_prompt_segment_cache(
1304 target_file,
1305 fetched_at_epoch,
1306 &weekly.non_weekly_label,
1307 weekly.non_weekly_remaining,
1308 weekly.weekly_remaining,
1309 weekly.weekly_reset_epoch,
1310 weekly.non_weekly_reset_epoch,
1311 );
1312 }
1313
1314 AsyncFetchResult {
1315 line: Some(format_one_line_output(
1316 target_file,
1317 &weekly.non_weekly_label,
1318 weekly.non_weekly_remaining,
1319 weekly.weekly_remaining,
1320 weekly.weekly_reset_epoch,
1321 )),
1322 rc: 0,
1323 err: String::new(),
1324 }
1325}
1326
1327fn fetch_one_line_cached(target_file: &Path) -> AsyncFetchResult {
1328 match cache::read_cache_entry_for_cached_mode(target_file) {
1329 Ok(entry) => AsyncFetchResult {
1330 line: Some(format_one_line_output(
1331 target_file,
1332 &entry.non_weekly_label,
1333 entry.non_weekly_remaining,
1334 entry.weekly_remaining,
1335 entry.weekly_reset_epoch,
1336 )),
1337 rc: 0,
1338 err: String::new(),
1339 },
1340 Err(err) => AsyncFetchResult {
1341 line: None,
1342 rc: 1,
1343 err: err.to_string(),
1344 },
1345 }
1346}
1347
1348fn format_one_line_output(
1349 target_file: &Path,
1350 non_weekly_label: &str,
1351 non_weekly_remaining: i64,
1352 weekly_remaining: i64,
1353 weekly_reset_epoch: i64,
1354) -> String {
1355 let prefix = cache::secret_name_for_target(target_file)
1356 .map(|name| format!("{name} "))
1357 .unwrap_or_default();
1358 let weekly_reset_iso =
1359 render::format_epoch_local_datetime(weekly_reset_epoch).unwrap_or_else(|| "?".to_string());
1360
1361 format!(
1362 "{}{}:{}% W:{}% {}",
1363 prefix, non_weekly_label, non_weekly_remaining, weekly_remaining, weekly_reset_iso
1364 )
1365}
1366
1367fn normalize_one_line(line: String) -> String {
1368 line.replace(['\n', '\r', '\t'], " ")
1369}
1370
1371fn sync_auth_silent() -> Result<(i32, Option<String>)> {
1372 let auth_file = match crate::paths::resolve_auth_file() {
1373 Some(path) => path,
1374 None => return Ok((0, None)),
1375 };
1376
1377 let sync_result = match sync_auth_to_matching_secrets(
1378 &CODEX_PROVIDER_PROFILE,
1379 &auth_file,
1380 fs::SECRET_FILE_MODE,
1381 TimestampPolicy::Strict,
1382 ) {
1383 Ok(result) => result,
1384 Err(SyncSecretsError::HashAuthFile { path, .. })
1385 | Err(SyncSecretsError::HashSecretFile { path, .. }) => {
1386 return Ok((1, Some(format!("codex: failed to hash {}", path.display()))));
1387 }
1388 Err(err) => return Err(err.into()),
1389 };
1390 if !sync_result.auth_file_present || !sync_result.auth_identity_present {
1391 return Ok((0, None));
1392 }
1393
1394 Ok((0, None))
1395}
1396
1397fn maybe_sync_all_mode_auth_silent(debug_mode: bool) {
1398 match sync_auth_silent() {
1399 Ok((0, _)) => {}
1400 Ok((_, sync_err)) => {
1401 if debug_mode
1402 && let Some(message) = sync_err
1403 && !message.trim().is_empty()
1404 {
1405 eprintln!("{message}");
1406 }
1407 }
1408 Err(err) => {
1409 if debug_mode {
1410 eprintln!("codex-rate-limits: failed to sync auth and secrets: {err}");
1411 }
1412 }
1413 }
1414}
1415
1416fn run_all_mode(args: &RateLimitsOptions, cached_mode: bool, debug_mode: bool) -> Result<i32> {
1417 let secret_dir = crate::paths::resolve_secret_dir().unwrap_or_default();
1418 if !secret_dir.is_dir() {
1419 eprintln!(
1420 "codex-rate-limits: CODEX_SECRET_DIR not found: {}",
1421 secret_dir.display()
1422 );
1423 return Ok(1);
1424 }
1425
1426 let mut secret_files: Vec<PathBuf> = std::fs::read_dir(&secret_dir)?
1427 .flatten()
1428 .map(|entry| entry.path())
1429 .filter(|path| path.extension().and_then(|s| s.to_str()) == Some("json"))
1430 .collect();
1431
1432 if secret_files.is_empty() {
1433 eprintln!(
1434 "codex-rate-limits: no secrets found in {}",
1435 secret_dir.display()
1436 );
1437 return Ok(1);
1438 }
1439
1440 secret_files.sort();
1441
1442 let current_name = current_secret_basename(&secret_files);
1443
1444 let total = secret_files.len();
1445 let progress = if total > 1 {
1446 Some(Progress::new(
1447 total as u64,
1448 ProgressOptions::default()
1449 .with_prefix("codex-rate-limits ")
1450 .with_finish(ProgressFinish::Clear),
1451 ))
1452 } else {
1453 None
1454 };
1455
1456 let mut rc = 0;
1457 let mut rows: Vec<Row> = Vec::new();
1458 let mut window_labels = std::collections::HashSet::new();
1459
1460 for secret_file in secret_files {
1461 let secret_name = secret_file
1462 .file_name()
1463 .and_then(|name| name.to_str())
1464 .unwrap_or("")
1465 .to_string();
1466 if let Some(progress) = &progress {
1467 progress.set_message(secret_name.clone());
1468 }
1469
1470 let mut row = Row::empty(secret_name.trim_end_matches(".json").to_string());
1471 let output =
1472 match single_one_line(&secret_file, cached_mode, args.no_refresh_auth, debug_mode) {
1473 Ok(Some(line)) => line,
1474 Ok(None) => String::new(),
1475 Err(_) => String::new(),
1476 };
1477
1478 if output.is_empty() {
1479 if !cached_mode {
1480 rc = 1;
1481 }
1482 rows.push(row);
1483 continue;
1484 }
1485
1486 if let Some(parsed) = parse_one_line_output(&output) {
1487 row.window_label = parsed.window_label.clone();
1488 row.non_weekly_remaining = parsed.non_weekly_remaining;
1489 row.weekly_remaining = parsed.weekly_remaining;
1490 row.weekly_reset_iso = parsed.weekly_reset_iso.clone();
1491
1492 if cached_mode {
1493 if let Ok(cache_entry) = cache::read_cache_entry_for_cached_mode(&secret_file) {
1494 row.non_weekly_reset_epoch = cache_entry.non_weekly_reset_epoch;
1495 row.weekly_reset_epoch = Some(cache_entry.weekly_reset_epoch);
1496 }
1497 } else {
1498 let values = crate::json::read_json(&secret_file).ok();
1499 if let Some(values) = values {
1500 row.non_weekly_reset_epoch = crate::json::i64_at(
1501 &values,
1502 &["codex_rate_limits", "non_weekly_reset_at_epoch"],
1503 );
1504 row.weekly_reset_epoch = crate::json::i64_at(
1505 &values,
1506 &["codex_rate_limits", "weekly_reset_at_epoch"],
1507 );
1508 }
1509 }
1510
1511 window_labels.insert(row.window_label.clone());
1512 rows.push(row);
1513 } else {
1514 if !cached_mode {
1515 rc = 1;
1516 }
1517 rows.push(row);
1518 }
1519
1520 if let Some(progress) = &progress {
1521 progress.inc(1);
1522 }
1523 }
1524
1525 if let Some(progress) = progress {
1526 progress.finish_and_clear();
1527 }
1528
1529 println!("\n🚦 Codex rate limits for all accounts\n");
1530
1531 let mut non_weekly_header = "Non-weekly".to_string();
1532 let multiple_labels = window_labels.len() != 1;
1533 if !multiple_labels && let Some(label) = window_labels.iter().next() {
1534 non_weekly_header = label.clone();
1535 }
1536
1537 let now_epoch = Utc::now().timestamp();
1538
1539 println!(
1540 "{:<15} {:>8} {:>7} {:>8} {:>7} {:<18}",
1541 "Name", non_weekly_header, "Left", "Weekly", "Left", "Reset"
1542 );
1543 println!("----------------------------------------------------------------------------");
1544
1545 rows.sort_by_key(|row| row.sort_key());
1546
1547 for row in rows {
1548 let display_non_weekly = if multiple_labels && !row.window_label.is_empty() {
1549 if row.non_weekly_remaining >= 0 {
1550 format!("{}:{}%", row.window_label, row.non_weekly_remaining)
1551 } else {
1552 "-".to_string()
1553 }
1554 } else if row.non_weekly_remaining >= 0 {
1555 format!("{}%", row.non_weekly_remaining)
1556 } else {
1557 "-".to_string()
1558 };
1559
1560 let non_weekly_left = row
1561 .non_weekly_reset_epoch
1562 .and_then(|epoch| render::format_until_epoch_compact(epoch, now_epoch))
1563 .unwrap_or_else(|| "-".to_string());
1564 let weekly_left = row
1565 .weekly_reset_epoch
1566 .and_then(|epoch| render::format_until_epoch_compact(epoch, now_epoch))
1567 .unwrap_or_else(|| "-".to_string());
1568 let reset_display = row
1569 .weekly_reset_epoch
1570 .and_then(render::format_epoch_local_datetime_with_offset)
1571 .unwrap_or_else(|| "-".to_string());
1572
1573 let non_weekly_display = ansi::format_percent_cell(&display_non_weekly, 8, None);
1574 let weekly_display = if row.weekly_remaining >= 0 {
1575 ansi::format_percent_cell(&format!("{}%", row.weekly_remaining), 8, None)
1576 } else {
1577 ansi::format_percent_cell("-", 8, None)
1578 };
1579
1580 let is_current = current_name.as_deref() == Some(row.name.as_str());
1581 let name_display = ansi::format_name_cell(&row.name, 15, is_current, None);
1582
1583 println!(
1584 "{} {} {:>7} {} {:>7} {:<18}",
1585 name_display,
1586 non_weekly_display,
1587 non_weekly_left,
1588 weekly_display,
1589 weekly_left,
1590 reset_display
1591 );
1592 }
1593
1594 Ok(rc)
1595}
1596
1597fn current_secret_basename(secret_files: &[PathBuf]) -> Option<String> {
1598 let auth_file = crate::paths::resolve_auth_file()?;
1599 if !auth_file.is_file() {
1600 return None;
1601 }
1602
1603 let auth_key = auth::identity_key_from_auth_file(&auth_file).ok().flatten();
1604 let auth_hash = fs::sha256_file(&auth_file).ok();
1605
1606 if let Some(auth_hash) = auth_hash.as_deref() {
1607 for secret_file in secret_files {
1608 if let Ok(secret_hash) = fs::sha256_file(secret_file)
1609 && secret_hash == auth_hash
1610 && let Some(name) = secret_file.file_name().and_then(|name| name.to_str())
1611 {
1612 return Some(name.trim_end_matches(".json").to_string());
1613 }
1614 }
1615 }
1616
1617 if let Some(auth_key) = auth_key.as_deref() {
1618 for secret_file in secret_files {
1619 if let Ok(Some(candidate_key)) = auth::identity_key_from_auth_file(secret_file)
1620 && candidate_key == auth_key
1621 && let Some(name) = secret_file.file_name().and_then(|name| name.to_str())
1622 {
1623 return Some(name.trim_end_matches(".json").to_string());
1624 }
1625 }
1626 }
1627
1628 None
1629}
1630
1631fn run_single_mode(
1632 args: &RateLimitsOptions,
1633 cached_mode: bool,
1634 one_line: bool,
1635 output_json: bool,
1636) -> Result<i32> {
1637 let target_file = match resolve_target(args.secret.as_deref()) {
1638 Ok(path) => path,
1639 Err(code) => return Ok(code),
1640 };
1641
1642 if !target_file.is_file() {
1643 if output_json {
1644 diag_output::emit_error(
1645 DIAG_SCHEMA_VERSION,
1646 DIAG_COMMAND,
1647 "target-not-found",
1648 format!("codex-rate-limits: {} not found", target_file.display()),
1649 Some(serde_json::json!({
1650 "target_file": target_file.display().to_string(),
1651 })),
1652 )?;
1653 } else {
1654 eprintln!("codex-rate-limits: {} not found", target_file.display());
1655 }
1656 return Ok(1);
1657 }
1658
1659 if cached_mode {
1660 match cache::read_cache_entry_for_cached_mode(&target_file) {
1661 Ok(entry) => {
1662 let weekly_reset_iso =
1663 render::format_epoch_local_datetime(entry.weekly_reset_epoch)
1664 .unwrap_or_else(|| "?".to_string());
1665 let prefix = cache::secret_name_for_target(&target_file)
1666 .map(|name| format!("{name} "))
1667 .unwrap_or_default();
1668 println!(
1669 "{}{}:{}% W:{}% {}",
1670 prefix,
1671 entry.non_weekly_label,
1672 entry.non_weekly_remaining,
1673 entry.weekly_remaining,
1674 weekly_reset_iso
1675 );
1676 return Ok(0);
1677 }
1678 Err(err) => {
1679 eprintln!("{err}");
1680 return Ok(1);
1681 }
1682 }
1683 }
1684
1685 let base_url = std::env::var("CODEX_CHATGPT_BASE_URL")
1686 .unwrap_or_else(|_| "https://chatgpt.com/backend-api/".to_string());
1687 let connect_timeout = env_timeout("CODEX_RATE_LIMITS_CURL_CONNECT_TIMEOUT_SECONDS", 2);
1688 let max_time = env_timeout("CODEX_RATE_LIMITS_CURL_MAX_TIME_SECONDS", 8);
1689
1690 let usage_request = UsageRequest {
1691 target_file: target_file.clone(),
1692 refresh_on_401: !args.no_refresh_auth,
1693 base_url,
1694 connect_timeout_seconds: connect_timeout,
1695 max_time_seconds: max_time,
1696 };
1697
1698 let usage = match fetch_usage(&usage_request) {
1699 Ok(value) => value,
1700 Err(err) => {
1701 let msg = err.to_string();
1702 if msg.contains("missing access_token") {
1703 if output_json {
1704 diag_output::emit_error(
1705 DIAG_SCHEMA_VERSION,
1706 DIAG_COMMAND,
1707 "missing-access-token",
1708 format!(
1709 "codex-rate-limits: missing access_token in {}",
1710 target_file.display()
1711 ),
1712 Some(serde_json::json!({
1713 "target_file": target_file.display().to_string(),
1714 })),
1715 )?;
1716 } else {
1717 eprintln!(
1718 "codex-rate-limits: missing access_token in {}",
1719 target_file.display()
1720 );
1721 }
1722 return Ok(2);
1723 }
1724 if output_json {
1725 diag_output::emit_error(
1726 DIAG_SCHEMA_VERSION,
1727 DIAG_COMMAND,
1728 "request-failed",
1729 msg,
1730 Some(serde_json::json!({
1731 "target_file": target_file.display().to_string(),
1732 })),
1733 )?;
1734 } else {
1735 eprintln!("{msg}");
1736 }
1737 return Ok(3);
1738 }
1739 };
1740
1741 if let Err(err) = writeback::write_weekly(&target_file, &usage.json) {
1742 if output_json {
1743 diag_output::emit_error(
1744 DIAG_SCHEMA_VERSION,
1745 DIAG_COMMAND,
1746 "writeback-failed",
1747 err.to_string(),
1748 Some(serde_json::json!({
1749 "target_file": target_file.display().to_string(),
1750 })),
1751 )?;
1752 } else {
1753 eprintln!("{err}");
1754 }
1755 return Ok(4);
1756 }
1757
1758 if is_auth_file(&target_file) {
1759 let sync_rc = auth::sync::run_with_json(false)?;
1760 if sync_rc != 0 {
1761 if output_json {
1762 diag_output::emit_error(
1763 DIAG_SCHEMA_VERSION,
1764 DIAG_COMMAND,
1765 "sync-failed",
1766 "codex-rate-limits: failed to sync auth file",
1767 Some(serde_json::json!({
1768 "target_file": target_file.display().to_string(),
1769 })),
1770 )?;
1771 }
1772 return Ok(5);
1773 }
1774 }
1775
1776 let usage_data = match render::parse_usage(&usage.json) {
1777 Some(value) => value,
1778 None => {
1779 if output_json {
1780 diag_output::emit_error(
1781 DIAG_SCHEMA_VERSION,
1782 DIAG_COMMAND,
1783 "invalid-usage-payload",
1784 "codex-rate-limits: invalid usage payload",
1785 Some(serde_json::json!({
1786 "target_file": target_file.display().to_string(),
1787 "raw_usage": redact_sensitive_json(&usage.json),
1788 })),
1789 )?;
1790 } else {
1791 eprintln!("codex-rate-limits: invalid usage payload");
1792 }
1793 return Ok(3);
1794 }
1795 };
1796
1797 let values = render::render_values(&usage_data);
1798 let weekly = render::weekly_values(&values);
1799
1800 let fetched_at_epoch = Utc::now().timestamp();
1801 if fetched_at_epoch > 0 {
1802 let _ = cache::write_prompt_segment_cache(
1803 &target_file,
1804 fetched_at_epoch,
1805 &weekly.non_weekly_label,
1806 weekly.non_weekly_remaining,
1807 weekly.weekly_remaining,
1808 weekly.weekly_reset_epoch,
1809 weekly.non_weekly_reset_epoch,
1810 );
1811 }
1812
1813 if output_json {
1814 let result = RateLimitJsonResult {
1815 name: secret_display_name(&target_file),
1816 target_file: target_file_name(&target_file),
1817 status: "ok".to_string(),
1818 ok: true,
1819 source: "network".to_string(),
1820 summary: Some(RateLimitSummary {
1821 non_weekly_label: weekly.non_weekly_label,
1822 non_weekly_remaining: weekly.non_weekly_remaining,
1823 non_weekly_reset_epoch: weekly.non_weekly_reset_epoch,
1824 weekly_remaining: weekly.weekly_remaining,
1825 weekly_reset_epoch: weekly.weekly_reset_epoch,
1826 weekly_reset_local: render::format_epoch_local_datetime_with_offset(
1827 weekly.weekly_reset_epoch,
1828 ),
1829 }),
1830 raw_usage: Some(redact_sensitive_json(&usage.json)),
1831 error: None,
1832 };
1833 diag_output::emit_json(&RateLimitSingleEnvelope {
1834 schema_version: DIAG_SCHEMA_VERSION.to_string(),
1835 command: DIAG_COMMAND.to_string(),
1836 mode: "single".to_string(),
1837 ok: true,
1838 result,
1839 })?;
1840 return Ok(0);
1841 }
1842
1843 if one_line {
1844 let prefix = cache::secret_name_for_target(&target_file)
1845 .map(|name| format!("{name} "))
1846 .unwrap_or_default();
1847 let weekly_reset_iso = render::format_epoch_local_datetime(weekly.weekly_reset_epoch)
1848 .unwrap_or_else(|| "?".to_string());
1849
1850 println!(
1851 "{}{}:{}% W:{}% {}",
1852 prefix,
1853 weekly.non_weekly_label,
1854 weekly.non_weekly_remaining,
1855 weekly.weekly_remaining,
1856 weekly_reset_iso
1857 );
1858 return Ok(0);
1859 }
1860
1861 println!("Rate limits remaining");
1862 let primary_reset = render::format_epoch_local_datetime(values.primary_reset_epoch)
1863 .unwrap_or_else(|| "?".to_string());
1864 let secondary_reset = render::format_epoch_local_datetime(values.secondary_reset_epoch)
1865 .unwrap_or_else(|| "?".to_string());
1866
1867 println!(
1868 "{} {}% • {}",
1869 values.primary_label, values.primary_remaining, primary_reset
1870 );
1871 println!(
1872 "{} {}% • {}",
1873 values.secondary_label, values.secondary_remaining, secondary_reset
1874 );
1875
1876 Ok(0)
1877}
1878
1879fn single_one_line(
1880 target_file: &Path,
1881 cached_mode: bool,
1882 no_refresh_auth: bool,
1883 debug_mode: bool,
1884) -> Result<Option<String>> {
1885 if !target_file.is_file() {
1886 if debug_mode {
1887 eprintln!("codex-rate-limits: {} not found", target_file.display());
1888 }
1889 return Ok(None);
1890 }
1891
1892 if cached_mode {
1893 return match cache::read_cache_entry_for_cached_mode(target_file) {
1894 Ok(entry) => {
1895 let weekly_reset_iso =
1896 render::format_epoch_local_datetime(entry.weekly_reset_epoch)
1897 .unwrap_or_else(|| "?".to_string());
1898 let prefix = cache::secret_name_for_target(target_file)
1899 .map(|name| format!("{name} "))
1900 .unwrap_or_default();
1901 Ok(Some(format!(
1902 "{}{}:{}% W:{}% {}",
1903 prefix,
1904 entry.non_weekly_label,
1905 entry.non_weekly_remaining,
1906 entry.weekly_remaining,
1907 weekly_reset_iso
1908 )))
1909 }
1910 Err(err) => {
1911 if debug_mode {
1912 eprintln!("{err}");
1913 }
1914 Ok(None)
1915 }
1916 };
1917 }
1918
1919 let base_url = std::env::var("CODEX_CHATGPT_BASE_URL")
1920 .unwrap_or_else(|_| "https://chatgpt.com/backend-api/".to_string());
1921 let connect_timeout = env_timeout("CODEX_RATE_LIMITS_CURL_CONNECT_TIMEOUT_SECONDS", 2);
1922 let max_time = env_timeout("CODEX_RATE_LIMITS_CURL_MAX_TIME_SECONDS", 8);
1923
1924 let usage_request = UsageRequest {
1925 target_file: target_file.to_path_buf(),
1926 refresh_on_401: !no_refresh_auth,
1927 base_url,
1928 connect_timeout_seconds: connect_timeout,
1929 max_time_seconds: max_time,
1930 };
1931
1932 let usage = match fetch_usage(&usage_request) {
1933 Ok(value) => value,
1934 Err(err) => {
1935 if debug_mode {
1936 eprintln!("{err}");
1937 }
1938 return Ok(None);
1939 }
1940 };
1941
1942 let _ = writeback::write_weekly(target_file, &usage.json);
1943 if is_auth_file(target_file) {
1944 let _ = auth::sync::run();
1945 }
1946
1947 let usage_data = match render::parse_usage(&usage.json) {
1948 Some(value) => value,
1949 None => return Ok(None),
1950 };
1951 let values = render::render_values(&usage_data);
1952 let weekly = render::weekly_values(&values);
1953 let fetched_at_epoch = Utc::now().timestamp();
1954 if fetched_at_epoch > 0 {
1955 let _ = cache::write_prompt_segment_cache(
1956 target_file,
1957 fetched_at_epoch,
1958 &weekly.non_weekly_label,
1959 weekly.non_weekly_remaining,
1960 weekly.weekly_remaining,
1961 weekly.weekly_reset_epoch,
1962 weekly.non_weekly_reset_epoch,
1963 );
1964 }
1965 let prefix = cache::secret_name_for_target(target_file)
1966 .map(|name| format!("{name} "))
1967 .unwrap_or_default();
1968 let weekly_reset_iso = render::format_epoch_local_datetime(weekly.weekly_reset_epoch)
1969 .unwrap_or_else(|| "?".to_string());
1970
1971 Ok(Some(format!(
1972 "{}{}:{}% W:{}% {}",
1973 prefix,
1974 weekly.non_weekly_label,
1975 weekly.non_weekly_remaining,
1976 weekly.weekly_remaining,
1977 weekly_reset_iso
1978 )))
1979}
1980
1981fn resolve_target(secret: Option<&str>) -> std::result::Result<PathBuf, i32> {
1982 if let Some(secret_name) = secret {
1983 if secret_name.is_empty() || secret_name.contains('/') || secret_name.contains("..") {
1984 eprintln!("codex-rate-limits: invalid secret file name: {secret_name}");
1985 return Err(64);
1986 }
1987 let secret_dir = crate::paths::resolve_secret_dir().unwrap_or_default();
1988 return Ok(secret_dir.join(secret_name));
1989 }
1990
1991 if let Some(auth_file) = crate::paths::resolve_auth_file() {
1992 return Ok(auth_file);
1993 }
1994
1995 Err(1)
1996}
1997
1998fn is_auth_file(target_file: &Path) -> bool {
1999 if let Some(auth_file) = crate::paths::resolve_auth_file() {
2000 return auth_file == target_file;
2001 }
2002 false
2003}
2004
2005fn env_timeout(key: &str, default: u64) -> u64 {
2006 std::env::var(key)
2007 .ok()
2008 .and_then(|raw| raw.parse::<u64>().ok())
2009 .unwrap_or(default)
2010}
2011
2012struct Row {
2013 name: String,
2014 window_label: String,
2015 non_weekly_remaining: i64,
2016 non_weekly_reset_epoch: Option<i64>,
2017 weekly_remaining: i64,
2018 weekly_reset_epoch: Option<i64>,
2019 weekly_reset_iso: String,
2020}
2021
2022impl Row {
2023 fn empty(name: String) -> Self {
2024 Self {
2025 name,
2026 window_label: String::new(),
2027 non_weekly_remaining: -1,
2028 non_weekly_reset_epoch: None,
2029 weekly_remaining: -1,
2030 weekly_reset_epoch: None,
2031 weekly_reset_iso: String::new(),
2032 }
2033 }
2034
2035 fn sort_key(&self) -> (i32, i64, String) {
2036 if let Some(epoch) = self.weekly_reset_epoch {
2037 (0, epoch, self.name.clone())
2038 } else {
2039 (1, i64::MAX, self.name.clone())
2040 }
2041 }
2042}
2043
2044struct ParsedOneLine {
2045 window_label: String,
2046 non_weekly_remaining: i64,
2047 weekly_remaining: i64,
2048 weekly_reset_iso: String,
2049}
2050
2051fn parse_one_line_output(line: &str) -> Option<ParsedOneLine> {
2052 let parts: Vec<&str> = line.split_whitespace().collect();
2053 if parts.len() < 3 {
2054 return None;
2055 }
2056
2057 fn parse_fields(
2058 window_field: &str,
2059 weekly_field: &str,
2060 reset_iso: String,
2061 ) -> Option<ParsedOneLine> {
2062 let window_label = window_field
2063 .split(':')
2064 .next()?
2065 .trim_matches('"')
2066 .to_string();
2067 let non_weekly_remaining = window_field.split(':').nth(1)?;
2068 let non_weekly_remaining = non_weekly_remaining
2069 .trim_end_matches('%')
2070 .parse::<i64>()
2071 .ok()?;
2072
2073 let weekly_remaining = weekly_field.trim_start_matches("W:").trim_end_matches('%');
2074 let weekly_remaining = weekly_remaining.parse::<i64>().ok()?;
2075
2076 Some(ParsedOneLine {
2077 window_label,
2078 non_weekly_remaining,
2079 weekly_remaining,
2080 weekly_reset_iso: reset_iso,
2081 })
2082 }
2083
2084 let len = parts.len();
2085 let window_field = parts[len - 3];
2086 let weekly_field = parts[len - 2];
2087 let reset_iso = parts[len - 1].to_string();
2088
2089 if let Some(parsed) = parse_fields(window_field, weekly_field, reset_iso) {
2090 return Some(parsed);
2091 }
2092
2093 if len < 4 {
2094 return None;
2095 }
2096
2097 parse_fields(
2098 parts[len - 4],
2099 parts[len - 3],
2100 format!("{} {}", parts[len - 2], parts[len - 1]),
2101 )
2102}
2103
2104#[cfg(test)]
2105mod tests {
2106 use super::{
2107 async_fetch_one_line, cache, collect_json_from_cache, collect_secret_files,
2108 collect_secret_files_for_async_text, current_secret_basename, env_timeout,
2109 fetch_one_line_cached, is_auth_file, normalize_one_line, parse_one_line_output,
2110 redact_sensitive_json, resolve_target, secret_display_name, single_one_line,
2111 sync_auth_silent, target_file_name,
2112 };
2113 use chrono::Utc;
2114 use nils_test_support::{EnvGuard, GlobalStateLock};
2115 use serde_json::json;
2116 use std::fs;
2117 use std::path::Path;
2118
2119 const HEADER: &str = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0";
2120 const PAYLOAD_ALPHA: &str = "eyJzdWIiOiJ1c2VyXzEyMyIsImVtYWlsIjoiYWxwaGFAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF91c2VyX2lkIjoidXNlcl8xMjMiLCJlbWFpbCI6ImFscGhhQGV4YW1wbGUuY29tIn19";
2121 const PAYLOAD_BETA: &str = "eyJzdWIiOiJ1c2VyXzQ1NiIsImVtYWlsIjoiYmV0YUBleGFtcGxlLmNvbSIsImh0dHBzOi8vYXBpLm9wZW5haS5jb20vYXV0aCI6eyJjaGF0Z3B0X3VzZXJfaWQiOiJ1c2VyXzQ1NiIsImVtYWlsIjoiYmV0YUBleGFtcGxlLmNvbSJ9fQ";
2122
2123 fn token(payload: &str) -> String {
2124 format!("{HEADER}.{payload}.sig")
2125 }
2126
2127 fn auth_json(
2128 payload: &str,
2129 account_id: &str,
2130 refresh_token: &str,
2131 last_refresh: &str,
2132 ) -> String {
2133 format!(
2134 r#"{{"tokens":{{"access_token":"{}","id_token":"{}","refresh_token":"{}","account_id":"{}"}},"last_refresh":"{}"}}"#,
2135 token(payload),
2136 token(payload),
2137 refresh_token,
2138 account_id,
2139 last_refresh
2140 )
2141 }
2142
2143 fn fresh_fetched_at() -> i64 {
2144 Utc::now().timestamp()
2145 }
2146
2147 #[test]
2148 fn redact_sensitive_json_removes_tokens_recursively() {
2149 let input = json!({
2150 "tokens": {
2151 "access_token": "a",
2152 "refresh_token": "b",
2153 "nested": {
2154 "id_token": "c",
2155 "Authorization": "Bearer x",
2156 "ok": 1
2157 }
2158 },
2159 "items": [
2160 {"authorization": "Bearer y", "value": 2}
2161 ],
2162 "safe": true
2163 });
2164
2165 let redacted = redact_sensitive_json(&input);
2166 assert_eq!(redacted["tokens"]["nested"]["ok"], 1);
2167 assert_eq!(redacted["safe"], true);
2168 assert!(
2169 redacted["tokens"].get("access_token").is_none(),
2170 "access_token should be removed"
2171 );
2172 assert!(
2173 redacted["tokens"]["nested"].get("id_token").is_none(),
2174 "id_token should be removed"
2175 );
2176 assert!(
2177 redacted["tokens"]["nested"].get("Authorization").is_none(),
2178 "Authorization should be removed"
2179 );
2180 assert!(
2181 redacted["items"][0].get("authorization").is_none(),
2182 "authorization should be removed"
2183 );
2184 }
2185
2186 #[test]
2187 fn collect_secret_files_reports_missing_secret_dir() {
2188 let lock = GlobalStateLock::new();
2189 let dir = tempfile::TempDir::new().expect("tempdir");
2190 let missing = dir.path().join("missing");
2191 let _secret = EnvGuard::set(
2192 &lock,
2193 "CODEX_SECRET_DIR",
2194 missing.to_str().expect("missing path"),
2195 );
2196
2197 let err = collect_secret_files().expect_err("expected missing dir error");
2198 assert_eq!(err.0, 1);
2199 assert!(err.1.contains("CODEX_SECRET_DIR not found"));
2200 }
2201
2202 #[test]
2203 fn collect_secret_files_returns_sorted_json_files_only() {
2204 let lock = GlobalStateLock::new();
2205 let dir = tempfile::TempDir::new().expect("tempdir");
2206 let secrets = dir.path().join("secrets");
2207 fs::create_dir_all(&secrets).expect("secrets dir");
2208 fs::write(secrets.join("beta.json"), "{}").expect("write beta");
2209 fs::write(secrets.join("alpha.json"), "{}").expect("write alpha");
2210 fs::write(secrets.join("note.txt"), "ignore").expect("write note");
2211 let _secret = EnvGuard::set(
2212 &lock,
2213 "CODEX_SECRET_DIR",
2214 secrets.to_str().expect("secrets path"),
2215 );
2216
2217 let files = collect_secret_files().expect("secret files");
2218 assert_eq!(files.len(), 2);
2219 assert_eq!(
2220 files[0].file_name().and_then(|name| name.to_str()),
2221 Some("alpha.json")
2222 );
2223 assert_eq!(
2224 files[1].file_name().and_then(|name| name.to_str()),
2225 Some("beta.json")
2226 );
2227 }
2228
2229 #[test]
2230 fn collect_secret_files_for_async_text_allows_empty_secret_dir() {
2231 let lock = GlobalStateLock::new();
2232 let dir = tempfile::TempDir::new().expect("tempdir");
2233 let secret_dir = dir.path().join("secrets");
2234 fs::create_dir_all(&secret_dir).expect("secret dir");
2235 let _secret = EnvGuard::set(
2236 &lock,
2237 "CODEX_SECRET_DIR",
2238 secret_dir.to_str().expect("secret"),
2239 );
2240
2241 let files = collect_secret_files_for_async_text().expect("async text secret files");
2242 assert!(files.is_empty());
2243 }
2244
2245 #[test]
2246 fn rate_limits_helper_env_timeout_supports_default_and_parse() {
2247 let lock = GlobalStateLock::new();
2248 let key = "CODEX_RATE_LIMITS_CURL_MAX_TIME_SECONDS";
2249
2250 let _removed = EnvGuard::remove(&lock, key);
2251 assert_eq!(env_timeout(key, 7), 7);
2252
2253 let _set = EnvGuard::set(&lock, key, "11");
2254 assert_eq!(env_timeout(key, 7), 11);
2255
2256 let _invalid = EnvGuard::set(&lock, key, "oops");
2257 assert_eq!(env_timeout(key, 7), 7);
2258 }
2259
2260 #[test]
2261 fn rate_limits_helper_resolve_target_and_is_auth_file() {
2262 let lock = GlobalStateLock::new();
2263 let dir = tempfile::TempDir::new().expect("tempdir");
2264 let secret_dir = dir.path().join("secrets");
2265 fs::create_dir_all(&secret_dir).expect("secret dir");
2266 let auth_file = dir.path().join("auth.json");
2267 fs::write(&auth_file, "{}").expect("auth");
2268
2269 let _secret = EnvGuard::set(
2270 &lock,
2271 "CODEX_SECRET_DIR",
2272 secret_dir.to_str().expect("secret"),
2273 );
2274 let _auth = EnvGuard::set(&lock, "CODEX_AUTH_FILE", auth_file.to_str().expect("auth"));
2275
2276 assert_eq!(
2277 resolve_target(Some("alpha.json")).expect("target"),
2278 secret_dir.join("alpha.json")
2279 );
2280 assert_eq!(resolve_target(Some("../bad")).expect_err("usage"), 64);
2281 assert_eq!(resolve_target(None).expect("auth default"), auth_file);
2282 assert!(is_auth_file(&auth_file));
2283 assert!(!is_auth_file(&secret_dir.join("alpha.json")));
2284 }
2285
2286 #[test]
2287 fn rate_limits_helper_resolve_target_without_auth_returns_err() {
2288 let lock = GlobalStateLock::new();
2289 let _auth = EnvGuard::remove(&lock, "CODEX_AUTH_FILE");
2290 let _home = EnvGuard::set(&lock, "HOME", "");
2291
2292 assert_eq!(resolve_target(None).expect_err("missing auth"), 1);
2293 }
2294
2295 #[test]
2296 fn rate_limits_helper_collect_json_from_cache_covers_hit_and_miss() {
2297 let lock = GlobalStateLock::new();
2298 let dir = tempfile::TempDir::new().expect("tempdir");
2299 let secret_dir = dir.path().join("secrets");
2300 let cache_root = dir.path().join("cache-root");
2301 fs::create_dir_all(&secret_dir).expect("secrets");
2302 fs::create_dir_all(&cache_root).expect("cache");
2303
2304 let alpha = secret_dir.join("alpha.json");
2305 fs::write(&alpha, "{}").expect("alpha");
2306
2307 let _secret = EnvGuard::set(
2308 &lock,
2309 "CODEX_SECRET_DIR",
2310 secret_dir.to_str().expect("secret"),
2311 );
2312 let _cache = EnvGuard::set(&lock, "ZSH_CACHE_DIR", cache_root.to_str().expect("cache"));
2313 cache::write_prompt_segment_cache(
2314 &alpha,
2315 fresh_fetched_at(),
2316 "3h",
2317 92,
2318 88,
2319 1_700_003_600,
2320 Some(1_700_001_200),
2321 )
2322 .expect("write cache");
2323
2324 let hit = collect_json_from_cache(&alpha, "cache", true);
2325 assert!(hit.ok);
2326 assert_eq!(hit.status, "ok");
2327 let summary = hit.summary.expect("summary");
2328 assert_eq!(summary.non_weekly_label, "3h");
2329 assert_eq!(summary.non_weekly_remaining, 92);
2330 assert_eq!(summary.weekly_remaining, 88);
2331
2332 let missing_target = secret_dir.join("missing.json");
2333 let miss = collect_json_from_cache(&missing_target, "cache", true);
2334 assert!(!miss.ok);
2335 let error = miss.error.expect("error");
2336 assert_eq!(error.code, "cache-read-failed");
2337 assert!(error.message.contains("cache not found"));
2338 }
2339
2340 #[test]
2341 fn rate_limits_helper_fetch_one_line_cached_covers_success_and_error() {
2342 let lock = GlobalStateLock::new();
2343 let dir = tempfile::TempDir::new().expect("tempdir");
2344 let secret_dir = dir.path().join("secrets");
2345 let cache_root = dir.path().join("cache-root");
2346 fs::create_dir_all(&secret_dir).expect("secrets");
2347 fs::create_dir_all(&cache_root).expect("cache");
2348
2349 let alpha = secret_dir.join("alpha.json");
2350 fs::write(&alpha, "{}").expect("alpha");
2351
2352 let _secret = EnvGuard::set(
2353 &lock,
2354 "CODEX_SECRET_DIR",
2355 secret_dir.to_str().expect("secret"),
2356 );
2357 let _cache = EnvGuard::set(&lock, "ZSH_CACHE_DIR", cache_root.to_str().expect("cache"));
2358 cache::write_prompt_segment_cache(
2359 &alpha,
2360 fresh_fetched_at(),
2361 "3h",
2362 70,
2363 55,
2364 1_700_003_600,
2365 Some(1_700_001_200),
2366 )
2367 .expect("write cache");
2368
2369 let cached = fetch_one_line_cached(&alpha);
2370 assert_eq!(cached.rc, 0);
2371 assert!(cached.err.is_empty());
2372 assert!(cached.line.expect("line").contains("3h:70%"));
2373
2374 let miss = fetch_one_line_cached(&secret_dir.join("beta.json"));
2375 assert_eq!(miss.rc, 1);
2376 assert!(miss.line.is_none());
2377 assert!(miss.err.contains("cache not found"));
2378 }
2379
2380 #[test]
2381 fn rate_limits_helper_async_fetch_one_line_uses_cache_fallback() {
2382 let lock = GlobalStateLock::new();
2383 let dir = tempfile::TempDir::new().expect("tempdir");
2384 let secret_dir = dir.path().join("secrets");
2385 let cache_root = dir.path().join("cache-root");
2386 fs::create_dir_all(&secret_dir).expect("secrets");
2387 fs::create_dir_all(&cache_root).expect("cache");
2388
2389 let missing = secret_dir.join("ghost.json");
2390 let _secret = EnvGuard::set(
2391 &lock,
2392 "CODEX_SECRET_DIR",
2393 secret_dir.to_str().expect("secret"),
2394 );
2395 let _cache = EnvGuard::set(&lock, "ZSH_CACHE_DIR", cache_root.to_str().expect("cache"));
2396 cache::write_prompt_segment_cache(
2397 &missing,
2398 fresh_fetched_at(),
2399 "3h",
2400 68,
2401 42,
2402 1_700_003_600,
2403 Some(1_700_001_200),
2404 )
2405 .expect("write cache");
2406
2407 let result = async_fetch_one_line(&missing, false, true, "ghost");
2408 assert_eq!(result.rc, 0);
2409 let line = result.line.expect("line");
2410 assert!(line.contains("3h:68%"));
2411 assert!(result.err.contains("falling back to cache"));
2412 }
2413
2414 #[test]
2415 fn rate_limits_helper_single_one_line_cached_mode_handles_hit_and_miss() {
2416 let lock = GlobalStateLock::new();
2417 let dir = tempfile::TempDir::new().expect("tempdir");
2418 let secret_dir = dir.path().join("secrets");
2419 let cache_root = dir.path().join("cache-root");
2420 fs::create_dir_all(&secret_dir).expect("secrets");
2421 fs::create_dir_all(&cache_root).expect("cache");
2422
2423 let alpha = secret_dir.join("alpha.json");
2424 let beta = secret_dir.join("beta.json");
2425 fs::write(&alpha, "{}").expect("alpha");
2426 fs::write(&beta, "{}").expect("beta");
2427
2428 let _secret = EnvGuard::set(
2429 &lock,
2430 "CODEX_SECRET_DIR",
2431 secret_dir.to_str().expect("secret"),
2432 );
2433 let _cache = EnvGuard::set(&lock, "ZSH_CACHE_DIR", cache_root.to_str().expect("cache"));
2434 cache::write_prompt_segment_cache(
2435 &alpha,
2436 fresh_fetched_at(),
2437 "3h",
2438 61,
2439 39,
2440 1_700_003_600,
2441 Some(1_700_001_200),
2442 )
2443 .expect("write cache");
2444
2445 let hit = single_one_line(&alpha, true, true, false).expect("single");
2446 assert!(hit.expect("line").contains("3h:61%"));
2447
2448 let miss = single_one_line(&beta, true, true, true).expect("single");
2449 assert!(miss.is_none());
2450
2451 let missing =
2452 single_one_line(&secret_dir.join("missing.json"), true, true, true).expect("single");
2453 assert!(missing.is_none());
2454 }
2455
2456 #[test]
2457 fn rate_limits_helper_sync_auth_silent_updates_matching_secret_and_timestamps() {
2458 let lock = GlobalStateLock::new();
2459 let dir = tempfile::TempDir::new().expect("tempdir");
2460 let secret_dir = dir.path().join("secrets");
2461 let cache_dir = dir.path().join("cache");
2462 fs::create_dir_all(&secret_dir).expect("secrets");
2463 fs::create_dir_all(&cache_dir).expect("cache");
2464
2465 let auth_file = dir.path().join("auth.json");
2466 let alpha = secret_dir.join("alpha.json");
2467 let beta = secret_dir.join("beta.json");
2468 fs::write(
2469 &auth_file,
2470 auth_json(
2471 PAYLOAD_ALPHA,
2472 "acct_001",
2473 "refresh_new",
2474 "2025-01-20T12:34:56Z",
2475 ),
2476 )
2477 .expect("auth");
2478 fs::write(
2479 &alpha,
2480 auth_json(
2481 PAYLOAD_ALPHA,
2482 "acct_001",
2483 "refresh_old",
2484 "2025-01-19T12:34:56Z",
2485 ),
2486 )
2487 .expect("alpha");
2488 fs::write(
2489 &beta,
2490 auth_json(
2491 PAYLOAD_BETA,
2492 "acct_002",
2493 "refresh_beta",
2494 "2025-01-18T12:34:56Z",
2495 ),
2496 )
2497 .expect("beta");
2498 fs::write(secret_dir.join("invalid.json"), "{invalid").expect("invalid");
2499 fs::write(secret_dir.join("note.txt"), "ignore").expect("note");
2500
2501 let _auth = EnvGuard::set(&lock, "CODEX_AUTH_FILE", auth_file.to_str().expect("auth"));
2502 let _secret = EnvGuard::set(
2503 &lock,
2504 "CODEX_SECRET_DIR",
2505 secret_dir.to_str().expect("secret"),
2506 );
2507 let _cache = EnvGuard::set(
2508 &lock,
2509 "CODEX_SECRET_CACHE_DIR",
2510 cache_dir.to_str().expect("cache"),
2511 );
2512
2513 let (rc, err) = sync_auth_silent().expect("sync");
2514 assert_eq!(rc, 0);
2515 assert!(err.is_none());
2516 assert_eq!(
2517 fs::read(&alpha).expect("alpha"),
2518 fs::read(&auth_file).expect("auth")
2519 );
2520 assert_ne!(
2521 fs::read(&beta).expect("beta"),
2522 fs::read(&auth_file).expect("auth")
2523 );
2524 assert!(cache_dir.join("alpha.json.timestamp").is_file());
2525 assert!(cache_dir.join("auth.json.timestamp").is_file());
2526 }
2527
2528 #[test]
2529 fn rate_limits_helper_parsers_and_name_helpers_cover_fallbacks() {
2530 let parsed =
2531 parse_one_line_output("alpha 3h:90% W:80% 2025-01-20 12:00:00+00:00").expect("parsed");
2532 assert_eq!(parsed.window_label, "3h");
2533 assert_eq!(parsed.non_weekly_remaining, 90);
2534 assert_eq!(parsed.weekly_remaining, 80);
2535 assert_eq!(parsed.weekly_reset_iso, "2025-01-20 12:00:00+00:00");
2536 assert!(parse_one_line_output("bad").is_none());
2537
2538 assert_eq!(normalize_one_line("a\tb\nc\r".to_string()), "a b c ");
2539 assert_eq!(target_file_name(Path::new("alpha.json")), "alpha.json");
2540 assert_eq!(target_file_name(Path::new("")), "");
2541 assert_eq!(secret_display_name(Path::new("alpha.json")), "alpha");
2542 }
2543
2544 #[test]
2545 fn rate_limits_helper_current_secret_basename_tracks_auth_switch() {
2546 let lock = GlobalStateLock::new();
2547 let dir = tempfile::TempDir::new().expect("tempdir");
2548 let secret_dir = dir.path().join("secrets");
2549 fs::create_dir_all(&secret_dir).expect("secrets");
2550
2551 let auth_file = dir.path().join("auth.json");
2552 let alpha = secret_dir.join("alpha.json");
2553 let beta = secret_dir.join("beta.json");
2554
2555 let alpha_json = auth_json(
2556 PAYLOAD_ALPHA,
2557 "acct_001",
2558 "refresh_alpha",
2559 "2025-01-20T12:34:56Z",
2560 );
2561 let beta_json = auth_json(
2562 PAYLOAD_BETA,
2563 "acct_002",
2564 "refresh_beta",
2565 "2025-01-21T12:34:56Z",
2566 );
2567 fs::write(&alpha, &alpha_json).expect("alpha");
2568 fs::write(&beta, &beta_json).expect("beta");
2569 fs::write(&auth_file, &alpha_json).expect("auth alpha");
2570
2571 let _auth = EnvGuard::set(&lock, "CODEX_AUTH_FILE", auth_file.to_str().expect("auth"));
2572
2573 let secret_files = vec![alpha.clone(), beta.clone()];
2574 assert_eq!(
2575 current_secret_basename(&secret_files).as_deref(),
2576 Some("alpha")
2577 );
2578
2579 fs::write(&auth_file, &beta_json).expect("auth beta");
2580 assert_eq!(
2581 current_secret_basename(&secret_files).as_deref(),
2582 Some("beta")
2583 );
2584 }
2585}