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