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