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 let _ = secret;
674 eprintln!("codex-rate-limits: --async does not accept positional args");
675 eprintln!(
676 "codex-rate-limits: hint: async always queries all secrets under CODEX_SECRET_DIR"
677 );
678 return Ok(64);
679 }
680 if args.clear_cache && args.cached {
681 eprintln!("codex-rate-limits: --async: -c is not compatible with --cached");
682 return Ok(64);
683 }
684
685 let jobs = resolve_async_jobs(args.jobs.as_deref());
686
687 if args.clear_cache
688 && let Err(err) = cache::clear_prompt_segment_cache()
689 {
690 eprintln!("{err}");
691 return Ok(1);
692 }
693
694 let secret_files = match collect_secret_files_for_async_text() {
695 Ok(value) => value,
696 Err(err) => {
697 eprintln!("{err}");
698 return Ok(1);
699 }
700 };
701
702 if !watch_mode {
703 if secret_files.is_empty() {
704 let secret_dir = crate::paths::resolve_secret_dir().unwrap_or_default();
705 eprintln!(
706 "codex-rate-limits-async: no secrets found in {}",
707 secret_dir.display()
708 );
709 return Ok(1);
710 }
711
712 let current_name = current_secret_basename(&secret_files);
713 let round = collect_async_round(&secret_files, args.cached, args.no_refresh_auth, jobs);
714 render_all_accounts_table(
715 round.rows,
716 &round.window_labels,
717 current_name.as_deref(),
718 None,
719 );
720 emit_async_debug(debug_mode, &secret_files, &round.stderr_map);
721 return Ok(round.rc);
722 }
723
724 let mut overall_rc = 0;
725 let mut rendered_rounds = 0u64;
726 let max_rounds = watch_max_rounds_for_test();
727 let watch_interval_seconds = watch_interval_seconds();
728 let is_terminal_stdout = std::io::stdout().is_terminal();
729
730 loop {
731 let secret_files = match collect_secret_files_for_async_text() {
732 Ok(value) => value,
733 Err(err) => {
734 overall_rc = 1;
735 if is_terminal_stdout {
736 print!("{ANSI_CLEAR_SCREEN_AND_HOME}");
737 }
738 eprintln!("{err}");
739 let _ = std::io::stdout().flush();
740
741 rendered_rounds += 1;
742 if let Some(limit) = max_rounds
743 && rendered_rounds >= limit
744 {
745 break;
746 }
747
748 thread::sleep(Duration::from_secs(watch_interval_seconds));
749 continue;
750 }
751 };
752 let current_name = current_secret_basename(&secret_files);
753 let round = collect_async_round(&secret_files, args.cached, args.no_refresh_auth, jobs);
754 if round.rc != 0 {
755 overall_rc = 1;
756 }
757
758 if is_terminal_stdout {
759 print!("{ANSI_CLEAR_SCREEN_AND_HOME}");
760 }
761
762 let now_epoch = Utc::now().timestamp();
763 let update_time = format_watch_update_time(now_epoch);
764 render_all_accounts_table(
765 round.rows,
766 &round.window_labels,
767 current_name.as_deref(),
768 Some(update_time.as_str()),
769 );
770 emit_async_debug(debug_mode, &secret_files, &round.stderr_map);
771 let _ = std::io::stdout().flush();
772
773 rendered_rounds += 1;
774 if let Some(limit) = max_rounds
775 && rendered_rounds >= limit
776 {
777 break;
778 }
779
780 thread::sleep(Duration::from_secs(watch_interval_seconds));
781 }
782
783 Ok(overall_rc)
784}
785
786fn collect_secret_files_for_async_text() -> std::result::Result<Vec<PathBuf>, String> {
787 let secret_dir = crate::paths::resolve_secret_dir().unwrap_or_default();
788 if !secret_dir.is_dir() {
789 return Err(format!(
790 "codex-rate-limits-async: CODEX_SECRET_DIR not found: {}",
791 secret_dir.display()
792 ));
793 }
794
795 let mut secret_files: Vec<PathBuf> = std::fs::read_dir(&secret_dir)
796 .map_err(|err| format!("codex-rate-limits-async: failed to read CODEX_SECRET_DIR: {err}"))?
797 .flatten()
798 .map(|entry| entry.path())
799 .filter(|path| path.extension().and_then(|s| s.to_str()) == Some("json"))
800 .collect();
801
802 secret_files.sort();
803 Ok(secret_files)
804}
805
806struct AsyncRound {
807 rc: i32,
808 rows: Vec<Row>,
809 window_labels: std::collections::HashSet<String>,
810 stderr_map: std::collections::HashMap<String, String>,
811}
812
813fn resolve_async_jobs(jobs: Option<&str>) -> usize {
814 jobs.and_then(|raw| raw.parse::<i64>().ok())
815 .filter(|value| *value > 0)
816 .map(|value| value as usize)
817 .unwrap_or(5)
818}
819
820fn collect_async_items<T, F>(
821 secret_files: &[PathBuf],
822 jobs: usize,
823 progress_prefix: Option<&str>,
824 worker: F,
825) -> std::collections::HashMap<String, T>
826where
827 T: Send + 'static,
828 F: Fn(PathBuf, String) -> T + Send + Sync + 'static,
829{
830 let total = secret_files.len();
831 if total == 0 {
832 return std::collections::HashMap::new();
833 }
834
835 let progress = if total > 1 {
836 progress_prefix.map(|prefix| {
837 Progress::new(
838 total as u64,
839 ProgressOptions::default()
840 .with_prefix(prefix)
841 .with_finish(ProgressFinish::Clear),
842 )
843 })
844 } else {
845 None
846 };
847
848 let worker_count = jobs.clamp(1, total);
849 let worker = Arc::new(worker);
850 let (tx, rx) = mpsc::channel();
851 let mut handles = Vec::new();
852 let mut index = 0usize;
853
854 while index < total && handles.len() < worker_count {
855 let path = secret_files[index].clone();
856 index += 1;
857 let tx = tx.clone();
858 let worker = Arc::clone(&worker);
859 handles.push(thread::spawn(move || {
860 let secret_name = path
861 .file_name()
862 .and_then(|name| name.to_str())
863 .unwrap_or("")
864 .to_string();
865 let value = worker(path, secret_name.clone());
866 let _ = tx.send(AsyncCollectedItem { secret_name, value });
867 }));
868 }
869
870 let mut items = std::collections::HashMap::new();
871 while items.len() < total {
872 let item = match rx.recv() {
873 Ok(item) => item,
874 Err(_) => break,
875 };
876 if let Some(progress) = &progress {
877 progress.set_message(item.secret_name.clone());
878 progress.inc(1);
879 }
880 items.insert(item.secret_name.clone(), item.value);
881
882 if index < total {
883 let path = secret_files[index].clone();
884 index += 1;
885 let tx = tx.clone();
886 let worker = Arc::clone(&worker);
887 handles.push(thread::spawn(move || {
888 let secret_name = path
889 .file_name()
890 .and_then(|name| name.to_str())
891 .unwrap_or("")
892 .to_string();
893 let value = worker(path, secret_name.clone());
894 let _ = tx.send(AsyncCollectedItem { secret_name, value });
895 }));
896 }
897 }
898
899 if let Some(progress) = progress {
900 progress.finish_and_clear();
901 }
902
903 drop(tx);
904 for handle in handles {
905 let _ = handle.join();
906 }
907
908 items
909}
910
911fn collect_async_round(
912 secret_files: &[PathBuf],
913 cached_mode: bool,
914 no_refresh_auth: bool,
915 jobs: usize,
916) -> AsyncRound {
917 let mut events = collect_async_items(
918 secret_files,
919 jobs,
920 Some("codex-rate-limits "),
921 move |path, secret_name| {
922 async_fetch_one_line(&path, cached_mode, no_refresh_auth, &secret_name)
923 },
924 );
925
926 let mut rc = 0;
927 let mut rows: Vec<Row> = Vec::new();
928 let mut window_labels = std::collections::HashSet::new();
929 let mut stderr_map: std::collections::HashMap<String, String> =
930 std::collections::HashMap::new();
931
932 for secret_file in secret_files {
933 let secret_name = secret_file
934 .file_name()
935 .and_then(|name| name.to_str())
936 .unwrap_or("")
937 .to_string();
938
939 let mut row = Row::empty(secret_name.trim_end_matches(".json").to_string());
940 let event = events.remove(&secret_name);
941 if let Some(event) = event {
942 if !event.err.is_empty() {
943 stderr_map.insert(secret_name.clone(), event.err.clone());
944 }
945 if !cached_mode && event.rc != 0 {
946 rc = 1;
947 }
948
949 if let Some(line) = &event.line
950 && let Some(parsed) = parse_one_line_output(line)
951 {
952 row.window_label = parsed.window_label.clone();
953 row.non_weekly_remaining = parsed.non_weekly_remaining;
954 row.weekly_remaining = parsed.weekly_remaining;
955 row.weekly_reset_iso = parsed.weekly_reset_iso.clone();
956
957 if cached_mode {
958 if let Ok(cache_entry) = cache::read_cache_entry_for_cached_mode(secret_file) {
959 row.non_weekly_reset_epoch = cache_entry.non_weekly_reset_epoch;
960 row.weekly_reset_epoch = Some(cache_entry.weekly_reset_epoch);
961 }
962 } else {
963 let values = crate::json::read_json(secret_file).ok();
964 if let Some(values) = values {
965 row.non_weekly_reset_epoch = crate::json::i64_at(
966 &values,
967 &["codex_rate_limits", "non_weekly_reset_at_epoch"],
968 );
969 row.weekly_reset_epoch = crate::json::i64_at(
970 &values,
971 &["codex_rate_limits", "weekly_reset_at_epoch"],
972 );
973 }
974 if (row.non_weekly_reset_epoch.is_none() || row.weekly_reset_epoch.is_none())
975 && let Ok(cache_entry) = cache::read_cache_entry(secret_file)
976 {
977 if row.non_weekly_reset_epoch.is_none() {
978 row.non_weekly_reset_epoch = cache_entry.non_weekly_reset_epoch;
979 }
980 if row.weekly_reset_epoch.is_none() {
981 row.weekly_reset_epoch = Some(cache_entry.weekly_reset_epoch);
982 }
983 }
984 }
985
986 window_labels.insert(row.window_label.clone());
987 rows.push(row);
988 continue;
989 }
990 }
991
992 if !cached_mode {
993 rc = 1;
994 }
995 rows.push(row);
996 }
997
998 AsyncRound {
999 rc,
1000 rows,
1001 window_labels,
1002 stderr_map,
1003 }
1004}
1005
1006fn render_all_accounts_table(
1007 mut rows: Vec<Row>,
1008 window_labels: &std::collections::HashSet<String>,
1009 current_name: Option<&str>,
1010 update_time: Option<&str>,
1011) {
1012 println!("\n🚦 Codex rate limits for all accounts\n");
1013
1014 let mut non_weekly_header = "Non-weekly".to_string();
1015 let multiple_labels = window_labels.len() != 1;
1016 if !multiple_labels && let Some(label) = window_labels.iter().next() {
1017 non_weekly_header = label.clone();
1018 }
1019
1020 let now_epoch = Utc::now().timestamp();
1021
1022 println!(
1023 "{:<15} {:>8} {:>7} {:>8} {:>7} {:<18}",
1024 "Name", non_weekly_header, "Left", "Weekly", "Left", "Reset"
1025 );
1026 println!("----------------------------------------------------------------------------");
1027
1028 rows.sort_by_key(|row| row.sort_key());
1029
1030 for row in rows {
1031 let display_non_weekly = if multiple_labels && !row.window_label.is_empty() {
1032 if row.non_weekly_remaining >= 0 {
1033 format!("{}:{}%", row.window_label, row.non_weekly_remaining)
1034 } else {
1035 "-".to_string()
1036 }
1037 } else if row.non_weekly_remaining >= 0 {
1038 format!("{}%", row.non_weekly_remaining)
1039 } else {
1040 "-".to_string()
1041 };
1042
1043 let non_weekly_left = row
1044 .non_weekly_reset_epoch
1045 .and_then(|epoch| render::format_until_epoch_compact(epoch, now_epoch))
1046 .unwrap_or_else(|| "-".to_string());
1047 let weekly_left = row
1048 .weekly_reset_epoch
1049 .and_then(|epoch| render::format_until_epoch_compact(epoch, now_epoch))
1050 .unwrap_or_else(|| "-".to_string());
1051 let reset_display = row
1052 .weekly_reset_epoch
1053 .and_then(render::format_epoch_local_datetime_with_offset)
1054 .unwrap_or_else(|| "-".to_string());
1055
1056 let non_weekly_display = ansi::format_percent_cell(&display_non_weekly, 8, None);
1057 let weekly_display = if row.weekly_remaining >= 0 {
1058 ansi::format_percent_cell(&format!("{}%", row.weekly_remaining), 8, None)
1059 } else {
1060 ansi::format_percent_cell("-", 8, None)
1061 };
1062
1063 let is_current = current_name == Some(row.name.as_str());
1064 let name_display = ansi::format_name_cell(&row.name, 15, is_current, None);
1065
1066 println!(
1067 "{} {} {:>7} {} {:>7} {:<18}",
1068 name_display,
1069 non_weekly_display,
1070 non_weekly_left,
1071 weekly_display,
1072 weekly_left,
1073 reset_display
1074 );
1075 }
1076
1077 if let Some(update_time) = update_time {
1078 println!();
1079 println!("Last update: {update_time}");
1080 }
1081}
1082
1083fn emit_async_debug(
1084 debug_mode: bool,
1085 secret_files: &[PathBuf],
1086 stderr_map: &std::collections::HashMap<String, String>,
1087) {
1088 if !debug_mode {
1089 return;
1090 }
1091
1092 let mut printed = false;
1093 for secret_file in secret_files {
1094 let secret_name = secret_file
1095 .file_name()
1096 .and_then(|name| name.to_str())
1097 .unwrap_or("")
1098 .to_string();
1099 if let Some(err) = stderr_map.get(&secret_name) {
1100 if err.is_empty() {
1101 continue;
1102 }
1103 if !printed {
1104 printed = true;
1105 eprintln!();
1106 eprintln!("codex-rate-limits-async: per-account stderr (captured):");
1107 }
1108 let _ = secret_name;
1109 eprintln!("---- account stderr ----");
1110 eprintln!("{err}");
1111 }
1112 }
1113}
1114
1115fn watch_max_rounds_for_test() -> Option<u64> {
1116 std::env::var("CODEX_RATE_LIMITS_WATCH_MAX_ROUNDS")
1117 .ok()
1118 .and_then(|raw| raw.parse::<u64>().ok())
1119 .filter(|value| *value > 0)
1120}
1121
1122fn watch_interval_seconds() -> u64 {
1123 std::env::var("CODEX_RATE_LIMITS_WATCH_INTERVAL_SECONDS")
1124 .ok()
1125 .and_then(|raw| raw.parse::<u64>().ok())
1126 .filter(|value| *value > 0)
1127 .unwrap_or(WATCH_INTERVAL_SECONDS)
1128}
1129
1130fn format_watch_update_time(now_epoch: i64) -> String {
1131 render::format_epoch_local(now_epoch, "%Y-%m-%d %H:%M:%S %:z")
1132 .unwrap_or_else(|| now_epoch.to_string())
1133}
1134
1135fn async_fetch_one_line(
1136 target_file: &Path,
1137 cached_mode: bool,
1138 no_refresh_auth: bool,
1139 secret_name: &str,
1140) -> AsyncFetchResult {
1141 if cached_mode {
1142 return fetch_one_line_cached(target_file);
1143 }
1144
1145 let mut attempt = 1;
1146 let max_attempts = 2;
1147 let mut network_err: Option<String> = None;
1148
1149 let mut result = fetch_one_line_network(target_file, no_refresh_auth);
1150 if !result.err.is_empty() {
1151 network_err = Some(result.err.clone());
1152 }
1153
1154 while attempt < max_attempts && result.rc == 3 {
1155 thread::sleep(Duration::from_millis(250));
1156 let next = fetch_one_line_network(target_file, no_refresh_auth);
1157 if !next.err.is_empty() {
1158 network_err = Some(next.err.clone());
1159 }
1160 result = next;
1161 attempt += 1;
1162 if result.rc != 3 {
1163 break;
1164 }
1165 }
1166
1167 let mut errors: Vec<String> = Vec::new();
1168 if let Some(err) = network_err {
1169 errors.push(err);
1170 }
1171
1172 let missing_line = result
1173 .line
1174 .as_ref()
1175 .map(|line| line.trim().is_empty())
1176 .unwrap_or(true);
1177
1178 if result.rc != 0 || missing_line {
1179 let cached = fetch_one_line_cached(target_file);
1180 if !cached.err.is_empty() {
1181 errors.push(cached.err.clone());
1182 }
1183 if cached.rc == 0
1184 && cached
1185 .line
1186 .as_ref()
1187 .map(|line| !line.trim().is_empty())
1188 .unwrap_or(false)
1189 {
1190 if result.rc != 0 {
1191 let _ = secret_name;
1192 errors.push(format!(
1193 "codex-rate-limits-async: falling back to cache (rc={})",
1194 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 _ = target_file;
1356 let weekly_reset_iso =
1357 render::format_epoch_local_datetime(weekly_reset_epoch).unwrap_or_else(|| "?".to_string());
1358
1359 format!(
1360 "{}:{}% W:{}% {}",
1361 non_weekly_label, non_weekly_remaining, weekly_remaining, weekly_reset_iso
1362 )
1363}
1364
1365fn normalize_one_line(line: String) -> String {
1366 line.replace(['\n', '\r', '\t'], " ")
1367}
1368
1369fn sync_auth_silent() -> Result<(i32, Option<String>)> {
1370 let auth_file = match crate::paths::resolve_auth_file() {
1371 Some(path) => path,
1372 None => return Ok((0, None)),
1373 };
1374
1375 let sync_result = match sync_auth_to_matching_secrets(
1376 &CODEX_PROVIDER_PROFILE,
1377 &auth_file,
1378 fs::SECRET_FILE_MODE,
1379 TimestampPolicy::Strict,
1380 ) {
1381 Ok(result) => result,
1382 Err(SyncSecretsError::HashAuthFile { path, .. })
1383 | Err(SyncSecretsError::HashSecretFile { path, .. }) => {
1384 return Ok((1, Some(format!("codex: failed to hash {}", path.display()))));
1385 }
1386 Err(err) => return Err(err.into()),
1387 };
1388 if !sync_result.auth_file_present || !sync_result.auth_identity_present {
1389 return Ok((0, None));
1390 }
1391
1392 Ok((0, None))
1393}
1394
1395fn maybe_sync_all_mode_auth_silent(debug_mode: bool) {
1396 match sync_auth_silent() {
1397 Ok((0, _)) => {}
1398 Ok((_, sync_err)) => {
1399 if debug_mode
1400 && let Some(message) = sync_err
1401 && !message.trim().is_empty()
1402 {
1403 eprintln!("{message}");
1404 }
1405 }
1406 Err(err) => {
1407 if debug_mode {
1408 eprintln!("codex-rate-limits: failed to sync auth and secrets: {err}");
1409 }
1410 }
1411 }
1412}
1413
1414fn run_all_mode(args: &RateLimitsOptions, cached_mode: bool, debug_mode: bool) -> Result<i32> {
1415 let secret_dir = crate::paths::resolve_secret_dir().unwrap_or_default();
1416 if !secret_dir.is_dir() {
1417 eprintln!(
1418 "codex-rate-limits: CODEX_SECRET_DIR not found: {}",
1419 secret_dir.display()
1420 );
1421 return Ok(1);
1422 }
1423
1424 let mut secret_files: Vec<PathBuf> = std::fs::read_dir(&secret_dir)?
1425 .flatten()
1426 .map(|entry| entry.path())
1427 .filter(|path| path.extension().and_then(|s| s.to_str()) == Some("json"))
1428 .collect();
1429
1430 if secret_files.is_empty() {
1431 eprintln!(
1432 "codex-rate-limits: no secrets found in {}",
1433 secret_dir.display()
1434 );
1435 return Ok(1);
1436 }
1437
1438 secret_files.sort();
1439
1440 let current_name = current_secret_basename(&secret_files);
1441
1442 let total = secret_files.len();
1443 let progress = if total > 1 {
1444 Some(Progress::new(
1445 total as u64,
1446 ProgressOptions::default()
1447 .with_prefix("codex-rate-limits ")
1448 .with_finish(ProgressFinish::Clear),
1449 ))
1450 } else {
1451 None
1452 };
1453
1454 let mut rc = 0;
1455 let mut rows: Vec<Row> = Vec::new();
1456 let mut window_labels = std::collections::HashSet::new();
1457
1458 for secret_file in secret_files {
1459 let secret_name = secret_file
1460 .file_name()
1461 .and_then(|name| name.to_str())
1462 .unwrap_or("")
1463 .to_string();
1464 if let Some(progress) = &progress {
1465 progress.set_message(secret_name.clone());
1466 }
1467
1468 let mut row = Row::empty(secret_name.trim_end_matches(".json").to_string());
1469 let output =
1470 match single_one_line(&secret_file, cached_mode, args.no_refresh_auth, debug_mode) {
1471 Ok(Some(line)) => line,
1472 Ok(None) => String::new(),
1473 Err(_) => String::new(),
1474 };
1475
1476 if output.is_empty() {
1477 if !cached_mode {
1478 rc = 1;
1479 }
1480 rows.push(row);
1481 continue;
1482 }
1483
1484 if let Some(parsed) = parse_one_line_output(&output) {
1485 row.window_label = parsed.window_label.clone();
1486 row.non_weekly_remaining = parsed.non_weekly_remaining;
1487 row.weekly_remaining = parsed.weekly_remaining;
1488 row.weekly_reset_iso = parsed.weekly_reset_iso.clone();
1489
1490 if cached_mode {
1491 if let Ok(cache_entry) = cache::read_cache_entry_for_cached_mode(&secret_file) {
1492 row.non_weekly_reset_epoch = cache_entry.non_weekly_reset_epoch;
1493 row.weekly_reset_epoch = Some(cache_entry.weekly_reset_epoch);
1494 }
1495 } else {
1496 let values = crate::json::read_json(&secret_file).ok();
1497 if let Some(values) = values {
1498 row.non_weekly_reset_epoch = crate::json::i64_at(
1499 &values,
1500 &["codex_rate_limits", "non_weekly_reset_at_epoch"],
1501 );
1502 row.weekly_reset_epoch = crate::json::i64_at(
1503 &values,
1504 &["codex_rate_limits", "weekly_reset_at_epoch"],
1505 );
1506 }
1507 }
1508
1509 window_labels.insert(row.window_label.clone());
1510 rows.push(row);
1511 } else {
1512 if !cached_mode {
1513 rc = 1;
1514 }
1515 rows.push(row);
1516 }
1517
1518 if let Some(progress) = &progress {
1519 progress.inc(1);
1520 }
1521 }
1522
1523 if let Some(progress) = progress {
1524 progress.finish_and_clear();
1525 }
1526
1527 println!("\n🚦 Codex rate limits for all accounts\n");
1528
1529 let mut non_weekly_header = "Non-weekly".to_string();
1530 let multiple_labels = window_labels.len() != 1;
1531 if !multiple_labels && let Some(label) = window_labels.iter().next() {
1532 non_weekly_header = label.clone();
1533 }
1534
1535 let now_epoch = Utc::now().timestamp();
1536
1537 println!(
1538 "{:<15} {:>8} {:>7} {:>8} {:>7} {:<18}",
1539 "Name", non_weekly_header, "Left", "Weekly", "Left", "Reset"
1540 );
1541 println!("----------------------------------------------------------------------------");
1542
1543 rows.sort_by_key(|row| row.sort_key());
1544
1545 for row in rows {
1546 let display_non_weekly = if multiple_labels && !row.window_label.is_empty() {
1547 if row.non_weekly_remaining >= 0 {
1548 format!("{}:{}%", row.window_label, row.non_weekly_remaining)
1549 } else {
1550 "-".to_string()
1551 }
1552 } else if row.non_weekly_remaining >= 0 {
1553 format!("{}%", row.non_weekly_remaining)
1554 } else {
1555 "-".to_string()
1556 };
1557
1558 let non_weekly_left = row
1559 .non_weekly_reset_epoch
1560 .and_then(|epoch| render::format_until_epoch_compact(epoch, now_epoch))
1561 .unwrap_or_else(|| "-".to_string());
1562 let weekly_left = row
1563 .weekly_reset_epoch
1564 .and_then(|epoch| render::format_until_epoch_compact(epoch, now_epoch))
1565 .unwrap_or_else(|| "-".to_string());
1566 let reset_display = row
1567 .weekly_reset_epoch
1568 .and_then(render::format_epoch_local_datetime_with_offset)
1569 .unwrap_or_else(|| "-".to_string());
1570
1571 let non_weekly_display = ansi::format_percent_cell(&display_non_weekly, 8, None);
1572 let weekly_display = if row.weekly_remaining >= 0 {
1573 ansi::format_percent_cell(&format!("{}%", row.weekly_remaining), 8, None)
1574 } else {
1575 ansi::format_percent_cell("-", 8, None)
1576 };
1577
1578 let is_current = current_name.as_deref() == Some(row.name.as_str());
1579 let name_display = ansi::format_name_cell(&row.name, 15, is_current, None);
1580
1581 println!(
1582 "{} {} {:>7} {} {:>7} {:<18}",
1583 name_display,
1584 non_weekly_display,
1585 non_weekly_left,
1586 weekly_display,
1587 weekly_left,
1588 reset_display
1589 );
1590 }
1591
1592 Ok(rc)
1593}
1594
1595fn current_secret_basename(secret_files: &[PathBuf]) -> Option<String> {
1596 let auth_file = crate::paths::resolve_auth_file()?;
1597 if !auth_file.is_file() {
1598 return None;
1599 }
1600
1601 let auth_key = auth::identity_key_from_auth_file(&auth_file).ok().flatten();
1602 let auth_hash = fs::sha256_file(&auth_file).ok();
1603
1604 if let Some(auth_hash) = auth_hash.as_deref() {
1605 for secret_file in secret_files {
1606 if let Ok(secret_hash) = fs::sha256_file(secret_file)
1607 && secret_hash == auth_hash
1608 && let Some(name) = secret_file.file_name().and_then(|name| name.to_str())
1609 {
1610 return Some(name.trim_end_matches(".json").to_string());
1611 }
1612 }
1613 }
1614
1615 if let Some(auth_key) = auth_key.as_deref() {
1616 for secret_file in secret_files {
1617 if let Ok(Some(candidate_key)) = auth::identity_key_from_auth_file(secret_file)
1618 && candidate_key == auth_key
1619 && let Some(name) = secret_file.file_name().and_then(|name| name.to_str())
1620 {
1621 return Some(name.trim_end_matches(".json").to_string());
1622 }
1623 }
1624 }
1625
1626 None
1627}
1628
1629fn run_single_mode(
1630 args: &RateLimitsOptions,
1631 cached_mode: bool,
1632 one_line: bool,
1633 output_json: bool,
1634) -> Result<i32> {
1635 let target_file = match resolve_target(args.secret.as_deref()) {
1636 Ok(path) => path,
1637 Err(code) => return Ok(code),
1638 };
1639
1640 if !target_file.is_file() {
1641 if output_json {
1642 diag_output::emit_error(
1643 DIAG_SCHEMA_VERSION,
1644 DIAG_COMMAND,
1645 "target-not-found",
1646 format!("codex-rate-limits: {} not found", target_file.display()),
1647 Some(serde_json::json!({
1648 "target_file": target_file.display().to_string(),
1649 })),
1650 )?;
1651 } else {
1652 eprintln!("codex-rate-limits: {} not found", target_file.display());
1653 }
1654 return Ok(1);
1655 }
1656
1657 if cached_mode {
1658 match cache::read_cache_entry_for_cached_mode(&target_file) {
1659 Ok(entry) => {
1660 let weekly_reset_iso =
1661 render::format_epoch_local_datetime(entry.weekly_reset_epoch)
1662 .unwrap_or_else(|| "?".to_string());
1663 println!(
1664 "{}:{}% W:{}% {}",
1665 entry.non_weekly_label,
1666 entry.non_weekly_remaining,
1667 entry.weekly_remaining,
1668 weekly_reset_iso
1669 );
1670 return Ok(0);
1671 }
1672 Err(err) => {
1673 eprintln!("{err}");
1674 return Ok(1);
1675 }
1676 }
1677 }
1678
1679 let base_url = std::env::var("CODEX_CHATGPT_BASE_URL")
1680 .unwrap_or_else(|_| "https://chatgpt.com/backend-api/".to_string());
1681 let connect_timeout = env_timeout("CODEX_RATE_LIMITS_CURL_CONNECT_TIMEOUT_SECONDS", 2);
1682 let max_time = env_timeout("CODEX_RATE_LIMITS_CURL_MAX_TIME_SECONDS", 8);
1683
1684 let usage_request = UsageRequest {
1685 target_file: target_file.clone(),
1686 refresh_on_401: !args.no_refresh_auth,
1687 base_url,
1688 connect_timeout_seconds: connect_timeout,
1689 max_time_seconds: max_time,
1690 };
1691
1692 let usage = match fetch_usage(&usage_request) {
1693 Ok(value) => value,
1694 Err(err) => {
1695 let msg = err.to_string();
1696 if msg.contains("missing access_token") {
1697 if output_json {
1698 diag_output::emit_error(
1699 DIAG_SCHEMA_VERSION,
1700 DIAG_COMMAND,
1701 "missing-access-token",
1702 format!(
1703 "codex-rate-limits: missing access_token in {}",
1704 target_file.display()
1705 ),
1706 Some(serde_json::json!({
1707 "target_file": target_file.display().to_string(),
1708 })),
1709 )?;
1710 } else {
1711 eprintln!(
1712 "codex-rate-limits: missing access_token in {}",
1713 target_file.display()
1714 );
1715 }
1716 return Ok(2);
1717 }
1718 if output_json {
1719 diag_output::emit_error(
1720 DIAG_SCHEMA_VERSION,
1721 DIAG_COMMAND,
1722 "request-failed",
1723 msg,
1724 Some(serde_json::json!({
1725 "target_file": target_file.display().to_string(),
1726 })),
1727 )?;
1728 } else {
1729 eprintln!("{msg}");
1730 }
1731 return Ok(3);
1732 }
1733 };
1734
1735 if let Err(err) = writeback::write_weekly(&target_file, &usage.json) {
1736 if output_json {
1737 diag_output::emit_error(
1738 DIAG_SCHEMA_VERSION,
1739 DIAG_COMMAND,
1740 "writeback-failed",
1741 err.to_string(),
1742 Some(serde_json::json!({
1743 "target_file": target_file.display().to_string(),
1744 })),
1745 )?;
1746 } else {
1747 eprintln!("{err}");
1748 }
1749 return Ok(4);
1750 }
1751
1752 if is_auth_file(&target_file) {
1753 let sync_rc = auth::sync::run_with_json(false)?;
1754 if sync_rc != 0 {
1755 if output_json {
1756 diag_output::emit_error(
1757 DIAG_SCHEMA_VERSION,
1758 DIAG_COMMAND,
1759 "sync-failed",
1760 "codex-rate-limits: failed to sync auth file",
1761 Some(serde_json::json!({
1762 "target_file": target_file.display().to_string(),
1763 })),
1764 )?;
1765 }
1766 return Ok(5);
1767 }
1768 }
1769
1770 let usage_data = match render::parse_usage(&usage.json) {
1771 Some(value) => value,
1772 None => {
1773 if output_json {
1774 diag_output::emit_error(
1775 DIAG_SCHEMA_VERSION,
1776 DIAG_COMMAND,
1777 "invalid-usage-payload",
1778 "codex-rate-limits: invalid usage payload",
1779 Some(serde_json::json!({
1780 "target_file": target_file.display().to_string(),
1781 "raw_usage": redact_sensitive_json(&usage.json),
1782 })),
1783 )?;
1784 } else {
1785 eprintln!("codex-rate-limits: invalid usage payload");
1786 }
1787 return Ok(3);
1788 }
1789 };
1790
1791 let values = render::render_values(&usage_data);
1792 let weekly = render::weekly_values(&values);
1793
1794 let fetched_at_epoch = Utc::now().timestamp();
1795 if fetched_at_epoch > 0 {
1796 let _ = cache::write_prompt_segment_cache(
1797 &target_file,
1798 fetched_at_epoch,
1799 &weekly.non_weekly_label,
1800 weekly.non_weekly_remaining,
1801 weekly.weekly_remaining,
1802 weekly.weekly_reset_epoch,
1803 weekly.non_weekly_reset_epoch,
1804 );
1805 }
1806
1807 if output_json {
1808 let result = RateLimitJsonResult {
1809 name: secret_display_name(&target_file),
1810 target_file: target_file_name(&target_file),
1811 status: "ok".to_string(),
1812 ok: true,
1813 source: "network".to_string(),
1814 summary: Some(RateLimitSummary {
1815 non_weekly_label: weekly.non_weekly_label,
1816 non_weekly_remaining: weekly.non_weekly_remaining,
1817 non_weekly_reset_epoch: weekly.non_weekly_reset_epoch,
1818 weekly_remaining: weekly.weekly_remaining,
1819 weekly_reset_epoch: weekly.weekly_reset_epoch,
1820 weekly_reset_local: render::format_epoch_local_datetime_with_offset(
1821 weekly.weekly_reset_epoch,
1822 ),
1823 }),
1824 raw_usage: Some(redact_sensitive_json(&usage.json)),
1825 error: None,
1826 };
1827 diag_output::emit_json(&RateLimitSingleEnvelope {
1828 schema_version: DIAG_SCHEMA_VERSION.to_string(),
1829 command: DIAG_COMMAND.to_string(),
1830 mode: "single".to_string(),
1831 ok: true,
1832 result,
1833 })?;
1834 return Ok(0);
1835 }
1836
1837 if one_line {
1838 let weekly_reset_iso = render::format_epoch_local_datetime(weekly.weekly_reset_epoch)
1839 .unwrap_or_else(|| "?".to_string());
1840
1841 println!(
1842 "{}:{}% W:{}% {}",
1843 weekly.non_weekly_label,
1844 weekly.non_weekly_remaining,
1845 weekly.weekly_remaining,
1846 weekly_reset_iso
1847 );
1848 return Ok(0);
1849 }
1850
1851 println!("Rate limits remaining");
1852 let primary_reset = render::format_epoch_local_datetime(values.primary_reset_epoch)
1853 .unwrap_or_else(|| "?".to_string());
1854 let secondary_reset = render::format_epoch_local_datetime(values.secondary_reset_epoch)
1855 .unwrap_or_else(|| "?".to_string());
1856
1857 println!(
1858 "{} {}% • {}",
1859 values.primary_label, values.primary_remaining, primary_reset
1860 );
1861 println!(
1862 "{} {}% • {}",
1863 values.secondary_label, values.secondary_remaining, secondary_reset
1864 );
1865
1866 Ok(0)
1867}
1868
1869fn single_one_line(
1870 target_file: &Path,
1871 cached_mode: bool,
1872 no_refresh_auth: bool,
1873 debug_mode: bool,
1874) -> Result<Option<String>> {
1875 if !target_file.is_file() {
1876 if debug_mode {
1877 eprintln!("codex-rate-limits: target file not found");
1878 }
1879 return Ok(None);
1880 }
1881
1882 if cached_mode {
1883 return match cache::read_cache_entry_for_cached_mode(target_file) {
1884 Ok(entry) => {
1885 let weekly_reset_iso =
1886 render::format_epoch_local_datetime(entry.weekly_reset_epoch)
1887 .unwrap_or_else(|| "?".to_string());
1888 Ok(Some(format!(
1889 "{}:{}% W:{}% {}",
1890 entry.non_weekly_label,
1891 entry.non_weekly_remaining,
1892 entry.weekly_remaining,
1893 weekly_reset_iso
1894 )))
1895 }
1896 Err(err) => {
1897 if debug_mode {
1898 eprintln!("{err}");
1899 }
1900 Ok(None)
1901 }
1902 };
1903 }
1904
1905 let base_url = std::env::var("CODEX_CHATGPT_BASE_URL")
1906 .unwrap_or_else(|_| "https://chatgpt.com/backend-api/".to_string());
1907 let connect_timeout = env_timeout("CODEX_RATE_LIMITS_CURL_CONNECT_TIMEOUT_SECONDS", 2);
1908 let max_time = env_timeout("CODEX_RATE_LIMITS_CURL_MAX_TIME_SECONDS", 8);
1909
1910 let usage_request = UsageRequest {
1911 target_file: target_file.to_path_buf(),
1912 refresh_on_401: !no_refresh_auth,
1913 base_url,
1914 connect_timeout_seconds: connect_timeout,
1915 max_time_seconds: max_time,
1916 };
1917
1918 let usage = match fetch_usage(&usage_request) {
1919 Ok(value) => value,
1920 Err(err) => {
1921 if debug_mode {
1922 eprintln!("{err}");
1923 }
1924 return Ok(None);
1925 }
1926 };
1927
1928 let _ = writeback::write_weekly(target_file, &usage.json);
1929 if is_auth_file(target_file) {
1930 let _ = auth::sync::run();
1931 }
1932
1933 let usage_data = match render::parse_usage(&usage.json) {
1934 Some(value) => value,
1935 None => return Ok(None),
1936 };
1937 let values = render::render_values(&usage_data);
1938 let weekly = render::weekly_values(&values);
1939 let fetched_at_epoch = Utc::now().timestamp();
1940 if fetched_at_epoch > 0 {
1941 let _ = cache::write_prompt_segment_cache(
1942 target_file,
1943 fetched_at_epoch,
1944 &weekly.non_weekly_label,
1945 weekly.non_weekly_remaining,
1946 weekly.weekly_remaining,
1947 weekly.weekly_reset_epoch,
1948 weekly.non_weekly_reset_epoch,
1949 );
1950 }
1951 let weekly_reset_iso = render::format_epoch_local_datetime(weekly.weekly_reset_epoch)
1952 .unwrap_or_else(|| "?".to_string());
1953
1954 Ok(Some(format!(
1955 "{}:{}% W:{}% {}",
1956 weekly.non_weekly_label,
1957 weekly.non_weekly_remaining,
1958 weekly.weekly_remaining,
1959 weekly_reset_iso
1960 )))
1961}
1962
1963fn resolve_target(secret: Option<&str>) -> std::result::Result<PathBuf, i32> {
1964 if let Some(secret_name) = secret {
1965 if secret_name.is_empty() || secret_name.contains('/') || secret_name.contains("..") {
1966 eprintln!("codex-rate-limits: invalid secret file name");
1967 return Err(64);
1968 }
1969 let secret_dir = crate::paths::resolve_secret_dir().unwrap_or_default();
1970 return Ok(secret_dir.join(secret_name));
1971 }
1972
1973 if let Some(auth_file) = crate::paths::resolve_auth_file() {
1974 return Ok(auth_file);
1975 }
1976
1977 Err(1)
1978}
1979
1980fn is_auth_file(target_file: &Path) -> bool {
1981 if let Some(auth_file) = crate::paths::resolve_auth_file() {
1982 return auth_file == target_file;
1983 }
1984 false
1985}
1986
1987fn env_timeout(key: &str, default: u64) -> u64 {
1988 std::env::var(key)
1989 .ok()
1990 .and_then(|raw| raw.parse::<u64>().ok())
1991 .unwrap_or(default)
1992}
1993
1994struct Row {
1995 name: String,
1996 window_label: String,
1997 non_weekly_remaining: i64,
1998 non_weekly_reset_epoch: Option<i64>,
1999 weekly_remaining: i64,
2000 weekly_reset_epoch: Option<i64>,
2001 weekly_reset_iso: String,
2002}
2003
2004impl Row {
2005 fn empty(name: String) -> Self {
2006 Self {
2007 name,
2008 window_label: String::new(),
2009 non_weekly_remaining: -1,
2010 non_weekly_reset_epoch: None,
2011 weekly_remaining: -1,
2012 weekly_reset_epoch: None,
2013 weekly_reset_iso: String::new(),
2014 }
2015 }
2016
2017 fn sort_key(&self) -> (i32, i64, String) {
2018 if let Some(epoch) = self.weekly_reset_epoch {
2019 (0, epoch, self.name.clone())
2020 } else {
2021 (1, i64::MAX, self.name.clone())
2022 }
2023 }
2024}
2025
2026struct ParsedOneLine {
2027 window_label: String,
2028 non_weekly_remaining: i64,
2029 weekly_remaining: i64,
2030 weekly_reset_iso: String,
2031}
2032
2033fn parse_one_line_output(line: &str) -> Option<ParsedOneLine> {
2034 let parts: Vec<&str> = line.split_whitespace().collect();
2035 if parts.len() < 3 {
2036 return None;
2037 }
2038
2039 fn parse_fields(
2040 window_field: &str,
2041 weekly_field: &str,
2042 reset_iso: String,
2043 ) -> Option<ParsedOneLine> {
2044 let window_label = window_field
2045 .split(':')
2046 .next()?
2047 .trim_matches('"')
2048 .to_string();
2049 let non_weekly_remaining = window_field.split(':').nth(1)?;
2050 let non_weekly_remaining = non_weekly_remaining
2051 .trim_end_matches('%')
2052 .parse::<i64>()
2053 .ok()?;
2054
2055 let weekly_remaining = weekly_field.trim_start_matches("W:").trim_end_matches('%');
2056 let weekly_remaining = weekly_remaining.parse::<i64>().ok()?;
2057
2058 Some(ParsedOneLine {
2059 window_label,
2060 non_weekly_remaining,
2061 weekly_remaining,
2062 weekly_reset_iso: reset_iso,
2063 })
2064 }
2065
2066 let len = parts.len();
2067 let window_field = parts[len - 3];
2068 let weekly_field = parts[len - 2];
2069 let reset_iso = parts[len - 1].to_string();
2070
2071 if let Some(parsed) = parse_fields(window_field, weekly_field, reset_iso) {
2072 return Some(parsed);
2073 }
2074
2075 if len < 4 {
2076 return None;
2077 }
2078
2079 parse_fields(
2080 parts[len - 4],
2081 parts[len - 3],
2082 format!("{} {}", parts[len - 2], parts[len - 1]),
2083 )
2084}
2085
2086#[cfg(test)]
2087mod tests {
2088 use super::{
2089 async_fetch_one_line, cache, collect_json_from_cache, collect_secret_files,
2090 collect_secret_files_for_async_text, current_secret_basename, env_timeout,
2091 fetch_one_line_cached, is_auth_file, normalize_one_line, parse_one_line_output,
2092 redact_sensitive_json, resolve_target, secret_display_name, single_one_line,
2093 sync_auth_silent, target_file_name,
2094 };
2095 use chrono::Utc;
2096 use nils_test_support::{EnvGuard, GlobalStateLock};
2097 use serde_json::json;
2098 use std::fs;
2099 use std::path::Path;
2100
2101 const HEADER: &str = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0";
2102 const PAYLOAD_ALPHA: &str = "eyJzdWIiOiJ1c2VyXzEyMyIsImVtYWlsIjoiYWxwaGFAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF91c2VyX2lkIjoidXNlcl8xMjMiLCJlbWFpbCI6ImFscGhhQGV4YW1wbGUuY29tIn19";
2103 const PAYLOAD_BETA: &str = "eyJzdWIiOiJ1c2VyXzQ1NiIsImVtYWlsIjoiYmV0YUBleGFtcGxlLmNvbSIsImh0dHBzOi8vYXBpLm9wZW5haS5jb20vYXV0aCI6eyJjaGF0Z3B0X3VzZXJfaWQiOiJ1c2VyXzQ1NiIsImVtYWlsIjoiYmV0YUBleGFtcGxlLmNvbSJ9fQ";
2104
2105 fn token(payload: &str) -> String {
2106 format!("{HEADER}.{payload}.sig")
2107 }
2108
2109 fn auth_json(
2110 payload: &str,
2111 account_id: &str,
2112 refresh_token: &str,
2113 last_refresh: &str,
2114 ) -> String {
2115 format!(
2116 r#"{{"tokens":{{"access_token":"{}","id_token":"{}","refresh_token":"{}","account_id":"{}"}},"last_refresh":"{}"}}"#,
2117 token(payload),
2118 token(payload),
2119 refresh_token,
2120 account_id,
2121 last_refresh
2122 )
2123 }
2124
2125 fn fresh_fetched_at() -> i64 {
2126 Utc::now().timestamp()
2127 }
2128
2129 #[test]
2130 fn redact_sensitive_json_removes_tokens_recursively() {
2131 let input = json!({
2132 "tokens": {
2133 "access_token": "a",
2134 "refresh_token": "b",
2135 "nested": {
2136 "id_token": "c",
2137 "Authorization": "Bearer x",
2138 "ok": 1
2139 }
2140 },
2141 "items": [
2142 {"authorization": "Bearer y", "value": 2}
2143 ],
2144 "safe": true
2145 });
2146
2147 let redacted = redact_sensitive_json(&input);
2148 assert_eq!(redacted["tokens"]["nested"]["ok"], 1);
2149 assert_eq!(redacted["safe"], true);
2150 assert!(
2151 redacted["tokens"].get("access_token").is_none(),
2152 "access_token should be removed"
2153 );
2154 assert!(
2155 redacted["tokens"]["nested"].get("id_token").is_none(),
2156 "id_token should be removed"
2157 );
2158 assert!(
2159 redacted["tokens"]["nested"].get("Authorization").is_none(),
2160 "Authorization should be removed"
2161 );
2162 assert!(
2163 redacted["items"][0].get("authorization").is_none(),
2164 "authorization should be removed"
2165 );
2166 }
2167
2168 #[test]
2169 fn collect_secret_files_reports_missing_secret_dir() {
2170 let lock = GlobalStateLock::new();
2171 let dir = tempfile::TempDir::new().expect("tempdir");
2172 let missing = dir.path().join("missing");
2173 let _secret = EnvGuard::set(
2174 &lock,
2175 "CODEX_SECRET_DIR",
2176 missing.to_str().expect("missing path"),
2177 );
2178
2179 let err = collect_secret_files().expect_err("expected missing dir error");
2180 assert_eq!(err.0, 1);
2181 assert!(err.1.contains("CODEX_SECRET_DIR not found"));
2182 }
2183
2184 #[test]
2185 fn collect_secret_files_returns_sorted_json_files_only() {
2186 let lock = GlobalStateLock::new();
2187 let dir = tempfile::TempDir::new().expect("tempdir");
2188 let secrets = dir.path().join("secrets");
2189 fs::create_dir_all(&secrets).expect("secrets dir");
2190 fs::write(secrets.join("beta.json"), "{}").expect("write beta");
2191 fs::write(secrets.join("alpha.json"), "{}").expect("write alpha");
2192 fs::write(secrets.join("note.txt"), "ignore").expect("write note");
2193 let _secret = EnvGuard::set(
2194 &lock,
2195 "CODEX_SECRET_DIR",
2196 secrets.to_str().expect("secrets path"),
2197 );
2198
2199 let files = collect_secret_files().expect("secret files");
2200 assert_eq!(files.len(), 2);
2201 assert_eq!(
2202 files[0].file_name().and_then(|name| name.to_str()),
2203 Some("alpha.json")
2204 );
2205 assert_eq!(
2206 files[1].file_name().and_then(|name| name.to_str()),
2207 Some("beta.json")
2208 );
2209 }
2210
2211 #[test]
2212 fn collect_secret_files_for_async_text_allows_empty_secret_dir() {
2213 let lock = GlobalStateLock::new();
2214 let dir = tempfile::TempDir::new().expect("tempdir");
2215 let secret_dir = dir.path().join("secrets");
2216 fs::create_dir_all(&secret_dir).expect("secret dir");
2217 let _secret = EnvGuard::set(
2218 &lock,
2219 "CODEX_SECRET_DIR",
2220 secret_dir.to_str().expect("secret"),
2221 );
2222
2223 let files = collect_secret_files_for_async_text().expect("async text secret files");
2224 assert!(files.is_empty());
2225 }
2226
2227 #[test]
2228 fn rate_limits_helper_env_timeout_supports_default_and_parse() {
2229 let lock = GlobalStateLock::new();
2230 let key = "CODEX_RATE_LIMITS_CURL_MAX_TIME_SECONDS";
2231
2232 let _removed = EnvGuard::remove(&lock, key);
2233 assert_eq!(env_timeout(key, 7), 7);
2234
2235 let _set = EnvGuard::set(&lock, key, "11");
2236 assert_eq!(env_timeout(key, 7), 11);
2237
2238 let _invalid = EnvGuard::set(&lock, key, "oops");
2239 assert_eq!(env_timeout(key, 7), 7);
2240 }
2241
2242 #[test]
2243 fn rate_limits_helper_resolve_target_and_is_auth_file() {
2244 let lock = GlobalStateLock::new();
2245 let dir = tempfile::TempDir::new().expect("tempdir");
2246 let secret_dir = dir.path().join("secrets");
2247 fs::create_dir_all(&secret_dir).expect("secret dir");
2248 let auth_file = dir.path().join("auth.json");
2249 fs::write(&auth_file, "{}").expect("auth");
2250
2251 let _secret = EnvGuard::set(
2252 &lock,
2253 "CODEX_SECRET_DIR",
2254 secret_dir.to_str().expect("secret"),
2255 );
2256 let _auth = EnvGuard::set(&lock, "CODEX_AUTH_FILE", auth_file.to_str().expect("auth"));
2257
2258 assert_eq!(
2259 resolve_target(Some("alpha.json")).expect("target"),
2260 secret_dir.join("alpha.json")
2261 );
2262 assert_eq!(resolve_target(Some("../bad")).expect_err("usage"), 64);
2263 assert_eq!(resolve_target(None).expect("auth default"), auth_file);
2264 assert!(is_auth_file(&auth_file));
2265 assert!(!is_auth_file(&secret_dir.join("alpha.json")));
2266 }
2267
2268 #[test]
2269 fn rate_limits_helper_resolve_target_without_auth_returns_err() {
2270 let lock = GlobalStateLock::new();
2271 let _auth = EnvGuard::remove(&lock, "CODEX_AUTH_FILE");
2272 let _home = EnvGuard::set(&lock, "HOME", "");
2273
2274 assert_eq!(resolve_target(None).expect_err("missing auth"), 1);
2275 }
2276
2277 #[test]
2278 fn rate_limits_helper_collect_json_from_cache_covers_hit_and_miss() {
2279 let lock = GlobalStateLock::new();
2280 let dir = tempfile::TempDir::new().expect("tempdir");
2281 let secret_dir = dir.path().join("secrets");
2282 let cache_root = dir.path().join("cache-root");
2283 fs::create_dir_all(&secret_dir).expect("secrets");
2284 fs::create_dir_all(&cache_root).expect("cache");
2285
2286 let alpha = secret_dir.join("alpha.json");
2287 fs::write(&alpha, "{}").expect("alpha");
2288
2289 let _secret = EnvGuard::set(
2290 &lock,
2291 "CODEX_SECRET_DIR",
2292 secret_dir.to_str().expect("secret"),
2293 );
2294 let _cache = EnvGuard::set(&lock, "ZSH_CACHE_DIR", cache_root.to_str().expect("cache"));
2295 cache::write_prompt_segment_cache(
2296 &alpha,
2297 fresh_fetched_at(),
2298 "3h",
2299 92,
2300 88,
2301 1_700_003_600,
2302 Some(1_700_001_200),
2303 )
2304 .expect("write cache");
2305
2306 let hit = collect_json_from_cache(&alpha, "cache", true);
2307 assert!(hit.ok);
2308 assert_eq!(hit.status, "ok");
2309 let summary = hit.summary.expect("summary");
2310 assert_eq!(summary.non_weekly_label, "3h");
2311 assert_eq!(summary.non_weekly_remaining, 92);
2312 assert_eq!(summary.weekly_remaining, 88);
2313
2314 let missing_target = secret_dir.join("missing.json");
2315 let miss = collect_json_from_cache(&missing_target, "cache", true);
2316 assert!(!miss.ok);
2317 let error = miss.error.expect("error");
2318 assert_eq!(error.code, "cache-read-failed");
2319 assert!(error.message.contains("cache not found"));
2320 }
2321
2322 #[test]
2323 fn rate_limits_helper_fetch_one_line_cached_covers_success_and_error() {
2324 let lock = GlobalStateLock::new();
2325 let dir = tempfile::TempDir::new().expect("tempdir");
2326 let secret_dir = dir.path().join("secrets");
2327 let cache_root = dir.path().join("cache-root");
2328 fs::create_dir_all(&secret_dir).expect("secrets");
2329 fs::create_dir_all(&cache_root).expect("cache");
2330
2331 let alpha = secret_dir.join("alpha.json");
2332 fs::write(&alpha, "{}").expect("alpha");
2333
2334 let _secret = EnvGuard::set(
2335 &lock,
2336 "CODEX_SECRET_DIR",
2337 secret_dir.to_str().expect("secret"),
2338 );
2339 let _cache = EnvGuard::set(&lock, "ZSH_CACHE_DIR", cache_root.to_str().expect("cache"));
2340 cache::write_prompt_segment_cache(
2341 &alpha,
2342 fresh_fetched_at(),
2343 "3h",
2344 70,
2345 55,
2346 1_700_003_600,
2347 Some(1_700_001_200),
2348 )
2349 .expect("write cache");
2350
2351 let cached = fetch_one_line_cached(&alpha);
2352 assert_eq!(cached.rc, 0);
2353 assert!(cached.err.is_empty());
2354 assert!(cached.line.expect("line").contains("3h:70%"));
2355
2356 let miss = fetch_one_line_cached(&secret_dir.join("beta.json"));
2357 assert_eq!(miss.rc, 1);
2358 assert!(miss.line.is_none());
2359 assert!(miss.err.contains("cache not found"));
2360 }
2361
2362 #[test]
2363 fn rate_limits_helper_async_fetch_one_line_uses_cache_fallback() {
2364 let lock = GlobalStateLock::new();
2365 let dir = tempfile::TempDir::new().expect("tempdir");
2366 let secret_dir = dir.path().join("secrets");
2367 let cache_root = dir.path().join("cache-root");
2368 fs::create_dir_all(&secret_dir).expect("secrets");
2369 fs::create_dir_all(&cache_root).expect("cache");
2370
2371 let missing = secret_dir.join("ghost.json");
2372 let _secret = EnvGuard::set(
2373 &lock,
2374 "CODEX_SECRET_DIR",
2375 secret_dir.to_str().expect("secret"),
2376 );
2377 let _cache = EnvGuard::set(&lock, "ZSH_CACHE_DIR", cache_root.to_str().expect("cache"));
2378 cache::write_prompt_segment_cache(
2379 &missing,
2380 fresh_fetched_at(),
2381 "3h",
2382 68,
2383 42,
2384 1_700_003_600,
2385 Some(1_700_001_200),
2386 )
2387 .expect("write cache");
2388
2389 let result = async_fetch_one_line(&missing, false, true, "ghost");
2390 assert_eq!(result.rc, 0);
2391 let line = result.line.expect("line");
2392 assert!(line.contains("3h:68%"));
2393 assert!(result.err.contains("falling back to cache"));
2394 }
2395
2396 #[test]
2397 fn rate_limits_helper_single_one_line_cached_mode_handles_hit_and_miss() {
2398 let lock = GlobalStateLock::new();
2399 let dir = tempfile::TempDir::new().expect("tempdir");
2400 let secret_dir = dir.path().join("secrets");
2401 let cache_root = dir.path().join("cache-root");
2402 fs::create_dir_all(&secret_dir).expect("secrets");
2403 fs::create_dir_all(&cache_root).expect("cache");
2404
2405 let alpha = secret_dir.join("alpha.json");
2406 let beta = secret_dir.join("beta.json");
2407 fs::write(&alpha, "{}").expect("alpha");
2408 fs::write(&beta, "{}").expect("beta");
2409
2410 let _secret = EnvGuard::set(
2411 &lock,
2412 "CODEX_SECRET_DIR",
2413 secret_dir.to_str().expect("secret"),
2414 );
2415 let _cache = EnvGuard::set(&lock, "ZSH_CACHE_DIR", cache_root.to_str().expect("cache"));
2416 cache::write_prompt_segment_cache(
2417 &alpha,
2418 fresh_fetched_at(),
2419 "3h",
2420 61,
2421 39,
2422 1_700_003_600,
2423 Some(1_700_001_200),
2424 )
2425 .expect("write cache");
2426
2427 let hit = single_one_line(&alpha, true, true, false).expect("single");
2428 assert!(hit.expect("line").contains("3h:61%"));
2429
2430 let miss = single_one_line(&beta, true, true, true).expect("single");
2431 assert!(miss.is_none());
2432
2433 let missing =
2434 single_one_line(&secret_dir.join("missing.json"), true, true, true).expect("single");
2435 assert!(missing.is_none());
2436 }
2437
2438 #[test]
2439 fn rate_limits_helper_sync_auth_silent_updates_matching_secret_and_timestamps() {
2440 let lock = GlobalStateLock::new();
2441 let dir = tempfile::TempDir::new().expect("tempdir");
2442 let secret_dir = dir.path().join("secrets");
2443 let cache_dir = dir.path().join("cache");
2444 fs::create_dir_all(&secret_dir).expect("secrets");
2445 fs::create_dir_all(&cache_dir).expect("cache");
2446
2447 let auth_file = dir.path().join("auth.json");
2448 let alpha = secret_dir.join("alpha.json");
2449 let beta = secret_dir.join("beta.json");
2450 fs::write(
2451 &auth_file,
2452 auth_json(
2453 PAYLOAD_ALPHA,
2454 "acct_001",
2455 "refresh_new",
2456 "2025-01-20T12:34:56Z",
2457 ),
2458 )
2459 .expect("auth");
2460 fs::write(
2461 &alpha,
2462 auth_json(
2463 PAYLOAD_ALPHA,
2464 "acct_001",
2465 "refresh_old",
2466 "2025-01-19T12:34:56Z",
2467 ),
2468 )
2469 .expect("alpha");
2470 fs::write(
2471 &beta,
2472 auth_json(
2473 PAYLOAD_BETA,
2474 "acct_002",
2475 "refresh_beta",
2476 "2025-01-18T12:34:56Z",
2477 ),
2478 )
2479 .expect("beta");
2480 fs::write(secret_dir.join("invalid.json"), "{invalid").expect("invalid");
2481 fs::write(secret_dir.join("note.txt"), "ignore").expect("note");
2482
2483 let _auth = EnvGuard::set(&lock, "CODEX_AUTH_FILE", auth_file.to_str().expect("auth"));
2484 let _secret = EnvGuard::set(
2485 &lock,
2486 "CODEX_SECRET_DIR",
2487 secret_dir.to_str().expect("secret"),
2488 );
2489 let _cache = EnvGuard::set(
2490 &lock,
2491 "CODEX_SECRET_CACHE_DIR",
2492 cache_dir.to_str().expect("cache"),
2493 );
2494
2495 let (rc, err) = sync_auth_silent().expect("sync");
2496 assert_eq!(rc, 0);
2497 assert!(err.is_none());
2498 assert_eq!(
2499 fs::read(&alpha).expect("alpha"),
2500 fs::read(&auth_file).expect("auth")
2501 );
2502 assert_ne!(
2503 fs::read(&beta).expect("beta"),
2504 fs::read(&auth_file).expect("auth")
2505 );
2506 assert!(cache_dir.join("alpha.json.timestamp").is_file());
2507 assert!(cache_dir.join("auth.json.timestamp").is_file());
2508 }
2509
2510 #[test]
2511 fn rate_limits_helper_parsers_and_name_helpers_cover_fallbacks() {
2512 let parsed =
2513 parse_one_line_output("alpha 3h:90% W:80% 2025-01-20 12:00:00+00:00").expect("parsed");
2514 assert_eq!(parsed.window_label, "3h");
2515 assert_eq!(parsed.non_weekly_remaining, 90);
2516 assert_eq!(parsed.weekly_remaining, 80);
2517 assert_eq!(parsed.weekly_reset_iso, "2025-01-20 12:00:00+00:00");
2518 assert!(parse_one_line_output("bad").is_none());
2519
2520 assert_eq!(normalize_one_line("a\tb\nc\r".to_string()), "a b c ");
2521 assert_eq!(target_file_name(Path::new("alpha.json")), "alpha.json");
2522 assert_eq!(target_file_name(Path::new("")), "");
2523 assert_eq!(secret_display_name(Path::new("alpha.json")), "alpha");
2524 }
2525
2526 #[test]
2527 fn rate_limits_helper_current_secret_basename_tracks_auth_switch() {
2528 let lock = GlobalStateLock::new();
2529 let dir = tempfile::TempDir::new().expect("tempdir");
2530 let secret_dir = dir.path().join("secrets");
2531 fs::create_dir_all(&secret_dir).expect("secrets");
2532
2533 let auth_file = dir.path().join("auth.json");
2534 let alpha = secret_dir.join("alpha.json");
2535 let beta = secret_dir.join("beta.json");
2536
2537 let alpha_json = auth_json(
2538 PAYLOAD_ALPHA,
2539 "acct_001",
2540 "refresh_alpha",
2541 "2025-01-20T12:34:56Z",
2542 );
2543 let beta_json = auth_json(
2544 PAYLOAD_BETA,
2545 "acct_002",
2546 "refresh_beta",
2547 "2025-01-21T12:34:56Z",
2548 );
2549 fs::write(&alpha, &alpha_json).expect("alpha");
2550 fs::write(&beta, &beta_json).expect("beta");
2551 fs::write(&auth_file, &alpha_json).expect("auth alpha");
2552
2553 let _auth = EnvGuard::set(&lock, "CODEX_AUTH_FILE", auth_file.to_str().expect("auth"));
2554
2555 let secret_files = vec![alpha.clone(), beta.clone()];
2556 assert_eq!(
2557 current_secret_basename(&secret_files).as_deref(),
2558 Some("alpha")
2559 );
2560
2561 fs::write(&auth_file, &beta_json).expect("auth beta");
2562 assert_eq!(
2563 current_secret_basename(&secret_files).as_deref(),
2564 Some("beta")
2565 );
2566 }
2567}