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