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");
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");
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(target_file: &Path, source: &str) -> RateLimitJsonResult {
488 match cache::read_cache_entry(target_file) {
489 Ok(entry) => RateLimitJsonResult {
490 name: secret_display_name(target_file),
491 target_file: target_file_name(target_file),
492 status: "ok".to_string(),
493 ok: true,
494 source: source.to_string(),
495 summary: Some(summary_from_cache(&entry)),
496 raw_usage: None,
497 error: None,
498 },
499 Err(err) => json_result_error(
500 target_file,
501 source,
502 "cache-read-failed",
503 err.to_string(),
504 None,
505 ),
506 }
507}
508
509fn json_result_error(
510 target_file: &Path,
511 source: &str,
512 code: &str,
513 message: String,
514 details: Option<Value>,
515) -> RateLimitJsonResult {
516 RateLimitJsonResult {
517 name: secret_display_name(target_file),
518 target_file: target_file_name(target_file),
519 status: "error".to_string(),
520 ok: false,
521 source: source.to_string(),
522 summary: None,
523 raw_usage: None,
524 error: Some(diag_output::ErrorEnvelope {
525 code: code.to_string(),
526 message,
527 details,
528 }),
529 }
530}
531
532fn secret_display_name(target_file: &Path) -> String {
533 cache::secret_name_for_target(target_file).unwrap_or_else(|| {
534 target_file
535 .file_name()
536 .and_then(|name| name.to_str())
537 .unwrap_or_default()
538 .trim_end_matches(".json")
539 .to_string()
540 })
541}
542
543fn target_file_name(target_file: &Path) -> String {
544 target_file
545 .file_name()
546 .and_then(|name| name.to_str())
547 .unwrap_or_default()
548 .to_string()
549}
550
551fn summary_from_usage(usage_json: &Value) -> Option<RateLimitSummary> {
552 let usage_data = render::parse_usage(usage_json)?;
553 let values = render::render_values(&usage_data);
554 let weekly = render::weekly_values(&values);
555 Some(RateLimitSummary {
556 non_weekly_label: weekly.non_weekly_label,
557 non_weekly_remaining: weekly.non_weekly_remaining,
558 non_weekly_reset_epoch: weekly.non_weekly_reset_epoch,
559 weekly_remaining: weekly.weekly_remaining,
560 weekly_reset_epoch: weekly.weekly_reset_epoch,
561 weekly_reset_local: render::format_epoch_local_datetime_with_offset(
562 weekly.weekly_reset_epoch,
563 ),
564 })
565}
566
567fn summary_from_cache(entry: &cache::CacheEntry) -> RateLimitSummary {
568 RateLimitSummary {
569 non_weekly_label: entry.non_weekly_label.clone(),
570 non_weekly_remaining: entry.non_weekly_remaining,
571 non_weekly_reset_epoch: entry.non_weekly_reset_epoch,
572 weekly_remaining: entry.weekly_remaining,
573 weekly_reset_epoch: entry.weekly_reset_epoch,
574 weekly_reset_local: render::format_epoch_local_datetime_with_offset(
575 entry.weekly_reset_epoch,
576 ),
577 }
578}
579
580fn redact_sensitive_json(value: &Value) -> Value {
581 match value {
582 Value::Object(map) => {
583 let mut next = serde_json::Map::new();
584 for (key, val) in map {
585 if is_sensitive_key(key) {
586 continue;
587 }
588 next.insert(key.clone(), redact_sensitive_json(val));
589 }
590 Value::Object(next)
591 }
592 Value::Array(items) => Value::Array(items.iter().map(redact_sensitive_json).collect()),
593 _ => value.clone(),
594 }
595}
596
597fn is_sensitive_key(key: &str) -> bool {
598 matches!(
599 key,
600 "access_token" | "refresh_token" | "id_token" | "authorization" | "Authorization"
601 )
602}
603
604struct AsyncEvent {
605 secret_name: String,
606 line: Option<String>,
607 rc: i32,
608 err: String,
609}
610
611struct AsyncFetchResult {
612 line: Option<String>,
613 rc: i32,
614 err: String,
615}
616
617fn run_async_mode(args: &RateLimitsOptions, debug_mode: bool) -> Result<i32> {
618 run_async_mode_impl(args, debug_mode, false)
619}
620
621fn run_async_watch_mode(args: &RateLimitsOptions, debug_mode: bool) -> Result<i32> {
622 run_async_mode_impl(args, debug_mode, true)
623}
624
625fn run_async_mode_impl(
626 args: &RateLimitsOptions,
627 debug_mode: bool,
628 watch_mode: bool,
629) -> Result<i32> {
630 if args.json {
631 eprintln!("codex-rate-limits: --async does not support --json");
632 return Ok(64);
633 }
634 if args.one_line {
635 eprintln!("codex-rate-limits: --async does not support --one-line");
636 return Ok(64);
637 }
638 if let Some(secret) = args.secret.as_deref() {
639 eprintln!(
640 "codex-rate-limits: --async does not accept positional args: {}",
641 secret
642 );
643 eprintln!(
644 "codex-rate-limits: hint: async always queries all secrets under CODEX_SECRET_DIR"
645 );
646 return Ok(64);
647 }
648 if args.clear_cache && args.cached {
649 eprintln!("codex-rate-limits: --async: -c is not compatible with --cached");
650 return Ok(64);
651 }
652
653 let jobs = args
654 .jobs
655 .as_deref()
656 .and_then(|raw| raw.parse::<i64>().ok())
657 .filter(|value| *value > 0)
658 .map(|value| value as usize)
659 .unwrap_or(5);
660
661 if args.clear_cache
662 && let Err(err) = cache::clear_starship_cache()
663 {
664 eprintln!("{err}");
665 return Ok(1);
666 }
667
668 let secret_dir = crate::paths::resolve_secret_dir().unwrap_or_default();
669 if !secret_dir.is_dir() {
670 eprintln!(
671 "codex-rate-limits-async: CODEX_SECRET_DIR not found: {}",
672 secret_dir.display()
673 );
674 return Ok(1);
675 }
676
677 let mut secret_files: Vec<PathBuf> = std::fs::read_dir(&secret_dir)?
678 .flatten()
679 .map(|entry| entry.path())
680 .filter(|path| path.extension().and_then(|s| s.to_str()) == Some("json"))
681 .collect();
682
683 if secret_files.is_empty() {
684 eprintln!(
685 "codex-rate-limits-async: no secrets found in {}",
686 secret_dir.display()
687 );
688 return Ok(1);
689 }
690
691 secret_files.sort();
692
693 let current_name = current_secret_basename(&secret_files);
694
695 if !watch_mode {
696 let round = collect_async_round(&secret_files, args.cached, args.no_refresh_auth, jobs);
697 render_all_accounts_table(
698 round.rows,
699 &round.window_labels,
700 current_name.as_deref(),
701 None,
702 );
703 emit_async_debug(debug_mode, &secret_files, &round.stderr_map);
704 return Ok(round.rc);
705 }
706
707 let mut overall_rc = 0;
708 let mut rendered_rounds = 0u64;
709 let max_rounds = watch_max_rounds_for_test();
710 let is_terminal_stdout = std::io::stdout().is_terminal();
711
712 loop {
713 let round = collect_async_round(&secret_files, args.cached, args.no_refresh_auth, jobs);
714 if round.rc != 0 {
715 overall_rc = 1;
716 }
717
718 if is_terminal_stdout {
719 print!("{ANSI_CLEAR_SCREEN_AND_HOME}");
720 }
721
722 let now_epoch = Utc::now().timestamp();
723 let update_time = format_watch_update_time(now_epoch);
724 render_all_accounts_table(
725 round.rows,
726 &round.window_labels,
727 current_name.as_deref(),
728 Some(update_time.as_str()),
729 );
730 emit_async_debug(debug_mode, &secret_files, &round.stderr_map);
731 let _ = std::io::stdout().flush();
732
733 rendered_rounds += 1;
734 if let Some(limit) = max_rounds
735 && rendered_rounds >= limit
736 {
737 break;
738 }
739
740 thread::sleep(Duration::from_secs(WATCH_INTERVAL_SECONDS));
741 }
742
743 Ok(overall_rc)
744}
745
746struct AsyncRound {
747 rc: i32,
748 rows: Vec<Row>,
749 window_labels: std::collections::HashSet<String>,
750 stderr_map: std::collections::HashMap<String, String>,
751}
752
753fn collect_async_round(
754 secret_files: &[PathBuf],
755 cached_mode: bool,
756 no_refresh_auth: bool,
757 jobs: usize,
758) -> AsyncRound {
759 let total = secret_files.len();
760 let progress = if total > 1 {
761 Some(Progress::new(
762 total as u64,
763 ProgressOptions::default()
764 .with_prefix("codex-rate-limits ")
765 .with_finish(ProgressFinish::Clear),
766 ))
767 } else {
768 None
769 };
770
771 let (tx, rx) = mpsc::channel();
772 let mut handles = Vec::new();
773 let mut index = 0usize;
774 let worker_count = jobs.min(total);
775
776 let spawn_worker = |path: PathBuf,
777 cached_mode: bool,
778 no_refresh_auth: bool,
779 tx: mpsc::Sender<AsyncEvent>|
780 -> thread::JoinHandle<()> {
781 thread::spawn(move || {
782 let secret_name = path
783 .file_name()
784 .and_then(|name| name.to_str())
785 .unwrap_or("")
786 .to_string();
787 let result = async_fetch_one_line(&path, cached_mode, no_refresh_auth, &secret_name);
788 let _ = tx.send(AsyncEvent {
789 secret_name,
790 line: result.line,
791 rc: result.rc,
792 err: result.err,
793 });
794 })
795 };
796
797 while index < total && handles.len() < worker_count {
798 let path = secret_files[index].clone();
799 index += 1;
800 handles.push(spawn_worker(path, cached_mode, no_refresh_auth, tx.clone()));
801 }
802
803 let mut events: std::collections::HashMap<String, AsyncEvent> =
804 std::collections::HashMap::new();
805 while events.len() < total {
806 let event = match rx.recv() {
807 Ok(event) => event,
808 Err(_) => break,
809 };
810 if let Some(progress) = &progress {
811 progress.set_message(event.secret_name.clone());
812 progress.inc(1);
813 }
814 events.insert(event.secret_name.clone(), event);
815
816 if index < total {
817 let path = secret_files[index].clone();
818 index += 1;
819 handles.push(spawn_worker(path, cached_mode, no_refresh_auth, tx.clone()));
820 }
821 }
822
823 if let Some(progress) = progress {
824 progress.finish_and_clear();
825 }
826
827 drop(tx);
828 for handle in handles {
829 let _ = handle.join();
830 }
831
832 let mut rc = 0;
833 let mut rows: Vec<Row> = Vec::new();
834 let mut window_labels = std::collections::HashSet::new();
835 let mut stderr_map: std::collections::HashMap<String, String> =
836 std::collections::HashMap::new();
837
838 for secret_file in secret_files {
839 let secret_name = secret_file
840 .file_name()
841 .and_then(|name| name.to_str())
842 .unwrap_or("")
843 .to_string();
844
845 let mut row = Row::empty(secret_name.trim_end_matches(".json").to_string());
846 let event = events.get(&secret_name);
847 if let Some(event) = event {
848 if !event.err.is_empty() {
849 stderr_map.insert(secret_name.clone(), event.err.clone());
850 }
851 if !cached_mode && event.rc != 0 {
852 rc = 1;
853 }
854
855 if let Some(line) = &event.line
856 && let Some(parsed) = parse_one_line_output(line)
857 {
858 row.window_label = parsed.window_label.clone();
859 row.non_weekly_remaining = parsed.non_weekly_remaining;
860 row.weekly_remaining = parsed.weekly_remaining;
861 row.weekly_reset_iso = parsed.weekly_reset_iso.clone();
862
863 if cached_mode {
864 if let Ok(cache_entry) = cache::read_cache_entry(secret_file) {
865 row.non_weekly_reset_epoch = cache_entry.non_weekly_reset_epoch;
866 row.weekly_reset_epoch = Some(cache_entry.weekly_reset_epoch);
867 }
868 } else {
869 let values = crate::json::read_json(secret_file).ok();
870 if let Some(values) = values {
871 row.non_weekly_reset_epoch = crate::json::i64_at(
872 &values,
873 &["codex_rate_limits", "non_weekly_reset_at_epoch"],
874 );
875 row.weekly_reset_epoch = crate::json::i64_at(
876 &values,
877 &["codex_rate_limits", "weekly_reset_at_epoch"],
878 );
879 }
880 if (row.non_weekly_reset_epoch.is_none() || row.weekly_reset_epoch.is_none())
881 && let Ok(cache_entry) = cache::read_cache_entry(secret_file)
882 {
883 if row.non_weekly_reset_epoch.is_none() {
884 row.non_weekly_reset_epoch = cache_entry.non_weekly_reset_epoch;
885 }
886 if row.weekly_reset_epoch.is_none() {
887 row.weekly_reset_epoch = Some(cache_entry.weekly_reset_epoch);
888 }
889 }
890 }
891
892 window_labels.insert(row.window_label.clone());
893 rows.push(row);
894 continue;
895 }
896 }
897
898 if !cached_mode {
899 rc = 1;
900 }
901 rows.push(row);
902 }
903
904 AsyncRound {
905 rc,
906 rows,
907 window_labels,
908 stderr_map,
909 }
910}
911
912fn render_all_accounts_table(
913 mut rows: Vec<Row>,
914 window_labels: &std::collections::HashSet<String>,
915 current_name: Option<&str>,
916 update_time: Option<&str>,
917) {
918 println!("\n🚦 Codex rate limits for all accounts\n");
919
920 let mut non_weekly_header = "Non-weekly".to_string();
921 let multiple_labels = window_labels.len() != 1;
922 if !multiple_labels && let Some(label) = window_labels.iter().next() {
923 non_weekly_header = label.clone();
924 }
925
926 let now_epoch = Utc::now().timestamp();
927
928 println!(
929 "{:<15} {:>8} {:>7} {:>8} {:>7} {:<18}",
930 "Name", non_weekly_header, "Left", "Weekly", "Left", "Reset"
931 );
932 println!("----------------------------------------------------------------------------");
933
934 rows.sort_by_key(|row| row.sort_key());
935
936 for row in rows {
937 let display_non_weekly = if multiple_labels && !row.window_label.is_empty() {
938 if row.non_weekly_remaining >= 0 {
939 format!("{}:{}%", row.window_label, row.non_weekly_remaining)
940 } else {
941 "-".to_string()
942 }
943 } else if row.non_weekly_remaining >= 0 {
944 format!("{}%", row.non_weekly_remaining)
945 } else {
946 "-".to_string()
947 };
948
949 let non_weekly_left = row
950 .non_weekly_reset_epoch
951 .and_then(|epoch| render::format_until_epoch_compact(epoch, now_epoch))
952 .unwrap_or_else(|| "-".to_string());
953 let weekly_left = row
954 .weekly_reset_epoch
955 .and_then(|epoch| render::format_until_epoch_compact(epoch, now_epoch))
956 .unwrap_or_else(|| "-".to_string());
957 let reset_display = row
958 .weekly_reset_epoch
959 .and_then(render::format_epoch_local_datetime_with_offset)
960 .unwrap_or_else(|| "-".to_string());
961
962 let non_weekly_display = ansi::format_percent_cell(&display_non_weekly, 8, None);
963 let weekly_display = if row.weekly_remaining >= 0 {
964 ansi::format_percent_cell(&format!("{}%", row.weekly_remaining), 8, None)
965 } else {
966 ansi::format_percent_cell("-", 8, None)
967 };
968
969 let is_current = current_name == Some(row.name.as_str());
970 let name_display = ansi::format_name_cell(&row.name, 15, is_current, None);
971
972 println!(
973 "{} {} {:>7} {} {:>7} {:<18}",
974 name_display,
975 non_weekly_display,
976 non_weekly_left,
977 weekly_display,
978 weekly_left,
979 reset_display
980 );
981 }
982
983 if let Some(update_time) = update_time {
984 println!();
985 println!("Last update: {update_time}");
986 }
987}
988
989fn emit_async_debug(
990 debug_mode: bool,
991 secret_files: &[PathBuf],
992 stderr_map: &std::collections::HashMap<String, String>,
993) {
994 if !debug_mode {
995 return;
996 }
997
998 let mut printed = false;
999 for secret_file in secret_files {
1000 let secret_name = secret_file
1001 .file_name()
1002 .and_then(|name| name.to_str())
1003 .unwrap_or("")
1004 .to_string();
1005 if let Some(err) = stderr_map.get(&secret_name) {
1006 if err.is_empty() {
1007 continue;
1008 }
1009 if !printed {
1010 printed = true;
1011 eprintln!();
1012 eprintln!("codex-rate-limits-async: per-account stderr (captured):");
1013 }
1014 eprintln!("---- {} ----", secret_name);
1015 eprintln!("{err}");
1016 }
1017 }
1018}
1019
1020fn watch_max_rounds_for_test() -> Option<u64> {
1021 std::env::var("CODEX_RATE_LIMITS_WATCH_MAX_ROUNDS")
1022 .ok()
1023 .and_then(|raw| raw.parse::<u64>().ok())
1024 .filter(|value| *value > 0)
1025}
1026
1027fn format_watch_update_time(now_epoch: i64) -> String {
1028 render::format_epoch_local(now_epoch, "%Y-%m-%d %H:%M:%S %:z")
1029 .unwrap_or_else(|| now_epoch.to_string())
1030}
1031
1032fn async_fetch_one_line(
1033 target_file: &Path,
1034 cached_mode: bool,
1035 no_refresh_auth: bool,
1036 secret_name: &str,
1037) -> AsyncFetchResult {
1038 if cached_mode {
1039 return fetch_one_line_cached(target_file);
1040 }
1041
1042 let mut attempt = 1;
1043 let max_attempts = 2;
1044 let mut network_err: Option<String> = None;
1045
1046 let mut result = fetch_one_line_network(target_file, no_refresh_auth);
1047 if !result.err.is_empty() {
1048 network_err = Some(result.err.clone());
1049 }
1050
1051 while attempt < max_attempts && result.rc == 3 {
1052 thread::sleep(Duration::from_millis(250));
1053 let next = fetch_one_line_network(target_file, no_refresh_auth);
1054 if !next.err.is_empty() {
1055 network_err = Some(next.err.clone());
1056 }
1057 result = next;
1058 attempt += 1;
1059 if result.rc != 3 {
1060 break;
1061 }
1062 }
1063
1064 let mut errors: Vec<String> = Vec::new();
1065 if let Some(err) = network_err {
1066 errors.push(err);
1067 }
1068
1069 let missing_line = result
1070 .line
1071 .as_ref()
1072 .map(|line| line.trim().is_empty())
1073 .unwrap_or(true);
1074
1075 if result.rc != 0 || missing_line {
1076 let cached = fetch_one_line_cached(target_file);
1077 if !cached.err.is_empty() {
1078 errors.push(cached.err.clone());
1079 }
1080 if cached.rc == 0
1081 && cached
1082 .line
1083 .as_ref()
1084 .map(|line| !line.trim().is_empty())
1085 .unwrap_or(false)
1086 {
1087 if result.rc != 0 {
1088 errors.push(format!(
1089 "codex-rate-limits-async: falling back to cache for {} (rc={})",
1090 secret_name, result.rc
1091 ));
1092 }
1093 result = AsyncFetchResult {
1094 line: cached.line,
1095 rc: 0,
1096 err: String::new(),
1097 };
1098 }
1099 }
1100
1101 let line = result.line.map(normalize_one_line);
1102 let err = errors.join("\n");
1103 AsyncFetchResult {
1104 line,
1105 rc: result.rc,
1106 err,
1107 }
1108}
1109
1110fn fetch_one_line_network(target_file: &Path, no_refresh_auth: bool) -> AsyncFetchResult {
1111 if !target_file.is_file() {
1112 return AsyncFetchResult {
1113 line: None,
1114 rc: 1,
1115 err: format!("codex-rate-limits: {} not found", target_file.display()),
1116 };
1117 }
1118
1119 let base_url = std::env::var("CODEX_CHATGPT_BASE_URL")
1120 .unwrap_or_else(|_| "https://chatgpt.com/backend-api/".to_string());
1121 let connect_timeout = env_timeout("CODEX_RATE_LIMITS_CURL_CONNECT_TIMEOUT_SECONDS", 2);
1122 let max_time = env_timeout("CODEX_RATE_LIMITS_CURL_MAX_TIME_SECONDS", 8);
1123
1124 let usage_request = UsageRequest {
1125 target_file: target_file.to_path_buf(),
1126 refresh_on_401: !no_refresh_auth,
1127 base_url,
1128 connect_timeout_seconds: connect_timeout,
1129 max_time_seconds: max_time,
1130 };
1131
1132 let usage = match fetch_usage(&usage_request) {
1133 Ok(value) => value,
1134 Err(err) => {
1135 let msg = err.to_string();
1136 if msg.contains("missing access_token") {
1137 return AsyncFetchResult {
1138 line: None,
1139 rc: 2,
1140 err: format!(
1141 "codex-rate-limits: missing access_token in {}",
1142 target_file.display()
1143 ),
1144 };
1145 }
1146 return AsyncFetchResult {
1147 line: None,
1148 rc: 3,
1149 err: msg,
1150 };
1151 }
1152 };
1153
1154 if let Err(err) = writeback::write_weekly(target_file, &usage.json) {
1155 return AsyncFetchResult {
1156 line: None,
1157 rc: 4,
1158 err: err.to_string(),
1159 };
1160 }
1161
1162 if is_auth_file(target_file) {
1163 match sync_auth_silent() {
1164 Ok((sync_rc, sync_err)) => {
1165 if sync_rc != 0 {
1166 return AsyncFetchResult {
1167 line: None,
1168 rc: 5,
1169 err: sync_err.unwrap_or_default(),
1170 };
1171 }
1172 }
1173 Err(_) => {
1174 return AsyncFetchResult {
1175 line: None,
1176 rc: 1,
1177 err: String::new(),
1178 };
1179 }
1180 }
1181 }
1182
1183 let usage_data = match render::parse_usage(&usage.json) {
1184 Some(value) => value,
1185 None => {
1186 return AsyncFetchResult {
1187 line: None,
1188 rc: 3,
1189 err: "codex-rate-limits: invalid usage payload".to_string(),
1190 };
1191 }
1192 };
1193
1194 let values = render::render_values(&usage_data);
1195 let weekly = render::weekly_values(&values);
1196
1197 let fetched_at_epoch = Utc::now().timestamp();
1198 if fetched_at_epoch > 0 {
1199 let _ = cache::write_starship_cache(
1200 target_file,
1201 fetched_at_epoch,
1202 &weekly.non_weekly_label,
1203 weekly.non_weekly_remaining,
1204 weekly.weekly_remaining,
1205 weekly.weekly_reset_epoch,
1206 weekly.non_weekly_reset_epoch,
1207 );
1208 }
1209
1210 AsyncFetchResult {
1211 line: Some(format_one_line_output(
1212 target_file,
1213 &weekly.non_weekly_label,
1214 weekly.non_weekly_remaining,
1215 weekly.weekly_remaining,
1216 weekly.weekly_reset_epoch,
1217 )),
1218 rc: 0,
1219 err: String::new(),
1220 }
1221}
1222
1223fn fetch_one_line_cached(target_file: &Path) -> AsyncFetchResult {
1224 match cache::read_cache_entry(target_file) {
1225 Ok(entry) => AsyncFetchResult {
1226 line: Some(format_one_line_output(
1227 target_file,
1228 &entry.non_weekly_label,
1229 entry.non_weekly_remaining,
1230 entry.weekly_remaining,
1231 entry.weekly_reset_epoch,
1232 )),
1233 rc: 0,
1234 err: String::new(),
1235 },
1236 Err(err) => AsyncFetchResult {
1237 line: None,
1238 rc: 1,
1239 err: err.to_string(),
1240 },
1241 }
1242}
1243
1244fn format_one_line_output(
1245 target_file: &Path,
1246 non_weekly_label: &str,
1247 non_weekly_remaining: i64,
1248 weekly_remaining: i64,
1249 weekly_reset_epoch: i64,
1250) -> String {
1251 let prefix = cache::secret_name_for_target(target_file)
1252 .map(|name| format!("{name} "))
1253 .unwrap_or_default();
1254 let weekly_reset_iso =
1255 render::format_epoch_local_datetime(weekly_reset_epoch).unwrap_or_else(|| "?".to_string());
1256
1257 format!(
1258 "{}{}:{}% W:{}% {}",
1259 prefix, non_weekly_label, non_weekly_remaining, weekly_remaining, weekly_reset_iso
1260 )
1261}
1262
1263fn normalize_one_line(line: String) -> String {
1264 line.replace(['\n', '\r', '\t'], " ")
1265}
1266
1267fn sync_auth_silent() -> Result<(i32, Option<String>)> {
1268 let auth_file = match crate::paths::resolve_auth_file() {
1269 Some(path) => path,
1270 None => return Ok((0, None)),
1271 };
1272
1273 if !auth_file.is_file() {
1274 return Ok((0, None));
1275 }
1276
1277 let auth_key = match auth::identity_key_from_auth_file(&auth_file) {
1278 Ok(Some(key)) => key,
1279 _ => return Ok((0, None)),
1280 };
1281
1282 let auth_last_refresh = auth::last_refresh_from_auth_file(&auth_file).unwrap_or(None);
1283 let auth_hash = match crate::fs::sha256_file(&auth_file) {
1284 Ok(hash) => hash,
1285 Err(_) => {
1286 return Ok((
1287 1,
1288 Some(format!("codex: failed to hash {}", auth_file.display())),
1289 ));
1290 }
1291 };
1292
1293 if let Some(secret_dir) = crate::paths::resolve_secret_dir()
1294 && let Ok(entries) = std::fs::read_dir(&secret_dir)
1295 {
1296 for entry in entries.flatten() {
1297 let path = entry.path();
1298 if path.extension().and_then(|s| s.to_str()) != Some("json") {
1299 continue;
1300 }
1301 let candidate_key = match auth::identity_key_from_auth_file(&path) {
1302 Ok(Some(key)) => key,
1303 _ => continue,
1304 };
1305 if candidate_key != auth_key {
1306 continue;
1307 }
1308
1309 let secret_hash = match crate::fs::sha256_file(&path) {
1310 Ok(hash) => hash,
1311 Err(_) => {
1312 return Ok((1, Some(format!("codex: failed to hash {}", path.display()))));
1313 }
1314 };
1315 if secret_hash == auth_hash {
1316 continue;
1317 }
1318
1319 let contents = std::fs::read(&auth_file)?;
1320 crate::fs::write_atomic(&path, &contents, crate::fs::SECRET_FILE_MODE)?;
1321
1322 let timestamp_path = secret_timestamp_path(&path)?;
1323 crate::fs::write_timestamp(×tamp_path, auth_last_refresh.as_deref())?;
1324 }
1325 }
1326
1327 let auth_timestamp = secret_timestamp_path(&auth_file)?;
1328 crate::fs::write_timestamp(&auth_timestamp, auth_last_refresh.as_deref())?;
1329
1330 Ok((0, None))
1331}
1332
1333fn maybe_sync_all_mode_auth_silent(debug_mode: bool) {
1334 match sync_auth_silent() {
1335 Ok((0, _)) => {}
1336 Ok((_, sync_err)) => {
1337 if debug_mode
1338 && let Some(message) = sync_err
1339 && !message.trim().is_empty()
1340 {
1341 eprintln!("{message}");
1342 }
1343 }
1344 Err(err) => {
1345 if debug_mode {
1346 eprintln!("codex-rate-limits: failed to sync auth and secrets: {err}");
1347 }
1348 }
1349 }
1350}
1351
1352fn secret_timestamp_path(target_file: &Path) -> Result<PathBuf> {
1353 let cache_dir = crate::paths::resolve_secret_cache_dir()
1354 .ok_or_else(|| anyhow::anyhow!("CODEX_SECRET_CACHE_DIR not resolved"))?;
1355 let name = target_file
1356 .file_name()
1357 .and_then(|name| name.to_str())
1358 .unwrap_or("auth.json");
1359 Ok(cache_dir.join(format!("{name}.timestamp")))
1360}
1361
1362fn run_all_mode(args: &RateLimitsOptions, cached_mode: bool, debug_mode: bool) -> Result<i32> {
1363 let secret_dir = crate::paths::resolve_secret_dir().unwrap_or_default();
1364 if !secret_dir.is_dir() {
1365 eprintln!(
1366 "codex-rate-limits: CODEX_SECRET_DIR not found: {}",
1367 secret_dir.display()
1368 );
1369 return Ok(1);
1370 }
1371
1372 let mut secret_files: Vec<PathBuf> = std::fs::read_dir(&secret_dir)?
1373 .flatten()
1374 .map(|entry| entry.path())
1375 .filter(|path| path.extension().and_then(|s| s.to_str()) == Some("json"))
1376 .collect();
1377
1378 if secret_files.is_empty() {
1379 eprintln!(
1380 "codex-rate-limits: no secrets found in {}",
1381 secret_dir.display()
1382 );
1383 return Ok(1);
1384 }
1385
1386 secret_files.sort();
1387
1388 let current_name = current_secret_basename(&secret_files);
1389
1390 let total = secret_files.len();
1391 let progress = if total > 1 {
1392 Some(Progress::new(
1393 total as u64,
1394 ProgressOptions::default()
1395 .with_prefix("codex-rate-limits ")
1396 .with_finish(ProgressFinish::Clear),
1397 ))
1398 } else {
1399 None
1400 };
1401
1402 let mut rc = 0;
1403 let mut rows: Vec<Row> = Vec::new();
1404 let mut window_labels = std::collections::HashSet::new();
1405
1406 for secret_file in secret_files {
1407 let secret_name = secret_file
1408 .file_name()
1409 .and_then(|name| name.to_str())
1410 .unwrap_or("")
1411 .to_string();
1412 if let Some(progress) = &progress {
1413 progress.set_message(secret_name.clone());
1414 }
1415
1416 let mut row = Row::empty(secret_name.trim_end_matches(".json").to_string());
1417 let output =
1418 match single_one_line(&secret_file, cached_mode, args.no_refresh_auth, debug_mode) {
1419 Ok(Some(line)) => line,
1420 Ok(None) => String::new(),
1421 Err(_) => String::new(),
1422 };
1423
1424 if output.is_empty() {
1425 if !cached_mode {
1426 rc = 1;
1427 }
1428 rows.push(row);
1429 continue;
1430 }
1431
1432 if let Some(parsed) = parse_one_line_output(&output) {
1433 row.window_label = parsed.window_label.clone();
1434 row.non_weekly_remaining = parsed.non_weekly_remaining;
1435 row.weekly_remaining = parsed.weekly_remaining;
1436 row.weekly_reset_iso = parsed.weekly_reset_iso.clone();
1437
1438 if cached_mode {
1439 if let Ok(cache_entry) = cache::read_cache_entry(&secret_file) {
1440 row.non_weekly_reset_epoch = cache_entry.non_weekly_reset_epoch;
1441 row.weekly_reset_epoch = Some(cache_entry.weekly_reset_epoch);
1442 }
1443 } else {
1444 let values = crate::json::read_json(&secret_file).ok();
1445 if let Some(values) = values {
1446 row.non_weekly_reset_epoch = crate::json::i64_at(
1447 &values,
1448 &["codex_rate_limits", "non_weekly_reset_at_epoch"],
1449 );
1450 row.weekly_reset_epoch = crate::json::i64_at(
1451 &values,
1452 &["codex_rate_limits", "weekly_reset_at_epoch"],
1453 );
1454 }
1455 }
1456
1457 window_labels.insert(row.window_label.clone());
1458 rows.push(row);
1459 } else {
1460 if !cached_mode {
1461 rc = 1;
1462 }
1463 rows.push(row);
1464 }
1465
1466 if let Some(progress) = &progress {
1467 progress.inc(1);
1468 }
1469 }
1470
1471 if let Some(progress) = progress {
1472 progress.finish_and_clear();
1473 }
1474
1475 println!("\n🚦 Codex rate limits for all accounts\n");
1476
1477 let mut non_weekly_header = "Non-weekly".to_string();
1478 let multiple_labels = window_labels.len() != 1;
1479 if !multiple_labels && let Some(label) = window_labels.iter().next() {
1480 non_weekly_header = label.clone();
1481 }
1482
1483 let now_epoch = Utc::now().timestamp();
1484
1485 println!(
1486 "{:<15} {:>8} {:>7} {:>8} {:>7} {:<18}",
1487 "Name", non_weekly_header, "Left", "Weekly", "Left", "Reset"
1488 );
1489 println!("----------------------------------------------------------------------------");
1490
1491 rows.sort_by_key(|row| row.sort_key());
1492
1493 for row in rows {
1494 let display_non_weekly = if multiple_labels && !row.window_label.is_empty() {
1495 if row.non_weekly_remaining >= 0 {
1496 format!("{}:{}%", row.window_label, row.non_weekly_remaining)
1497 } else {
1498 "-".to_string()
1499 }
1500 } else if row.non_weekly_remaining >= 0 {
1501 format!("{}%", row.non_weekly_remaining)
1502 } else {
1503 "-".to_string()
1504 };
1505
1506 let non_weekly_left = row
1507 .non_weekly_reset_epoch
1508 .and_then(|epoch| render::format_until_epoch_compact(epoch, now_epoch))
1509 .unwrap_or_else(|| "-".to_string());
1510 let weekly_left = row
1511 .weekly_reset_epoch
1512 .and_then(|epoch| render::format_until_epoch_compact(epoch, now_epoch))
1513 .unwrap_or_else(|| "-".to_string());
1514 let reset_display = row
1515 .weekly_reset_epoch
1516 .and_then(render::format_epoch_local_datetime_with_offset)
1517 .unwrap_or_else(|| "-".to_string());
1518
1519 let non_weekly_display = ansi::format_percent_cell(&display_non_weekly, 8, None);
1520 let weekly_display = if row.weekly_remaining >= 0 {
1521 ansi::format_percent_cell(&format!("{}%", row.weekly_remaining), 8, None)
1522 } else {
1523 ansi::format_percent_cell("-", 8, None)
1524 };
1525
1526 let is_current = current_name.as_deref() == Some(row.name.as_str());
1527 let name_display = ansi::format_name_cell(&row.name, 15, is_current, None);
1528
1529 println!(
1530 "{} {} {:>7} {} {:>7} {:<18}",
1531 name_display,
1532 non_weekly_display,
1533 non_weekly_left,
1534 weekly_display,
1535 weekly_left,
1536 reset_display
1537 );
1538 }
1539
1540 Ok(rc)
1541}
1542
1543fn current_secret_basename(secret_files: &[PathBuf]) -> Option<String> {
1544 let auth_file = crate::paths::resolve_auth_file()?;
1545 if !auth_file.is_file() {
1546 return None;
1547 }
1548
1549 let auth_key = auth::identity_key_from_auth_file(&auth_file).ok().flatten();
1550 let auth_hash = crate::fs::sha256_file(&auth_file).ok();
1551
1552 if let Some(auth_hash) = auth_hash.as_deref() {
1553 for secret_file in secret_files {
1554 if let Ok(secret_hash) = crate::fs::sha256_file(secret_file)
1555 && secret_hash == auth_hash
1556 && let Some(name) = secret_file.file_name().and_then(|name| name.to_str())
1557 {
1558 return Some(name.trim_end_matches(".json").to_string());
1559 }
1560 }
1561 }
1562
1563 if let Some(auth_key) = auth_key.as_deref() {
1564 for secret_file in secret_files {
1565 if let Ok(Some(candidate_key)) = auth::identity_key_from_auth_file(secret_file)
1566 && candidate_key == auth_key
1567 && let Some(name) = secret_file.file_name().and_then(|name| name.to_str())
1568 {
1569 return Some(name.trim_end_matches(".json").to_string());
1570 }
1571 }
1572 }
1573
1574 None
1575}
1576
1577fn run_single_mode(
1578 args: &RateLimitsOptions,
1579 cached_mode: bool,
1580 one_line: bool,
1581 output_json: bool,
1582) -> Result<i32> {
1583 let target_file = match resolve_target(args.secret.as_deref()) {
1584 Ok(path) => path,
1585 Err(code) => return Ok(code),
1586 };
1587
1588 if !target_file.is_file() {
1589 if output_json {
1590 diag_output::emit_error(
1591 DIAG_SCHEMA_VERSION,
1592 DIAG_COMMAND,
1593 "target-not-found",
1594 format!("codex-rate-limits: {} not found", target_file.display()),
1595 Some(serde_json::json!({
1596 "target_file": target_file.display().to_string(),
1597 })),
1598 )?;
1599 } else {
1600 eprintln!("codex-rate-limits: {} not found", target_file.display());
1601 }
1602 return Ok(1);
1603 }
1604
1605 if cached_mode {
1606 match cache::read_cache_entry(&target_file) {
1607 Ok(entry) => {
1608 let weekly_reset_iso =
1609 render::format_epoch_local_datetime(entry.weekly_reset_epoch)
1610 .unwrap_or_else(|| "?".to_string());
1611 let prefix = cache::secret_name_for_target(&target_file)
1612 .map(|name| format!("{name} "))
1613 .unwrap_or_default();
1614 println!(
1615 "{}{}:{}% W:{}% {}",
1616 prefix,
1617 entry.non_weekly_label,
1618 entry.non_weekly_remaining,
1619 entry.weekly_remaining,
1620 weekly_reset_iso
1621 );
1622 return Ok(0);
1623 }
1624 Err(err) => {
1625 eprintln!("{err}");
1626 return Ok(1);
1627 }
1628 }
1629 }
1630
1631 let base_url = std::env::var("CODEX_CHATGPT_BASE_URL")
1632 .unwrap_or_else(|_| "https://chatgpt.com/backend-api/".to_string());
1633 let connect_timeout = env_timeout("CODEX_RATE_LIMITS_CURL_CONNECT_TIMEOUT_SECONDS", 2);
1634 let max_time = env_timeout("CODEX_RATE_LIMITS_CURL_MAX_TIME_SECONDS", 8);
1635
1636 let usage_request = UsageRequest {
1637 target_file: target_file.clone(),
1638 refresh_on_401: !args.no_refresh_auth,
1639 base_url,
1640 connect_timeout_seconds: connect_timeout,
1641 max_time_seconds: max_time,
1642 };
1643
1644 let usage = match fetch_usage(&usage_request) {
1645 Ok(value) => value,
1646 Err(err) => {
1647 let msg = err.to_string();
1648 if msg.contains("missing access_token") {
1649 if output_json {
1650 diag_output::emit_error(
1651 DIAG_SCHEMA_VERSION,
1652 DIAG_COMMAND,
1653 "missing-access-token",
1654 format!(
1655 "codex-rate-limits: missing access_token in {}",
1656 target_file.display()
1657 ),
1658 Some(serde_json::json!({
1659 "target_file": target_file.display().to_string(),
1660 })),
1661 )?;
1662 } else {
1663 eprintln!(
1664 "codex-rate-limits: missing access_token in {}",
1665 target_file.display()
1666 );
1667 }
1668 return Ok(2);
1669 }
1670 if output_json {
1671 diag_output::emit_error(
1672 DIAG_SCHEMA_VERSION,
1673 DIAG_COMMAND,
1674 "request-failed",
1675 msg,
1676 Some(serde_json::json!({
1677 "target_file": target_file.display().to_string(),
1678 })),
1679 )?;
1680 } else {
1681 eprintln!("{msg}");
1682 }
1683 return Ok(3);
1684 }
1685 };
1686
1687 if let Err(err) = writeback::write_weekly(&target_file, &usage.json) {
1688 if output_json {
1689 diag_output::emit_error(
1690 DIAG_SCHEMA_VERSION,
1691 DIAG_COMMAND,
1692 "writeback-failed",
1693 err.to_string(),
1694 Some(serde_json::json!({
1695 "target_file": target_file.display().to_string(),
1696 })),
1697 )?;
1698 } else {
1699 eprintln!("{err}");
1700 }
1701 return Ok(4);
1702 }
1703
1704 if is_auth_file(&target_file) {
1705 let sync_rc = auth::sync::run_with_json(false)?;
1706 if sync_rc != 0 {
1707 if output_json {
1708 diag_output::emit_error(
1709 DIAG_SCHEMA_VERSION,
1710 DIAG_COMMAND,
1711 "sync-failed",
1712 "codex-rate-limits: failed to sync auth file",
1713 Some(serde_json::json!({
1714 "target_file": target_file.display().to_string(),
1715 })),
1716 )?;
1717 }
1718 return Ok(5);
1719 }
1720 }
1721
1722 let usage_data = match render::parse_usage(&usage.json) {
1723 Some(value) => value,
1724 None => {
1725 if output_json {
1726 diag_output::emit_error(
1727 DIAG_SCHEMA_VERSION,
1728 DIAG_COMMAND,
1729 "invalid-usage-payload",
1730 "codex-rate-limits: invalid usage payload",
1731 Some(serde_json::json!({
1732 "target_file": target_file.display().to_string(),
1733 "raw_usage": redact_sensitive_json(&usage.json),
1734 })),
1735 )?;
1736 } else {
1737 eprintln!("codex-rate-limits: invalid usage payload");
1738 }
1739 return Ok(3);
1740 }
1741 };
1742
1743 let values = render::render_values(&usage_data);
1744 let weekly = render::weekly_values(&values);
1745
1746 let fetched_at_epoch = Utc::now().timestamp();
1747 if fetched_at_epoch > 0 {
1748 let _ = cache::write_starship_cache(
1749 &target_file,
1750 fetched_at_epoch,
1751 &weekly.non_weekly_label,
1752 weekly.non_weekly_remaining,
1753 weekly.weekly_remaining,
1754 weekly.weekly_reset_epoch,
1755 weekly.non_weekly_reset_epoch,
1756 );
1757 }
1758
1759 if output_json {
1760 let result = RateLimitJsonResult {
1761 name: secret_display_name(&target_file),
1762 target_file: target_file_name(&target_file),
1763 status: "ok".to_string(),
1764 ok: true,
1765 source: "network".to_string(),
1766 summary: Some(RateLimitSummary {
1767 non_weekly_label: weekly.non_weekly_label,
1768 non_weekly_remaining: weekly.non_weekly_remaining,
1769 non_weekly_reset_epoch: weekly.non_weekly_reset_epoch,
1770 weekly_remaining: weekly.weekly_remaining,
1771 weekly_reset_epoch: weekly.weekly_reset_epoch,
1772 weekly_reset_local: render::format_epoch_local_datetime_with_offset(
1773 weekly.weekly_reset_epoch,
1774 ),
1775 }),
1776 raw_usage: Some(redact_sensitive_json(&usage.json)),
1777 error: None,
1778 };
1779 diag_output::emit_json(&RateLimitSingleEnvelope {
1780 schema_version: DIAG_SCHEMA_VERSION.to_string(),
1781 command: DIAG_COMMAND.to_string(),
1782 mode: "single".to_string(),
1783 ok: true,
1784 result,
1785 })?;
1786 return Ok(0);
1787 }
1788
1789 if one_line {
1790 let prefix = cache::secret_name_for_target(&target_file)
1791 .map(|name| format!("{name} "))
1792 .unwrap_or_default();
1793 let weekly_reset_iso = render::format_epoch_local_datetime(weekly.weekly_reset_epoch)
1794 .unwrap_or_else(|| "?".to_string());
1795
1796 println!(
1797 "{}{}:{}% W:{}% {}",
1798 prefix,
1799 weekly.non_weekly_label,
1800 weekly.non_weekly_remaining,
1801 weekly.weekly_remaining,
1802 weekly_reset_iso
1803 );
1804 return Ok(0);
1805 }
1806
1807 println!("Rate limits remaining");
1808 let primary_reset = render::format_epoch_local_datetime(values.primary_reset_epoch)
1809 .unwrap_or_else(|| "?".to_string());
1810 let secondary_reset = render::format_epoch_local_datetime(values.secondary_reset_epoch)
1811 .unwrap_or_else(|| "?".to_string());
1812
1813 println!(
1814 "{} {}% • {}",
1815 values.primary_label, values.primary_remaining, primary_reset
1816 );
1817 println!(
1818 "{} {}% • {}",
1819 values.secondary_label, values.secondary_remaining, secondary_reset
1820 );
1821
1822 Ok(0)
1823}
1824
1825fn single_one_line(
1826 target_file: &Path,
1827 cached_mode: bool,
1828 no_refresh_auth: bool,
1829 debug_mode: bool,
1830) -> Result<Option<String>> {
1831 if !target_file.is_file() {
1832 if debug_mode {
1833 eprintln!("codex-rate-limits: {} not found", target_file.display());
1834 }
1835 return Ok(None);
1836 }
1837
1838 if cached_mode {
1839 return match cache::read_cache_entry(target_file) {
1840 Ok(entry) => {
1841 let weekly_reset_iso =
1842 render::format_epoch_local_datetime(entry.weekly_reset_epoch)
1843 .unwrap_or_else(|| "?".to_string());
1844 let prefix = cache::secret_name_for_target(target_file)
1845 .map(|name| format!("{name} "))
1846 .unwrap_or_default();
1847 Ok(Some(format!(
1848 "{}{}:{}% W:{}% {}",
1849 prefix,
1850 entry.non_weekly_label,
1851 entry.non_weekly_remaining,
1852 entry.weekly_remaining,
1853 weekly_reset_iso
1854 )))
1855 }
1856 Err(err) => {
1857 if debug_mode {
1858 eprintln!("{err}");
1859 }
1860 Ok(None)
1861 }
1862 };
1863 }
1864
1865 let base_url = std::env::var("CODEX_CHATGPT_BASE_URL")
1866 .unwrap_or_else(|_| "https://chatgpt.com/backend-api/".to_string());
1867 let connect_timeout = env_timeout("CODEX_RATE_LIMITS_CURL_CONNECT_TIMEOUT_SECONDS", 2);
1868 let max_time = env_timeout("CODEX_RATE_LIMITS_CURL_MAX_TIME_SECONDS", 8);
1869
1870 let usage_request = UsageRequest {
1871 target_file: target_file.to_path_buf(),
1872 refresh_on_401: !no_refresh_auth,
1873 base_url,
1874 connect_timeout_seconds: connect_timeout,
1875 max_time_seconds: max_time,
1876 };
1877
1878 let usage = match fetch_usage(&usage_request) {
1879 Ok(value) => value,
1880 Err(err) => {
1881 if debug_mode {
1882 eprintln!("{err}");
1883 }
1884 return Ok(None);
1885 }
1886 };
1887
1888 let _ = writeback::write_weekly(target_file, &usage.json);
1889 if is_auth_file(target_file) {
1890 let _ = auth::sync::run();
1891 }
1892
1893 let usage_data = match render::parse_usage(&usage.json) {
1894 Some(value) => value,
1895 None => return Ok(None),
1896 };
1897 let values = render::render_values(&usage_data);
1898 let weekly = render::weekly_values(&values);
1899 let prefix = cache::secret_name_for_target(target_file)
1900 .map(|name| format!("{name} "))
1901 .unwrap_or_default();
1902 let weekly_reset_iso = render::format_epoch_local_datetime(weekly.weekly_reset_epoch)
1903 .unwrap_or_else(|| "?".to_string());
1904
1905 Ok(Some(format!(
1906 "{}{}:{}% W:{}% {}",
1907 prefix,
1908 weekly.non_weekly_label,
1909 weekly.non_weekly_remaining,
1910 weekly.weekly_remaining,
1911 weekly_reset_iso
1912 )))
1913}
1914
1915fn resolve_target(secret: Option<&str>) -> std::result::Result<PathBuf, i32> {
1916 if let Some(secret_name) = secret {
1917 if secret_name.is_empty() || secret_name.contains('/') || secret_name.contains("..") {
1918 eprintln!("codex-rate-limits: invalid secret file name: {secret_name}");
1919 return Err(64);
1920 }
1921 let secret_dir = crate::paths::resolve_secret_dir().unwrap_or_default();
1922 return Ok(secret_dir.join(secret_name));
1923 }
1924
1925 if let Some(auth_file) = crate::paths::resolve_auth_file() {
1926 return Ok(auth_file);
1927 }
1928
1929 Err(1)
1930}
1931
1932fn is_auth_file(target_file: &Path) -> bool {
1933 if let Some(auth_file) = crate::paths::resolve_auth_file() {
1934 return auth_file == target_file;
1935 }
1936 false
1937}
1938
1939fn env_timeout(key: &str, default: u64) -> u64 {
1940 std::env::var(key)
1941 .ok()
1942 .and_then(|raw| raw.parse::<u64>().ok())
1943 .unwrap_or(default)
1944}
1945
1946struct Row {
1947 name: String,
1948 window_label: String,
1949 non_weekly_remaining: i64,
1950 non_weekly_reset_epoch: Option<i64>,
1951 weekly_remaining: i64,
1952 weekly_reset_epoch: Option<i64>,
1953 weekly_reset_iso: String,
1954}
1955
1956impl Row {
1957 fn empty(name: String) -> Self {
1958 Self {
1959 name,
1960 window_label: String::new(),
1961 non_weekly_remaining: -1,
1962 non_weekly_reset_epoch: None,
1963 weekly_remaining: -1,
1964 weekly_reset_epoch: None,
1965 weekly_reset_iso: String::new(),
1966 }
1967 }
1968
1969 fn sort_key(&self) -> (i32, i64, String) {
1970 if let Some(epoch) = self.weekly_reset_epoch {
1971 (0, epoch, self.name.clone())
1972 } else {
1973 (1, i64::MAX, self.name.clone())
1974 }
1975 }
1976}
1977
1978struct ParsedOneLine {
1979 window_label: String,
1980 non_weekly_remaining: i64,
1981 weekly_remaining: i64,
1982 weekly_reset_iso: String,
1983}
1984
1985fn parse_one_line_output(line: &str) -> Option<ParsedOneLine> {
1986 let parts: Vec<&str> = line.split_whitespace().collect();
1987 if parts.len() < 3 {
1988 return None;
1989 }
1990
1991 fn parse_fields(
1992 window_field: &str,
1993 weekly_field: &str,
1994 reset_iso: String,
1995 ) -> Option<ParsedOneLine> {
1996 let window_label = window_field
1997 .split(':')
1998 .next()?
1999 .trim_matches('"')
2000 .to_string();
2001 let non_weekly_remaining = window_field.split(':').nth(1)?;
2002 let non_weekly_remaining = non_weekly_remaining
2003 .trim_end_matches('%')
2004 .parse::<i64>()
2005 .ok()?;
2006
2007 let weekly_remaining = weekly_field.trim_start_matches("W:").trim_end_matches('%');
2008 let weekly_remaining = weekly_remaining.parse::<i64>().ok()?;
2009
2010 Some(ParsedOneLine {
2011 window_label,
2012 non_weekly_remaining,
2013 weekly_remaining,
2014 weekly_reset_iso: reset_iso,
2015 })
2016 }
2017
2018 let len = parts.len();
2019 let window_field = parts[len - 3];
2020 let weekly_field = parts[len - 2];
2021 let reset_iso = parts[len - 1].to_string();
2022
2023 if let Some(parsed) = parse_fields(window_field, weekly_field, reset_iso) {
2024 return Some(parsed);
2025 }
2026
2027 if len < 4 {
2028 return None;
2029 }
2030
2031 parse_fields(
2032 parts[len - 4],
2033 parts[len - 3],
2034 format!("{} {}", parts[len - 2], parts[len - 1]),
2035 )
2036}
2037
2038#[cfg(test)]
2039mod tests {
2040 use super::{
2041 async_fetch_one_line, cache, collect_json_from_cache, collect_secret_files, env_timeout,
2042 fetch_one_line_cached, is_auth_file, normalize_one_line, parse_one_line_output,
2043 redact_sensitive_json, resolve_target, secret_display_name, single_one_line,
2044 sync_auth_silent, target_file_name,
2045 };
2046 use nils_test_support::{EnvGuard, GlobalStateLock};
2047 use serde_json::json;
2048 use std::fs;
2049 use std::path::Path;
2050
2051 const HEADER: &str = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0";
2052 const PAYLOAD_ALPHA: &str = "eyJzdWIiOiJ1c2VyXzEyMyIsImVtYWlsIjoiYWxwaGFAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF91c2VyX2lkIjoidXNlcl8xMjMiLCJlbWFpbCI6ImFscGhhQGV4YW1wbGUuY29tIn19";
2053 const PAYLOAD_BETA: &str = "eyJzdWIiOiJ1c2VyXzQ1NiIsImVtYWlsIjoiYmV0YUBleGFtcGxlLmNvbSIsImh0dHBzOi8vYXBpLm9wZW5haS5jb20vYXV0aCI6eyJjaGF0Z3B0X3VzZXJfaWQiOiJ1c2VyXzQ1NiIsImVtYWlsIjoiYmV0YUBleGFtcGxlLmNvbSJ9fQ";
2054
2055 fn token(payload: &str) -> String {
2056 format!("{HEADER}.{payload}.sig")
2057 }
2058
2059 fn auth_json(
2060 payload: &str,
2061 account_id: &str,
2062 refresh_token: &str,
2063 last_refresh: &str,
2064 ) -> String {
2065 format!(
2066 r#"{{"tokens":{{"access_token":"{}","id_token":"{}","refresh_token":"{}","account_id":"{}"}},"last_refresh":"{}"}}"#,
2067 token(payload),
2068 token(payload),
2069 refresh_token,
2070 account_id,
2071 last_refresh
2072 )
2073 }
2074
2075 #[test]
2076 fn redact_sensitive_json_removes_tokens_recursively() {
2077 let input = json!({
2078 "tokens": {
2079 "access_token": "a",
2080 "refresh_token": "b",
2081 "nested": {
2082 "id_token": "c",
2083 "Authorization": "Bearer x",
2084 "ok": 1
2085 }
2086 },
2087 "items": [
2088 {"authorization": "Bearer y", "value": 2}
2089 ],
2090 "safe": true
2091 });
2092
2093 let redacted = redact_sensitive_json(&input);
2094 assert_eq!(redacted["tokens"]["nested"]["ok"], 1);
2095 assert_eq!(redacted["safe"], true);
2096 assert!(
2097 redacted["tokens"].get("access_token").is_none(),
2098 "access_token should be removed"
2099 );
2100 assert!(
2101 redacted["tokens"]["nested"].get("id_token").is_none(),
2102 "id_token should be removed"
2103 );
2104 assert!(
2105 redacted["tokens"]["nested"].get("Authorization").is_none(),
2106 "Authorization should be removed"
2107 );
2108 assert!(
2109 redacted["items"][0].get("authorization").is_none(),
2110 "authorization should be removed"
2111 );
2112 }
2113
2114 #[test]
2115 fn collect_secret_files_reports_missing_secret_dir() {
2116 let lock = GlobalStateLock::new();
2117 let dir = tempfile::TempDir::new().expect("tempdir");
2118 let missing = dir.path().join("missing");
2119 let _secret = EnvGuard::set(
2120 &lock,
2121 "CODEX_SECRET_DIR",
2122 missing.to_str().expect("missing path"),
2123 );
2124
2125 let err = collect_secret_files().expect_err("expected missing dir error");
2126 assert_eq!(err.0, 1);
2127 assert!(err.1.contains("CODEX_SECRET_DIR not found"));
2128 }
2129
2130 #[test]
2131 fn collect_secret_files_returns_sorted_json_files_only() {
2132 let lock = GlobalStateLock::new();
2133 let dir = tempfile::TempDir::new().expect("tempdir");
2134 let secrets = dir.path().join("secrets");
2135 fs::create_dir_all(&secrets).expect("secrets dir");
2136 fs::write(secrets.join("beta.json"), "{}").expect("write beta");
2137 fs::write(secrets.join("alpha.json"), "{}").expect("write alpha");
2138 fs::write(secrets.join("note.txt"), "ignore").expect("write note");
2139 let _secret = EnvGuard::set(
2140 &lock,
2141 "CODEX_SECRET_DIR",
2142 secrets.to_str().expect("secrets path"),
2143 );
2144
2145 let files = collect_secret_files().expect("secret files");
2146 assert_eq!(files.len(), 2);
2147 assert_eq!(
2148 files[0].file_name().and_then(|name| name.to_str()),
2149 Some("alpha.json")
2150 );
2151 assert_eq!(
2152 files[1].file_name().and_then(|name| name.to_str()),
2153 Some("beta.json")
2154 );
2155 }
2156
2157 #[test]
2158 fn rate_limits_helper_env_timeout_supports_default_and_parse() {
2159 let lock = GlobalStateLock::new();
2160 let key = "CODEX_RATE_LIMITS_CURL_MAX_TIME_SECONDS";
2161
2162 let _removed = EnvGuard::remove(&lock, key);
2163 assert_eq!(env_timeout(key, 7), 7);
2164
2165 let _set = EnvGuard::set(&lock, key, "11");
2166 assert_eq!(env_timeout(key, 7), 11);
2167
2168 let _invalid = EnvGuard::set(&lock, key, "oops");
2169 assert_eq!(env_timeout(key, 7), 7);
2170 }
2171
2172 #[test]
2173 fn rate_limits_helper_resolve_target_and_is_auth_file() {
2174 let lock = GlobalStateLock::new();
2175 let dir = tempfile::TempDir::new().expect("tempdir");
2176 let secret_dir = dir.path().join("secrets");
2177 fs::create_dir_all(&secret_dir).expect("secret dir");
2178 let auth_file = dir.path().join("auth.json");
2179 fs::write(&auth_file, "{}").expect("auth");
2180
2181 let _secret = EnvGuard::set(
2182 &lock,
2183 "CODEX_SECRET_DIR",
2184 secret_dir.to_str().expect("secret"),
2185 );
2186 let _auth = EnvGuard::set(&lock, "CODEX_AUTH_FILE", auth_file.to_str().expect("auth"));
2187
2188 assert_eq!(
2189 resolve_target(Some("alpha.json")).expect("target"),
2190 secret_dir.join("alpha.json")
2191 );
2192 assert_eq!(resolve_target(Some("../bad")).expect_err("usage"), 64);
2193 assert_eq!(resolve_target(None).expect("auth default"), auth_file);
2194 assert!(is_auth_file(&auth_file));
2195 assert!(!is_auth_file(&secret_dir.join("alpha.json")));
2196 }
2197
2198 #[test]
2199 fn rate_limits_helper_resolve_target_without_auth_returns_err() {
2200 let lock = GlobalStateLock::new();
2201 let _auth = EnvGuard::remove(&lock, "CODEX_AUTH_FILE");
2202 let _home = EnvGuard::set(&lock, "HOME", "");
2203
2204 assert_eq!(resolve_target(None).expect_err("missing auth"), 1);
2205 }
2206
2207 #[test]
2208 fn rate_limits_helper_collect_json_from_cache_covers_hit_and_miss() {
2209 let lock = GlobalStateLock::new();
2210 let dir = tempfile::TempDir::new().expect("tempdir");
2211 let secret_dir = dir.path().join("secrets");
2212 let cache_root = dir.path().join("cache-root");
2213 fs::create_dir_all(&secret_dir).expect("secrets");
2214 fs::create_dir_all(&cache_root).expect("cache");
2215
2216 let alpha = secret_dir.join("alpha.json");
2217 fs::write(&alpha, "{}").expect("alpha");
2218
2219 let _secret = EnvGuard::set(
2220 &lock,
2221 "CODEX_SECRET_DIR",
2222 secret_dir.to_str().expect("secret"),
2223 );
2224 let _cache = EnvGuard::set(&lock, "ZSH_CACHE_DIR", cache_root.to_str().expect("cache"));
2225 cache::write_starship_cache(
2226 &alpha,
2227 1_700_000_000,
2228 "3h",
2229 92,
2230 88,
2231 1_700_003_600,
2232 Some(1_700_001_200),
2233 )
2234 .expect("write cache");
2235
2236 let hit = collect_json_from_cache(&alpha, "cache");
2237 assert!(hit.ok);
2238 assert_eq!(hit.status, "ok");
2239 let summary = hit.summary.expect("summary");
2240 assert_eq!(summary.non_weekly_label, "3h");
2241 assert_eq!(summary.non_weekly_remaining, 92);
2242 assert_eq!(summary.weekly_remaining, 88);
2243
2244 let missing_target = secret_dir.join("missing.json");
2245 let miss = collect_json_from_cache(&missing_target, "cache");
2246 assert!(!miss.ok);
2247 let error = miss.error.expect("error");
2248 assert_eq!(error.code, "cache-read-failed");
2249 assert!(error.message.contains("cache not found"));
2250 }
2251
2252 #[test]
2253 fn rate_limits_helper_fetch_one_line_cached_covers_success_and_error() {
2254 let lock = GlobalStateLock::new();
2255 let dir = tempfile::TempDir::new().expect("tempdir");
2256 let secret_dir = dir.path().join("secrets");
2257 let cache_root = dir.path().join("cache-root");
2258 fs::create_dir_all(&secret_dir).expect("secrets");
2259 fs::create_dir_all(&cache_root).expect("cache");
2260
2261 let alpha = secret_dir.join("alpha.json");
2262 fs::write(&alpha, "{}").expect("alpha");
2263
2264 let _secret = EnvGuard::set(
2265 &lock,
2266 "CODEX_SECRET_DIR",
2267 secret_dir.to_str().expect("secret"),
2268 );
2269 let _cache = EnvGuard::set(&lock, "ZSH_CACHE_DIR", cache_root.to_str().expect("cache"));
2270 cache::write_starship_cache(
2271 &alpha,
2272 1_700_000_000,
2273 "3h",
2274 70,
2275 55,
2276 1_700_003_600,
2277 Some(1_700_001_200),
2278 )
2279 .expect("write cache");
2280
2281 let cached = fetch_one_line_cached(&alpha);
2282 assert_eq!(cached.rc, 0);
2283 assert!(cached.err.is_empty());
2284 assert!(cached.line.expect("line").contains("3h:70%"));
2285
2286 let miss = fetch_one_line_cached(&secret_dir.join("beta.json"));
2287 assert_eq!(miss.rc, 1);
2288 assert!(miss.line.is_none());
2289 assert!(miss.err.contains("cache not found"));
2290 }
2291
2292 #[test]
2293 fn rate_limits_helper_async_fetch_one_line_uses_cache_fallback() {
2294 let lock = GlobalStateLock::new();
2295 let dir = tempfile::TempDir::new().expect("tempdir");
2296 let secret_dir = dir.path().join("secrets");
2297 let cache_root = dir.path().join("cache-root");
2298 fs::create_dir_all(&secret_dir).expect("secrets");
2299 fs::create_dir_all(&cache_root).expect("cache");
2300
2301 let missing = secret_dir.join("ghost.json");
2302 let _secret = EnvGuard::set(
2303 &lock,
2304 "CODEX_SECRET_DIR",
2305 secret_dir.to_str().expect("secret"),
2306 );
2307 let _cache = EnvGuard::set(&lock, "ZSH_CACHE_DIR", cache_root.to_str().expect("cache"));
2308 cache::write_starship_cache(
2309 &missing,
2310 1_700_000_000,
2311 "3h",
2312 68,
2313 42,
2314 1_700_003_600,
2315 Some(1_700_001_200),
2316 )
2317 .expect("write cache");
2318
2319 let result = async_fetch_one_line(&missing, false, true, "ghost");
2320 assert_eq!(result.rc, 0);
2321 let line = result.line.expect("line");
2322 assert!(line.contains("3h:68%"));
2323 assert!(result.err.contains("falling back to cache"));
2324 }
2325
2326 #[test]
2327 fn rate_limits_helper_single_one_line_cached_mode_handles_hit_and_miss() {
2328 let lock = GlobalStateLock::new();
2329 let dir = tempfile::TempDir::new().expect("tempdir");
2330 let secret_dir = dir.path().join("secrets");
2331 let cache_root = dir.path().join("cache-root");
2332 fs::create_dir_all(&secret_dir).expect("secrets");
2333 fs::create_dir_all(&cache_root).expect("cache");
2334
2335 let alpha = secret_dir.join("alpha.json");
2336 let beta = secret_dir.join("beta.json");
2337 fs::write(&alpha, "{}").expect("alpha");
2338 fs::write(&beta, "{}").expect("beta");
2339
2340 let _secret = EnvGuard::set(
2341 &lock,
2342 "CODEX_SECRET_DIR",
2343 secret_dir.to_str().expect("secret"),
2344 );
2345 let _cache = EnvGuard::set(&lock, "ZSH_CACHE_DIR", cache_root.to_str().expect("cache"));
2346 cache::write_starship_cache(
2347 &alpha,
2348 1_700_000_000,
2349 "3h",
2350 61,
2351 39,
2352 1_700_003_600,
2353 Some(1_700_001_200),
2354 )
2355 .expect("write cache");
2356
2357 let hit = single_one_line(&alpha, true, true, false).expect("single");
2358 assert!(hit.expect("line").contains("3h:61%"));
2359
2360 let miss = single_one_line(&beta, true, true, true).expect("single");
2361 assert!(miss.is_none());
2362
2363 let missing =
2364 single_one_line(&secret_dir.join("missing.json"), true, true, true).expect("single");
2365 assert!(missing.is_none());
2366 }
2367
2368 #[test]
2369 fn rate_limits_helper_sync_auth_silent_updates_matching_secret_and_timestamps() {
2370 let lock = GlobalStateLock::new();
2371 let dir = tempfile::TempDir::new().expect("tempdir");
2372 let secret_dir = dir.path().join("secrets");
2373 let cache_dir = dir.path().join("cache");
2374 fs::create_dir_all(&secret_dir).expect("secrets");
2375 fs::create_dir_all(&cache_dir).expect("cache");
2376
2377 let auth_file = dir.path().join("auth.json");
2378 let alpha = secret_dir.join("alpha.json");
2379 let beta = secret_dir.join("beta.json");
2380 fs::write(
2381 &auth_file,
2382 auth_json(
2383 PAYLOAD_ALPHA,
2384 "acct_001",
2385 "refresh_new",
2386 "2025-01-20T12:34:56Z",
2387 ),
2388 )
2389 .expect("auth");
2390 fs::write(
2391 &alpha,
2392 auth_json(
2393 PAYLOAD_ALPHA,
2394 "acct_001",
2395 "refresh_old",
2396 "2025-01-19T12:34:56Z",
2397 ),
2398 )
2399 .expect("alpha");
2400 fs::write(
2401 &beta,
2402 auth_json(
2403 PAYLOAD_BETA,
2404 "acct_002",
2405 "refresh_beta",
2406 "2025-01-18T12:34:56Z",
2407 ),
2408 )
2409 .expect("beta");
2410 fs::write(secret_dir.join("invalid.json"), "{invalid").expect("invalid");
2411 fs::write(secret_dir.join("note.txt"), "ignore").expect("note");
2412
2413 let _auth = EnvGuard::set(&lock, "CODEX_AUTH_FILE", auth_file.to_str().expect("auth"));
2414 let _secret = EnvGuard::set(
2415 &lock,
2416 "CODEX_SECRET_DIR",
2417 secret_dir.to_str().expect("secret"),
2418 );
2419 let _cache = EnvGuard::set(
2420 &lock,
2421 "CODEX_SECRET_CACHE_DIR",
2422 cache_dir.to_str().expect("cache"),
2423 );
2424
2425 let (rc, err) = sync_auth_silent().expect("sync");
2426 assert_eq!(rc, 0);
2427 assert!(err.is_none());
2428 assert_eq!(
2429 fs::read(&alpha).expect("alpha"),
2430 fs::read(&auth_file).expect("auth")
2431 );
2432 assert_ne!(
2433 fs::read(&beta).expect("beta"),
2434 fs::read(&auth_file).expect("auth")
2435 );
2436 assert!(cache_dir.join("alpha.json.timestamp").is_file());
2437 assert!(cache_dir.join("auth.json.timestamp").is_file());
2438 }
2439
2440 #[test]
2441 fn rate_limits_helper_parsers_and_name_helpers_cover_fallbacks() {
2442 let parsed =
2443 parse_one_line_output("alpha 3h:90% W:80% 2025-01-20 12:00:00+00:00").expect("parsed");
2444 assert_eq!(parsed.window_label, "3h");
2445 assert_eq!(parsed.non_weekly_remaining, 90);
2446 assert_eq!(parsed.weekly_remaining, 80);
2447 assert_eq!(parsed.weekly_reset_iso, "2025-01-20 12:00:00+00:00");
2448 assert!(parse_one_line_output("bad").is_none());
2449
2450 assert_eq!(normalize_one_line("a\tb\nc\r".to_string()), "a b c ");
2451 assert_eq!(target_file_name(Path::new("alpha.json")), "alpha.json");
2452 assert_eq!(target_file_name(Path::new("")), "");
2453 assert_eq!(secret_display_name(Path::new("alpha.json")), "alpha");
2454 }
2455}