Skip to main content

codex_cli/rate_limits/
mod.rs

1use anyhow::Result;
2use chrono::Utc;
3use serde::Serialize;
4use serde_json::Value;
5use std::io::{IsTerminal, Write};
6use std::path::{Path, PathBuf};
7use std::sync::{Arc, mpsc};
8use std::thread;
9use std::time::Duration;
10
11use crate::auth;
12use crate::diag_output;
13use crate::provider_profile::CODEX_PROVIDER_PROFILE;
14use crate::rate_limits::client::{UsageRequest, fetch_usage};
15use nils_common::env as shared_env;
16use nils_common::fs;
17use nils_common::provider_runtime::persistence::{
18    SyncSecretsError, TimestampPolicy, sync_auth_to_matching_secrets,
19};
20use nils_term::progress::{Progress, ProgressFinish, ProgressOptions};
21
22pub use nils_common::rate_limits_ansi as ansi;
23pub mod cache;
24pub mod client;
25pub mod render;
26pub mod writeback;
27
28#[derive(Clone, Debug)]
29pub struct RateLimitsOptions {
30    pub clear_cache: bool,
31    pub debug: bool,
32    pub cached: bool,
33    pub no_refresh_auth: bool,
34    pub json: bool,
35    pub one_line: bool,
36    pub all: bool,
37    pub async_mode: bool,
38    pub watch: bool,
39    pub jobs: Option<String>,
40    pub secret: Option<String>,
41}
42
43const DIAG_SCHEMA_VERSION: &str = "codex-cli.diag.rate-limits.v1";
44const DIAG_COMMAND: &str = "diag rate-limits";
45const WATCH_INTERVAL_SECONDS: u64 = 60;
46const ANSI_CLEAR_SCREEN_AND_HOME: &str = "\x1b[2J\x1b[H";
47
48fn refresh_on_401_enabled(no_refresh_auth: bool) -> bool {
49    !no_refresh_auth && shared_env::env_truthy(CODEX_PROVIDER_PROFILE.env.auto_refresh_enabled)
50}
51
52#[derive(Debug, Clone, Serialize)]
53struct RateLimitSummary {
54    non_weekly_label: String,
55    non_weekly_remaining: i64,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    non_weekly_reset_epoch: Option<i64>,
58    weekly_remaining: i64,
59    weekly_reset_epoch: i64,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    weekly_reset_local: Option<String>,
62}
63
64#[derive(Debug, Clone, Serialize)]
65struct RateLimitWindow {
66    label: String,
67    used_percent: i64,
68    remaining_percent: i64,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    reset_at_epoch: Option<i64>,
71}
72
73#[derive(Debug, Clone, Serialize)]
74struct RateLimitJsonResult {
75    provider: String,
76    name: String,
77    target_file: String,
78    status: String,
79    ok: bool,
80    source: String,
81    #[serde(skip_serializing_if = "Option::is_none")]
82    summary: Option<RateLimitSummary>,
83    #[serde(skip_serializing_if = "Option::is_none")]
84    windows: Option<Vec<RateLimitWindow>>,
85    #[serde(skip_serializing_if = "Option::is_none")]
86    raw_usage: Option<Value>,
87    #[serde(skip_serializing_if = "Option::is_none")]
88    error: Option<diag_output::ErrorEnvelope>,
89}
90
91#[derive(Debug, Clone, Serialize)]
92struct RateLimitSingleEnvelope {
93    schema_version: String,
94    command: String,
95    mode: String,
96    ok: bool,
97    result: RateLimitJsonResult,
98}
99
100#[derive(Debug, Clone, Serialize)]
101struct RateLimitCollectionEnvelope {
102    schema_version: String,
103    command: String,
104    mode: String,
105    ok: bool,
106    results: Vec<RateLimitJsonResult>,
107}
108
109pub fn run(args: &RateLimitsOptions) -> Result<i32> {
110    let cached_mode = args.cached;
111    let mut one_line = args.one_line;
112    let mut all_mode = args.all;
113    let output_json = args.json;
114
115    let mut debug_mode = args.debug;
116    if !debug_mode
117        && let Ok(raw) = std::env::var("ZSH_DEBUG")
118        && raw.parse::<i64>().unwrap_or(0) >= 2
119    {
120        debug_mode = true;
121    }
122
123    if args.async_mode {
124        if !args.cached {
125            maybe_sync_all_mode_auth_silent(debug_mode);
126        }
127        if args.json {
128            return run_async_json_mode(args, debug_mode);
129        }
130        if args.watch {
131            return run_async_watch_mode(args, debug_mode);
132        }
133        return run_async_mode(args, debug_mode);
134    }
135
136    if cached_mode {
137        one_line = true;
138        if output_json {
139            diag_output::emit_error(
140                DIAG_SCHEMA_VERSION,
141                DIAG_COMMAND,
142                "invalid-flag-combination",
143                "codex-rate-limits: --json is not supported with --cached",
144                Some(serde_json::json!({
145                    "flags": ["--json", "--cached"],
146                })),
147            )?;
148            return Ok(64);
149        }
150        if args.clear_cache {
151            eprintln!("codex-rate-limits: -c is not compatible with --cached");
152            return Ok(64);
153        }
154    }
155
156    if output_json && one_line {
157        diag_output::emit_error(
158            DIAG_SCHEMA_VERSION,
159            DIAG_COMMAND,
160            "invalid-flag-combination",
161            "codex-rate-limits: --one-line is not compatible with --json",
162            Some(serde_json::json!({
163                "flags": ["--one-line", "--json"],
164            })),
165        )?;
166        return Ok(64);
167    }
168
169    if args.clear_cache
170        && let Err(err) = cache::clear_prompt_segment_cache()
171    {
172        if output_json {
173            diag_output::emit_error(
174                DIAG_SCHEMA_VERSION,
175                DIAG_COMMAND,
176                "cache-clear-failed",
177                err.to_string(),
178                None,
179            )?;
180        } else {
181            eprintln!("{err}");
182        }
183        return Ok(1);
184    }
185
186    if !all_mode
187        && !output_json
188        && !cached_mode
189        && args.secret.is_none()
190        && shared_env::env_truthy("CODEX_RATE_LIMITS_DEFAULT_ALL_ENABLED")
191    {
192        all_mode = true;
193    }
194
195    if all_mode {
196        if !cached_mode {
197            maybe_sync_all_mode_auth_silent(debug_mode);
198        }
199        if args.secret.is_some() {
200            eprintln!(
201                "codex-rate-limits: usage: codex-rate-limits [-c] [-d] [--cached] [--no-refresh-auth] [--json] [--one-line] [--all] [secret.json]"
202            );
203            return Ok(64);
204        }
205        if output_json {
206            return run_all_json_mode(args, cached_mode, debug_mode);
207        }
208        return run_all_mode(args, cached_mode, debug_mode);
209    }
210
211    run_single_mode(args, cached_mode, one_line, output_json)
212}
213
214fn run_async_json_mode(args: &RateLimitsOptions, _debug_mode: bool) -> Result<i32> {
215    if args.one_line {
216        let message = "codex-rate-limits: --async does not support --one-line";
217        diag_output::emit_error(
218            DIAG_SCHEMA_VERSION,
219            DIAG_COMMAND,
220            "invalid-flag-combination",
221            message,
222            Some(serde_json::json!({
223                "flag": "--one-line",
224                "mode": "async",
225            })),
226        )?;
227        return Ok(64);
228    }
229    if let Some(secret) = args.secret.as_deref() {
230        let message = format!(
231            "codex-rate-limits: --async does not accept positional args: {}",
232            secret
233        );
234        diag_output::emit_error(
235            DIAG_SCHEMA_VERSION,
236            DIAG_COMMAND,
237            "invalid-positional-arg",
238            message,
239            Some(serde_json::json!({
240                "secret": secret,
241                "mode": "async",
242            })),
243        )?;
244        return Ok(64);
245    }
246    if args.clear_cache && args.cached {
247        let message = "codex-rate-limits: --async: -c is not compatible with --cached";
248        diag_output::emit_error(
249            DIAG_SCHEMA_VERSION,
250            DIAG_COMMAND,
251            "invalid-flag-combination",
252            message,
253            Some(serde_json::json!({
254                "flags": ["--async", "--cached", "-c"],
255            })),
256        )?;
257        return Ok(64);
258    }
259    if args.clear_cache
260        && let Err(err) = cache::clear_prompt_segment_cache()
261    {
262        diag_output::emit_error(
263            DIAG_SCHEMA_VERSION,
264            DIAG_COMMAND,
265            "cache-clear-failed",
266            err.to_string(),
267            None,
268        )?;
269        return Ok(1);
270    }
271
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 jobs = resolve_async_jobs(args.jobs.as_deref());
287    let cached_mode = args.cached;
288    let no_refresh_auth = args.no_refresh_auth;
289    let mut results_by_secret = collect_async_items(&secret_files, jobs, None, move |path, _| {
290        collect_json_result_for_secret(&path, cached_mode, no_refresh_auth, true)
291    });
292    let mut results = Vec::new();
293    let mut rc = 0;
294    for secret_file in &secret_files {
295        let secret_name = secret_file
296            .file_name()
297            .and_then(|name| name.to_str())
298            .unwrap_or("")
299            .to_string();
300        let result = results_by_secret.remove(&secret_name).unwrap_or_else(|| {
301            json_result_error(
302                secret_file,
303                "network",
304                "request-failed",
305                format!(
306                    "codex-rate-limits: async worker did not return a result for {}",
307                    secret_file.display()
308                ),
309                None,
310            )
311        });
312        if !args.cached && !result.ok {
313            rc = 1;
314        }
315        results.push(result);
316    }
317    results.sort_by(|a, b| a.name.cmp(&b.name));
318    emit_collection_envelope("async", rc == 0, results)?;
319    Ok(rc)
320}
321
322fn run_all_json_mode(
323    args: &RateLimitsOptions,
324    cached_mode: bool,
325    _debug_mode: bool,
326) -> Result<i32> {
327    let secret_files = match collect_secret_files() {
328        Ok(value) => value,
329        Err((code, message, details)) => {
330            diag_output::emit_error(
331                DIAG_SCHEMA_VERSION,
332                DIAG_COMMAND,
333                "secret-discovery-failed",
334                message,
335                details,
336            )?;
337            return Ok(code);
338        }
339    };
340
341    let mut results = Vec::new();
342    let mut rc = 0;
343    for secret_file in &secret_files {
344        let result =
345            collect_json_result_for_secret(secret_file, cached_mode, args.no_refresh_auth, false);
346        if !cached_mode && !result.ok {
347            rc = 1;
348        }
349        results.push(result);
350    }
351    results.sort_by(|a, b| a.name.cmp(&b.name));
352    emit_collection_envelope("all", rc == 0, results)?;
353    Ok(rc)
354}
355
356fn emit_collection_envelope(mode: &str, ok: bool, results: Vec<RateLimitJsonResult>) -> Result<()> {
357    diag_output::emit_json(&RateLimitCollectionEnvelope {
358        schema_version: DIAG_SCHEMA_VERSION.to_string(),
359        command: DIAG_COMMAND.to_string(),
360        mode: mode.to_string(),
361        ok,
362        results,
363    })
364}
365
366fn collect_secret_files() -> std::result::Result<Vec<PathBuf>, (i32, String, Option<Value>)> {
367    if std::env::var_os(CODEX_PROVIDER_PROFILE.env.secret_dir).is_some() {
368        let secret_dir = crate::paths::resolve_secret_dir().unwrap_or_default();
369        return collect_json_secret_files_from_dir(&secret_dir, true);
370    }
371
372    let secret_dir = crate::paths::resolve_secret_dir().unwrap_or_default();
373    if secret_dir.is_dir()
374        && let Ok(secret_files) = collect_json_secret_files_from_dir(&secret_dir, false)
375        && !secret_files.is_empty()
376    {
377        return Ok(secret_files);
378    }
379
380    if let Some(auth_file) = existing_active_auth_file() {
381        return Ok(vec![auth_file]);
382    }
383
384    if let Some(auth_file) = official_codex_auth_file() {
385        return Ok(vec![auth_file]);
386    }
387
388    no_secret_files_error(&secret_dir)
389}
390
391fn collect_json_secret_files_from_dir(
392    secret_dir: &Path,
393    strict: bool,
394) -> std::result::Result<Vec<PathBuf>, (i32, String, Option<Value>)> {
395    if !secret_dir.is_dir() {
396        return Err((
397            1,
398            format!(
399                "codex-rate-limits: CODEX_SECRET_DIR not found: {}",
400                secret_dir.display()
401            ),
402            Some(serde_json::json!({
403                "secret_dir": secret_dir.display().to_string(),
404            })),
405        ));
406    }
407
408    let mut secret_files: Vec<PathBuf> = std::fs::read_dir(secret_dir)
409        .map_err(|err| {
410            (
411                1,
412                format!("codex-rate-limits: failed to read CODEX_SECRET_DIR: {err}"),
413                Some(serde_json::json!({
414                    "secret_dir": secret_dir.display().to_string(),
415                })),
416            )
417        })?
418        .flatten()
419        .map(|entry| entry.path())
420        .filter(|path| path.extension().and_then(|s| s.to_str()) == Some("json"))
421        .collect();
422
423    if strict && secret_files.is_empty() {
424        return no_secret_files_error(secret_dir);
425    }
426
427    secret_files.sort();
428    Ok(secret_files)
429}
430
431fn no_secret_files_error(
432    secret_dir: &Path,
433) -> std::result::Result<Vec<PathBuf>, (i32, String, Option<Value>)> {
434    Err((
435        1,
436        format!(
437            "codex-rate-limits: no secrets found in {}",
438            secret_dir.display()
439        ),
440        Some(serde_json::json!({
441            "secret_dir": secret_dir.display().to_string(),
442        })),
443    ))
444}
445
446fn existing_active_auth_file() -> Option<PathBuf> {
447    crate::paths::resolve_auth_file().filter(|path| path.is_file())
448}
449
450fn official_codex_auth_file() -> Option<PathBuf> {
451    if let Some(path) = std::env::var_os("CODEX_HOME")
452        .map(PathBuf::from)
453        .map(|home| home.join("auth.json"))
454        .filter(|path| path.is_file())
455    {
456        return Some(path);
457    }
458
459    std::env::var_os("HOME")
460        .map(PathBuf::from)
461        .map(|home| home.join(".codex").join("auth.json"))
462        .filter(|path| path.is_file())
463}
464
465fn is_official_codex_auth_file(target_file: &Path) -> bool {
466    official_codex_auth_file().as_deref() == Some(target_file)
467}
468
469fn should_writeback_usage(target_file: &Path) -> bool {
470    !is_official_codex_auth_file(target_file)
471}
472
473fn collect_json_result_for_secret(
474    target_file: &Path,
475    cached_mode: bool,
476    no_refresh_auth: bool,
477    allow_cache_fallback: bool,
478) -> RateLimitJsonResult {
479    if cached_mode {
480        return collect_json_from_cache(target_file, "cache", true);
481    }
482
483    let base_url = std::env::var("CODEX_CHATGPT_BASE_URL")
484        .unwrap_or_else(|_| "https://chatgpt.com/backend-api/".to_string());
485    let connect_timeout = env_timeout("CODEX_RATE_LIMITS_CURL_CONNECT_TIMEOUT_SECONDS", 2);
486    let max_time = env_timeout("CODEX_RATE_LIMITS_CURL_MAX_TIME_SECONDS", 8);
487    let usage_request = UsageRequest {
488        target_file: target_file.to_path_buf(),
489        refresh_on_401: refresh_on_401_enabled(no_refresh_auth),
490        suppress_auth_refresh_output: false,
491        base_url,
492        connect_timeout_seconds: connect_timeout,
493        max_time_seconds: max_time,
494    };
495
496    match fetch_usage(&usage_request) {
497        Ok(usage) => {
498            if should_writeback_usage(target_file)
499                && let Err(err) = writeback::write_weekly(target_file, &usage.json)
500            {
501                return json_result_error(
502                    target_file,
503                    "network",
504                    "writeback-failed",
505                    err.to_string(),
506                    None,
507                );
508            }
509            if is_auth_file(target_file)
510                && let Ok(sync_rc) = auth::sync::run_with_json(false)
511                && sync_rc != 0
512            {
513                return json_result_error(
514                    target_file,
515                    "network",
516                    "sync-failed",
517                    "codex-rate-limits: failed to sync auth after usage fetch".to_string(),
518                    None,
519                );
520            }
521            match summary_and_windows_from_usage(&usage.json) {
522                Some((summary, windows)) => {
523                    let fetched_at_epoch = Utc::now().timestamp();
524                    if fetched_at_epoch > 0 {
525                        let _ = cache::write_prompt_segment_cache(
526                            target_file,
527                            fetched_at_epoch,
528                            &summary.non_weekly_label,
529                            summary.non_weekly_remaining,
530                            summary.weekly_remaining,
531                            summary.weekly_reset_epoch,
532                            summary.non_weekly_reset_epoch,
533                        );
534                    }
535                    RateLimitJsonResult {
536                        provider: "codex".to_string(),
537                        name: secret_display_name(target_file),
538                        target_file: target_file_name(target_file),
539                        status: "ok".to_string(),
540                        ok: true,
541                        source: "network".to_string(),
542                        summary: Some(summary),
543                        windows: Some(windows),
544                        raw_usage: Some(redact_sensitive_json(&usage.json)),
545                        error: None,
546                    }
547                }
548                None if render::rate_limit_is_explicit_null(&usage.json) => {
549                    // Benign: the backend reports no active window. Prefer the
550                    // last-known cached values (stale allowed), else report the
551                    // empty window as a success rather than an error.
552                    if allow_cache_fallback {
553                        let fallback =
554                            collect_json_from_cache(target_file, "cache-fallback", false);
555                        if fallback.ok {
556                            return fallback;
557                        }
558                    }
559                    json_result_no_window(target_file)
560                }
561                None => json_result_error(
562                    target_file,
563                    "network",
564                    "invalid-usage-payload",
565                    "codex-rate-limits: invalid usage payload".to_string(),
566                    Some(serde_json::json!({
567                        "raw_usage": redact_sensitive_json(&usage.json),
568                    })),
569                ),
570            }
571        }
572        Err(err) => {
573            if allow_cache_fallback {
574                let fallback = collect_json_from_cache(target_file, "cache-fallback", false);
575                if fallback.ok {
576                    return fallback;
577                }
578            }
579            let msg = err.to_string();
580            let code = if msg.contains("missing access_token") {
581                "missing-access-token"
582            } else {
583                "request-failed"
584            };
585            json_result_error(target_file, "network", code, msg, None)
586        }
587    }
588}
589
590fn collect_json_from_cache(
591    target_file: &Path,
592    source: &str,
593    enforce_ttl: bool,
594) -> RateLimitJsonResult {
595    let cache_entry = if enforce_ttl {
596        cache::read_cache_entry_for_cached_mode(target_file)
597    } else {
598        cache::read_cache_entry(target_file)
599    };
600
601    match cache_entry {
602        Ok(entry) => RateLimitJsonResult {
603            provider: "codex".to_string(),
604            name: secret_display_name(target_file),
605            target_file: target_file_name(target_file),
606            status: "ok".to_string(),
607            ok: true,
608            source: source.to_string(),
609            summary: Some(summary_from_cache(&entry)),
610            windows: Some(windows_from_cache(&entry)),
611            raw_usage: None,
612            error: None,
613        },
614        Err(err) => json_result_error(
615            target_file,
616            source,
617            "cache-read-failed",
618            err.to_string(),
619            None,
620        ),
621    }
622}
623
624fn json_result_error(
625    target_file: &Path,
626    source: &str,
627    code: &str,
628    message: String,
629    details: Option<Value>,
630) -> RateLimitJsonResult {
631    RateLimitJsonResult {
632        provider: "codex".to_string(),
633        name: secret_display_name(target_file),
634        target_file: target_file_name(target_file),
635        status: "error".to_string(),
636        ok: false,
637        source: source.to_string(),
638        summary: None,
639        windows: None,
640        raw_usage: None,
641        error: Some(diag_output::ErrorEnvelope {
642            code: code.to_string(),
643            message,
644            details,
645        }),
646    }
647}
648
649/// Benign "no active rate-limit window" result for a `rate_limit: null` payload
650/// with no cache to fall back to. Reported as a success, not an error.
651fn json_result_no_window(target_file: &Path) -> RateLimitJsonResult {
652    RateLimitJsonResult {
653        provider: "codex".to_string(),
654        name: secret_display_name(target_file),
655        target_file: target_file_name(target_file),
656        status: "no-rate-limit-window".to_string(),
657        ok: true,
658        source: "network".to_string(),
659        summary: None,
660        windows: Some(Vec::new()),
661        raw_usage: None,
662        error: None,
663    }
664}
665
666fn secret_display_name(target_file: &Path) -> String {
667    cache::secret_name_for_target(target_file).unwrap_or_else(|| {
668        target_file
669            .file_name()
670            .and_then(|name| name.to_str())
671            .unwrap_or_default()
672            .trim_end_matches(".json")
673            .to_string()
674    })
675}
676
677fn target_file_name(target_file: &Path) -> String {
678    target_file
679        .file_name()
680        .and_then(|name| name.to_str())
681        .unwrap_or_default()
682        .to_string()
683}
684
685fn summary_and_windows_from_usage(
686    usage_json: &Value,
687) -> Option<(RateLimitSummary, Vec<RateLimitWindow>)> {
688    let usage_data = render::parse_usage(usage_json)?;
689    let values = render::render_values(&usage_data);
690    let weekly = render::weekly_values(&values);
691    let summary = RateLimitSummary {
692        non_weekly_label: weekly.non_weekly_label,
693        non_weekly_remaining: weekly.non_weekly_remaining,
694        non_weekly_reset_epoch: weekly.non_weekly_reset_epoch,
695        weekly_remaining: weekly.weekly_remaining,
696        weekly_reset_epoch: weekly.weekly_reset_epoch,
697        weekly_reset_local: render::format_epoch_local_datetime_with_offset(
698            weekly.weekly_reset_epoch,
699        ),
700    };
701    let windows = windows_from_usage_values(&usage_data, &values);
702    Some((summary, windows))
703}
704
705fn windows_from_usage_values(
706    usage_data: &render::UsageData,
707    values: &render::RenderValues,
708) -> Vec<RateLimitWindow> {
709    vec![
710        RateLimitWindow {
711            label: values.primary_label.clone(),
712            used_percent: percent_i64(usage_data.primary.used_percent),
713            remaining_percent: values.primary_remaining,
714            reset_at_epoch: Some(values.primary_reset_epoch).filter(|epoch| *epoch > 0),
715        },
716        RateLimitWindow {
717            label: values.secondary_label.clone(),
718            used_percent: percent_i64(usage_data.secondary.used_percent),
719            remaining_percent: values.secondary_remaining,
720            reset_at_epoch: Some(values.secondary_reset_epoch).filter(|epoch| *epoch > 0),
721        },
722    ]
723}
724
725fn summary_from_cache(entry: &cache::CacheEntry) -> RateLimitSummary {
726    RateLimitSummary {
727        non_weekly_label: entry.non_weekly_label.clone(),
728        non_weekly_remaining: entry.non_weekly_remaining,
729        non_weekly_reset_epoch: entry.non_weekly_reset_epoch,
730        weekly_remaining: entry.weekly_remaining,
731        weekly_reset_epoch: entry.weekly_reset_epoch,
732        weekly_reset_local: render::format_epoch_local_datetime_with_offset(
733            entry.weekly_reset_epoch,
734        ),
735    }
736}
737
738fn windows_from_cache(entry: &cache::CacheEntry) -> Vec<RateLimitWindow> {
739    vec![
740        RateLimitWindow {
741            label: entry.non_weekly_label.clone(),
742            used_percent: remaining_to_used_percent(entry.non_weekly_remaining),
743            remaining_percent: entry.non_weekly_remaining,
744            reset_at_epoch: entry.non_weekly_reset_epoch,
745        },
746        RateLimitWindow {
747            label: "Weekly".to_string(),
748            used_percent: remaining_to_used_percent(entry.weekly_remaining),
749            remaining_percent: entry.weekly_remaining,
750            reset_at_epoch: Some(entry.weekly_reset_epoch).filter(|epoch| *epoch > 0),
751        },
752    ]
753}
754
755fn percent_i64(percent: f64) -> i64 {
756    if percent.is_finite() {
757        (percent.round() as i64).clamp(0, 100)
758    } else {
759        0
760    }
761}
762
763fn remaining_to_used_percent(remaining: i64) -> i64 {
764    (100 - remaining).clamp(0, 100)
765}
766
767fn redact_sensitive_json(value: &Value) -> Value {
768    match value {
769        Value::Object(map) => {
770            let mut next = serde_json::Map::new();
771            for (key, val) in map {
772                if is_sensitive_key(key) {
773                    continue;
774                }
775                next.insert(key.clone(), redact_sensitive_json(val));
776            }
777            Value::Object(next)
778        }
779        Value::Array(items) => Value::Array(items.iter().map(redact_sensitive_json).collect()),
780        _ => value.clone(),
781    }
782}
783
784fn is_sensitive_key(key: &str) -> bool {
785    matches!(
786        key,
787        "access_token" | "refresh_token" | "id_token" | "authorization" | "Authorization"
788    )
789}
790
791#[derive(Default)]
792struct AsyncFetchResult {
793    line: Option<String>,
794    rc: i32,
795    err: String,
796    /// The line was served from cache past its freshness TTL.
797    stale: bool,
798    /// The live fetch succeeded but the backend reported no active rate-limit
799    /// window (`rate_limit: null`) and no cache was available. Benign.
800    no_window: bool,
801}
802
803/// Distinct rc for "live fetch ok, but backend reports no active window". Kept
804/// separate from the network-failure codes (1/2/3) so it is neither retried
805/// nor counted as an error.
806const RC_NO_RATE_LIMIT_WINDOW: i32 = 6;
807
808struct AsyncCollectedItem<T> {
809    secret_name: String,
810    value: T,
811}
812
813fn run_async_mode(args: &RateLimitsOptions, debug_mode: bool) -> Result<i32> {
814    run_async_mode_impl(args, debug_mode, false)
815}
816
817fn run_async_watch_mode(args: &RateLimitsOptions, debug_mode: bool) -> Result<i32> {
818    run_async_mode_impl(args, debug_mode, true)
819}
820
821fn run_async_mode_impl(
822    args: &RateLimitsOptions,
823    debug_mode: bool,
824    watch_mode: bool,
825) -> Result<i32> {
826    if args.json {
827        eprintln!("codex-rate-limits: --async does not support --json");
828        return Ok(64);
829    }
830    if args.one_line {
831        eprintln!("codex-rate-limits: --async does not support --one-line");
832        return Ok(64);
833    }
834    if let Some(secret) = args.secret.as_deref() {
835        let _ = secret;
836        eprintln!("codex-rate-limits: --async does not accept positional args");
837        eprintln!(
838            "codex-rate-limits: hint: async always queries all secrets under CODEX_SECRET_DIR"
839        );
840        return Ok(64);
841    }
842    if args.clear_cache && args.cached {
843        eprintln!("codex-rate-limits: --async: -c is not compatible with --cached");
844        return Ok(64);
845    }
846
847    let jobs = resolve_async_jobs(args.jobs.as_deref());
848
849    if args.clear_cache
850        && let Err(err) = cache::clear_prompt_segment_cache()
851    {
852        eprintln!("{err}");
853        return Ok(1);
854    }
855
856    let secret_files = match collect_secret_files_for_async_text() {
857        Ok(value) => value,
858        Err(err) => {
859            eprintln!("{err}");
860            return Ok(1);
861        }
862    };
863
864    if !watch_mode {
865        if secret_files.is_empty() {
866            let secret_dir = crate::paths::resolve_secret_dir().unwrap_or_default();
867            eprintln!(
868                "codex-rate-limits-async: no secrets found in {}",
869                secret_dir.display()
870            );
871            return Ok(1);
872        }
873
874        let current_name = current_secret_basename(&secret_files);
875        let round = collect_async_round(&secret_files, args.cached, args.no_refresh_auth, jobs);
876        render_all_accounts_table(
877            round.rows,
878            &round.window_labels,
879            current_name.as_deref(),
880            None,
881        );
882        emit_async_debug(debug_mode, &secret_files, &round.stderr_map);
883        return Ok(round.rc);
884    }
885
886    let mut overall_rc = 0;
887    let mut rendered_rounds = 0u64;
888    let max_rounds = watch_max_rounds_for_test();
889    let watch_interval_seconds = watch_interval_seconds();
890    let is_terminal_stdout = std::io::stdout().is_terminal();
891
892    loop {
893        let secret_files = match collect_secret_files_for_async_text() {
894            Ok(value) => value,
895            Err(err) => {
896                overall_rc = 1;
897                if is_terminal_stdout {
898                    print!("{ANSI_CLEAR_SCREEN_AND_HOME}");
899                }
900                eprintln!("{err}");
901                let _ = std::io::stdout().flush();
902
903                rendered_rounds += 1;
904                if let Some(limit) = max_rounds
905                    && rendered_rounds >= limit
906                {
907                    break;
908                }
909
910                thread::sleep(Duration::from_secs(watch_interval_seconds));
911                continue;
912            }
913        };
914        let current_name = current_secret_basename(&secret_files);
915        let round = collect_async_round(&secret_files, args.cached, args.no_refresh_auth, jobs);
916        if round.rc != 0 {
917            overall_rc = 1;
918        }
919
920        if is_terminal_stdout {
921            print!("{ANSI_CLEAR_SCREEN_AND_HOME}");
922        }
923
924        let now_epoch = Utc::now().timestamp();
925        let update_time = format_watch_update_time(now_epoch);
926        render_all_accounts_table(
927            round.rows,
928            &round.window_labels,
929            current_name.as_deref(),
930            Some(update_time.as_str()),
931        );
932        emit_async_debug(debug_mode, &secret_files, &round.stderr_map);
933        let _ = std::io::stdout().flush();
934
935        rendered_rounds += 1;
936        if let Some(limit) = max_rounds
937            && rendered_rounds >= limit
938        {
939            break;
940        }
941
942        thread::sleep(Duration::from_secs(watch_interval_seconds));
943    }
944
945    Ok(overall_rc)
946}
947
948fn collect_secret_files_for_async_text() -> std::result::Result<Vec<PathBuf>, String> {
949    let secret_dir = crate::paths::resolve_secret_dir().unwrap_or_default();
950    if !secret_dir.is_dir() {
951        return Err(format!(
952            "codex-rate-limits-async: CODEX_SECRET_DIR not found: {}",
953            secret_dir.display()
954        ));
955    }
956
957    let mut secret_files: Vec<PathBuf> = std::fs::read_dir(&secret_dir)
958        .map_err(|err| format!("codex-rate-limits-async: failed to read CODEX_SECRET_DIR: {err}"))?
959        .flatten()
960        .map(|entry| entry.path())
961        .filter(|path| path.extension().and_then(|s| s.to_str()) == Some("json"))
962        .collect();
963
964    secret_files.sort();
965    Ok(secret_files)
966}
967
968struct AsyncRound {
969    rc: i32,
970    rows: Vec<Row>,
971    window_labels: std::collections::HashSet<String>,
972    stderr_map: std::collections::HashMap<String, String>,
973}
974
975fn resolve_async_jobs(jobs: Option<&str>) -> usize {
976    jobs.and_then(|raw| raw.parse::<i64>().ok())
977        .filter(|value| *value > 0)
978        .map(|value| value as usize)
979        .unwrap_or(5)
980}
981
982fn collect_async_items<T, F>(
983    secret_files: &[PathBuf],
984    jobs: usize,
985    progress_prefix: Option<&str>,
986    worker: F,
987) -> std::collections::HashMap<String, T>
988where
989    T: Send + 'static,
990    F: Fn(PathBuf, String) -> T + Send + Sync + 'static,
991{
992    let total = secret_files.len();
993    if total == 0 {
994        return std::collections::HashMap::new();
995    }
996
997    let progress = if total > 1 {
998        progress_prefix.map(|prefix| {
999            Progress::new(
1000                total as u64,
1001                ProgressOptions::default()
1002                    .with_prefix(prefix)
1003                    .with_finish(ProgressFinish::Clear),
1004            )
1005        })
1006    } else {
1007        None
1008    };
1009
1010    let worker_count = jobs.clamp(1, total);
1011    let worker = Arc::new(worker);
1012    let (tx, rx) = mpsc::channel();
1013    let mut handles = Vec::new();
1014    let mut index = 0usize;
1015
1016    while index < total && handles.len() < worker_count {
1017        let path = secret_files[index].clone();
1018        index += 1;
1019        let tx = tx.clone();
1020        let worker = Arc::clone(&worker);
1021        handles.push(thread::spawn(move || {
1022            let secret_name = path
1023                .file_name()
1024                .and_then(|name| name.to_str())
1025                .unwrap_or("")
1026                .to_string();
1027            let value = worker(path, secret_name.clone());
1028            let _ = tx.send(AsyncCollectedItem { secret_name, value });
1029        }));
1030    }
1031
1032    let mut items = std::collections::HashMap::new();
1033    while items.len() < total {
1034        let item = match rx.recv() {
1035            Ok(item) => item,
1036            Err(_) => break,
1037        };
1038        if let Some(progress) = &progress {
1039            progress.set_message(item.secret_name.clone());
1040            progress.inc(1);
1041        }
1042        items.insert(item.secret_name.clone(), item.value);
1043
1044        if index < total {
1045            let path = secret_files[index].clone();
1046            index += 1;
1047            let tx = tx.clone();
1048            let worker = Arc::clone(&worker);
1049            handles.push(thread::spawn(move || {
1050                let secret_name = path
1051                    .file_name()
1052                    .and_then(|name| name.to_str())
1053                    .unwrap_or("")
1054                    .to_string();
1055                let value = worker(path, secret_name.clone());
1056                let _ = tx.send(AsyncCollectedItem { secret_name, value });
1057            }));
1058        }
1059    }
1060
1061    if let Some(progress) = progress {
1062        progress.finish_and_clear();
1063    }
1064
1065    drop(tx);
1066    for handle in handles {
1067        let _ = handle.join();
1068    }
1069
1070    items
1071}
1072
1073fn collect_async_round(
1074    secret_files: &[PathBuf],
1075    cached_mode: bool,
1076    no_refresh_auth: bool,
1077    jobs: usize,
1078) -> AsyncRound {
1079    let mut events = collect_async_items(
1080        secret_files,
1081        jobs,
1082        Some("codex-rate-limits "),
1083        move |path, secret_name| {
1084            async_fetch_one_line(&path, cached_mode, no_refresh_auth, &secret_name)
1085        },
1086    );
1087
1088    let mut rc = 0;
1089    let mut rows: Vec<Row> = Vec::new();
1090    let mut window_labels = std::collections::HashSet::new();
1091    let mut stderr_map: std::collections::HashMap<String, String> =
1092        std::collections::HashMap::new();
1093
1094    for secret_file in secret_files {
1095        let secret_name = secret_file
1096            .file_name()
1097            .and_then(|name| name.to_str())
1098            .unwrap_or("")
1099            .to_string();
1100
1101        let mut row = Row::empty(secret_name.trim_end_matches(".json").to_string());
1102        let mut benign_no_window = false;
1103        let event = events.remove(&secret_name);
1104        if let Some(event) = event {
1105            if !event.err.is_empty() {
1106                stderr_map.insert(secret_name.clone(), event.err.clone());
1107            }
1108            // A null window (no_window) is benign and must not fail the round;
1109            // a stale cache fallback (rc == 0) likewise succeeded.
1110            if !cached_mode && event.rc != 0 && !event.no_window {
1111                rc = 1;
1112            }
1113            benign_no_window = event.no_window;
1114
1115            if let Some(line) = &event.line
1116                && let Some(parsed) = parse_one_line_output(line)
1117            {
1118                row.window_label = parsed.window_label.clone();
1119                row.non_weekly_remaining = parsed.non_weekly_remaining;
1120                row.weekly_remaining = parsed.weekly_remaining;
1121                row.weekly_reset_iso = parsed.weekly_reset_iso.clone();
1122
1123                if cached_mode {
1124                    if let Ok(cache_entry) = cache::read_cache_entry_for_cached_mode(secret_file) {
1125                        row.non_weekly_reset_epoch = cache_entry.non_weekly_reset_epoch;
1126                        row.weekly_reset_epoch = Some(cache_entry.weekly_reset_epoch);
1127                    }
1128                } else {
1129                    let values = crate::json::read_json(secret_file).ok();
1130                    if let Some(values) = values {
1131                        row.non_weekly_reset_epoch = crate::json::i64_at(
1132                            &values,
1133                            &["codex_rate_limits", "non_weekly_reset_at_epoch"],
1134                        );
1135                        row.weekly_reset_epoch = crate::json::i64_at(
1136                            &values,
1137                            &["codex_rate_limits", "weekly_reset_at_epoch"],
1138                        );
1139                    }
1140                    if (row.non_weekly_reset_epoch.is_none() || row.weekly_reset_epoch.is_none())
1141                        && let Ok(cache_entry) = cache::read_cache_entry(secret_file)
1142                    {
1143                        if row.non_weekly_reset_epoch.is_none() {
1144                            row.non_weekly_reset_epoch = cache_entry.non_weekly_reset_epoch;
1145                        }
1146                        if row.weekly_reset_epoch.is_none() {
1147                            row.weekly_reset_epoch = Some(cache_entry.weekly_reset_epoch);
1148                        }
1149                    }
1150                }
1151
1152                row.state = if event.stale {
1153                    RowState::Stale
1154                } else {
1155                    RowState::Filled
1156                };
1157                window_labels.insert(row.window_label.clone());
1158                rows.push(row);
1159                continue;
1160            }
1161        }
1162
1163        if benign_no_window {
1164            row.state = RowState::NoWindow;
1165        } else if !cached_mode {
1166            rc = 1;
1167        }
1168        rows.push(row);
1169    }
1170
1171    AsyncRound {
1172        rc,
1173        rows,
1174        window_labels,
1175        stderr_map,
1176    }
1177}
1178
1179fn render_all_accounts_table(
1180    mut rows: Vec<Row>,
1181    window_labels: &std::collections::HashSet<String>,
1182    current_name: Option<&str>,
1183    update_time: Option<&str>,
1184) {
1185    println!("\n🚦 Codex rate limits for all accounts\n");
1186
1187    let mut non_weekly_header = "Non-weekly".to_string();
1188    let multiple_labels = window_labels.len() != 1;
1189    if !multiple_labels && let Some(label) = window_labels.iter().next() {
1190        non_weekly_header = label.clone();
1191    }
1192
1193    let now_epoch = Utc::now().timestamp();
1194
1195    println!(
1196        "{:<15}  {:>8}  {:>7}  {:>8}  {:>7}  {:<18}",
1197        "Name", non_weekly_header, "Left", "Weekly", "Left", "Reset"
1198    );
1199    println!("----------------------------------------------------------------------------");
1200
1201    rows.sort_by_key(|row| row.sort_key());
1202
1203    for row in rows {
1204        // A null window reports "n/a"; a stale fallback keeps its values but is
1205        // marked so the reader knows they are not live.
1206        let no_window = row.state == RowState::NoWindow;
1207
1208        let display_non_weekly = if no_window {
1209            "n/a".to_string()
1210        } else if multiple_labels && !row.window_label.is_empty() {
1211            if row.non_weekly_remaining >= 0 {
1212                format!("{}:{}%", row.window_label, row.non_weekly_remaining)
1213            } else {
1214                "-".to_string()
1215            }
1216        } else if row.non_weekly_remaining >= 0 {
1217            format!("{}%", row.non_weekly_remaining)
1218        } else {
1219            "-".to_string()
1220        };
1221
1222        let non_weekly_left = row
1223            .non_weekly_reset_epoch
1224            .and_then(|epoch| render::format_until_epoch_compact(epoch, now_epoch))
1225            .unwrap_or_else(|| "-".to_string());
1226        let weekly_left = row
1227            .weekly_reset_epoch
1228            .and_then(|epoch| render::format_until_epoch_compact(epoch, now_epoch))
1229            .unwrap_or_else(|| "-".to_string());
1230        let mut reset_display = if no_window {
1231            "n/a".to_string()
1232        } else {
1233            row.weekly_reset_epoch
1234                .and_then(render::format_epoch_local_datetime_with_offset)
1235                .unwrap_or_else(|| "-".to_string())
1236        };
1237        if row.state == RowState::Stale {
1238            reset_display.push_str(" (stale)");
1239        }
1240
1241        let non_weekly_display = ansi::format_percent_cell(&display_non_weekly, 8, None);
1242        let weekly_display = if no_window {
1243            ansi::format_percent_cell("n/a", 8, None)
1244        } else if row.weekly_remaining >= 0 {
1245            ansi::format_percent_cell(&format!("{}%", row.weekly_remaining), 8, None)
1246        } else {
1247            ansi::format_percent_cell("-", 8, None)
1248        };
1249
1250        let is_current = current_name == Some(row.name.as_str());
1251        let name_display = ansi::format_name_cell(&row.name, 15, is_current, None);
1252
1253        println!(
1254            "{}  {}  {:>7}  {}  {:>7}  {:<18}",
1255            name_display,
1256            non_weekly_display,
1257            non_weekly_left,
1258            weekly_display,
1259            weekly_left,
1260            reset_display
1261        );
1262    }
1263
1264    if let Some(update_time) = update_time {
1265        println!();
1266        println!("Last update: {update_time}");
1267    }
1268}
1269
1270fn emit_async_debug(
1271    debug_mode: bool,
1272    secret_files: &[PathBuf],
1273    stderr_map: &std::collections::HashMap<String, String>,
1274) {
1275    if !debug_mode {
1276        return;
1277    }
1278
1279    let mut printed = false;
1280    for secret_file in secret_files {
1281        let secret_name = secret_file
1282            .file_name()
1283            .and_then(|name| name.to_str())
1284            .unwrap_or("")
1285            .to_string();
1286        if let Some(err) = stderr_map.get(&secret_name) {
1287            if err.is_empty() {
1288                continue;
1289            }
1290            if !printed {
1291                printed = true;
1292                eprintln!();
1293                eprintln!("codex-rate-limits-async: per-account stderr (captured):");
1294            }
1295            let _ = secret_name;
1296            eprintln!("---- account stderr ----");
1297            eprintln!("{err}");
1298        }
1299    }
1300}
1301
1302fn watch_max_rounds_for_test() -> Option<u64> {
1303    std::env::var("CODEX_RATE_LIMITS_WATCH_MAX_ROUNDS")
1304        .ok()
1305        .and_then(|raw| raw.parse::<u64>().ok())
1306        .filter(|value| *value > 0)
1307}
1308
1309fn watch_interval_seconds() -> u64 {
1310    std::env::var("CODEX_RATE_LIMITS_WATCH_INTERVAL_SECONDS")
1311        .ok()
1312        .and_then(|raw| raw.parse::<u64>().ok())
1313        .filter(|value| *value > 0)
1314        .unwrap_or(WATCH_INTERVAL_SECONDS)
1315}
1316
1317fn format_watch_update_time(now_epoch: i64) -> String {
1318    render::format_epoch_local(now_epoch, "%Y-%m-%d %H:%M:%S %:z")
1319        .unwrap_or_else(|| now_epoch.to_string())
1320}
1321
1322fn async_fetch_one_line(
1323    target_file: &Path,
1324    cached_mode: bool,
1325    no_refresh_auth: bool,
1326    secret_name: &str,
1327) -> AsyncFetchResult {
1328    if cached_mode {
1329        return fetch_one_line_cached(target_file);
1330    }
1331
1332    let mut attempt = 1;
1333    let max_attempts = 2;
1334    let mut network_err: Option<String> = None;
1335
1336    let mut result = fetch_one_line_network(target_file, no_refresh_auth);
1337    if !result.err.is_empty() {
1338        network_err = Some(result.err.clone());
1339    }
1340
1341    while attempt < max_attempts && result.rc == 3 {
1342        thread::sleep(Duration::from_millis(250));
1343        let next = fetch_one_line_network(target_file, no_refresh_auth);
1344        if !next.err.is_empty() {
1345            network_err = Some(next.err.clone());
1346        }
1347        result = next;
1348        attempt += 1;
1349        if result.rc != 3 {
1350            break;
1351        }
1352    }
1353
1354    let mut errors: Vec<String> = Vec::new();
1355    if let Some(err) = network_err {
1356        errors.push(err);
1357    }
1358
1359    // A null window is benign — the backend simply has no usage recorded in the
1360    // current window. Degrade to the last-known cached values (marked stale)
1361    // rather than surfacing it as a failure.
1362    let no_window_live = result.rc == RC_NO_RATE_LIMIT_WINDOW;
1363
1364    let missing_line = result
1365        .line
1366        .as_ref()
1367        .map(|line| line.trim().is_empty())
1368        .unwrap_or(true);
1369
1370    if result.rc != 0 || missing_line {
1371        let cached = fetch_one_line_cached_allow_stale(target_file);
1372        if !cached.err.is_empty() {
1373            errors.push(cached.err.clone());
1374        }
1375        if cached.rc == 0
1376            && cached
1377                .line
1378                .as_ref()
1379                .map(|line| !line.trim().is_empty())
1380                .unwrap_or(false)
1381        {
1382            // Only annotate the fallback for genuine failures; a null window is
1383            // an expected, quiet condition.
1384            if result.rc != 0 && !no_window_live {
1385                let _ = secret_name;
1386                errors.push(format!(
1387                    "codex-rate-limits-async: falling back to cache (rc={})",
1388                    result.rc
1389                ));
1390            }
1391            result = AsyncFetchResult {
1392                line: cached.line,
1393                stale: cached.stale,
1394                ..Default::default()
1395            };
1396        } else if no_window_live {
1397            // No cache to borrow from, but a null window is still benign.
1398            result = AsyncFetchResult {
1399                rc: RC_NO_RATE_LIMIT_WINDOW,
1400                no_window: true,
1401                ..Default::default()
1402            };
1403        }
1404    }
1405
1406    let line = result.line.map(normalize_one_line);
1407    let err = errors.join("\n");
1408    AsyncFetchResult {
1409        line,
1410        rc: result.rc,
1411        err,
1412        stale: result.stale,
1413        no_window: result.no_window,
1414    }
1415}
1416
1417fn fetch_one_line_network(target_file: &Path, no_refresh_auth: bool) -> AsyncFetchResult {
1418    if !target_file.is_file() {
1419        return AsyncFetchResult {
1420            line: None,
1421            rc: 1,
1422            err: format!("codex-rate-limits: {} not found", target_file.display()),
1423            ..Default::default()
1424        };
1425    }
1426
1427    let base_url = std::env::var("CODEX_CHATGPT_BASE_URL")
1428        .unwrap_or_else(|_| "https://chatgpt.com/backend-api/".to_string());
1429    let connect_timeout = env_timeout("CODEX_RATE_LIMITS_CURL_CONNECT_TIMEOUT_SECONDS", 2);
1430    let max_time = env_timeout("CODEX_RATE_LIMITS_CURL_MAX_TIME_SECONDS", 8);
1431
1432    let usage_request = UsageRequest {
1433        target_file: target_file.to_path_buf(),
1434        refresh_on_401: refresh_on_401_enabled(no_refresh_auth),
1435        suppress_auth_refresh_output: false,
1436        base_url,
1437        connect_timeout_seconds: connect_timeout,
1438        max_time_seconds: max_time,
1439    };
1440
1441    let usage = match fetch_usage(&usage_request) {
1442        Ok(value) => value,
1443        Err(err) => {
1444            let msg = err.to_string();
1445            if msg.contains("missing access_token") {
1446                return AsyncFetchResult {
1447                    line: None,
1448                    rc: 2,
1449                    err: format!(
1450                        "codex-rate-limits: missing access_token in {}",
1451                        target_file.display()
1452                    ),
1453                    ..Default::default()
1454                };
1455            }
1456            return AsyncFetchResult {
1457                line: None,
1458                rc: 3,
1459                err: msg,
1460                ..Default::default()
1461            };
1462        }
1463    };
1464
1465    if should_writeback_usage(target_file)
1466        && let Err(err) = writeback::write_weekly(target_file, &usage.json)
1467    {
1468        return AsyncFetchResult {
1469            line: None,
1470            rc: 4,
1471            err: err.to_string(),
1472            ..Default::default()
1473        };
1474    }
1475
1476    if is_auth_file(target_file) {
1477        match sync_auth_silent() {
1478            Ok((sync_rc, sync_err)) => {
1479                if sync_rc != 0 {
1480                    return AsyncFetchResult {
1481                        line: None,
1482                        rc: 5,
1483                        err: sync_err.unwrap_or_default(),
1484                        ..Default::default()
1485                    };
1486                }
1487            }
1488            Err(_) => {
1489                return AsyncFetchResult {
1490                    line: None,
1491                    rc: 1,
1492                    ..Default::default()
1493                };
1494            }
1495        }
1496    }
1497
1498    let usage_data = match render::parse_usage(&usage.json) {
1499        Some(value) => value,
1500        None => {
1501            if render::rate_limit_is_explicit_null(&usage.json) {
1502                return AsyncFetchResult {
1503                    rc: RC_NO_RATE_LIMIT_WINDOW,
1504                    ..Default::default()
1505                };
1506            }
1507            return AsyncFetchResult {
1508                line: None,
1509                rc: 3,
1510                err: "codex-rate-limits: invalid usage payload".to_string(),
1511                ..Default::default()
1512            };
1513        }
1514    };
1515
1516    let values = render::render_values(&usage_data);
1517    let weekly = render::weekly_values(&values);
1518
1519    let fetched_at_epoch = Utc::now().timestamp();
1520    if fetched_at_epoch > 0 {
1521        let _ = cache::write_prompt_segment_cache(
1522            target_file,
1523            fetched_at_epoch,
1524            &weekly.non_weekly_label,
1525            weekly.non_weekly_remaining,
1526            weekly.weekly_remaining,
1527            weekly.weekly_reset_epoch,
1528            weekly.non_weekly_reset_epoch,
1529        );
1530    }
1531
1532    AsyncFetchResult {
1533        line: Some(format_one_line_output(
1534            target_file,
1535            &weekly.non_weekly_label,
1536            weekly.non_weekly_remaining,
1537            weekly.weekly_remaining,
1538            weekly.weekly_reset_epoch,
1539        )),
1540        rc: 0,
1541        ..Default::default()
1542    }
1543}
1544
1545fn fetch_one_line_cached(target_file: &Path) -> AsyncFetchResult {
1546    match cache::read_cache_entry_for_cached_mode(target_file) {
1547        Ok(entry) => AsyncFetchResult {
1548            line: Some(format_one_line_output(
1549                target_file,
1550                &entry.non_weekly_label,
1551                entry.non_weekly_remaining,
1552                entry.weekly_remaining,
1553                entry.weekly_reset_epoch,
1554            )),
1555            rc: 0,
1556            ..Default::default()
1557        },
1558        Err(err) => AsyncFetchResult {
1559            line: None,
1560            rc: 1,
1561            err: err.to_string(),
1562            ..Default::default()
1563        },
1564    }
1565}
1566
1567/// Reads the cached one-line value without enforcing the freshness TTL, marking
1568/// the result `stale` when it is past the TTL. Used as the diag-path fallback
1569/// so a transient null/failed live fetch degrades to the last-known values.
1570fn fetch_one_line_cached_allow_stale(target_file: &Path) -> AsyncFetchResult {
1571    match cache::read_cache_entry_allow_stale(target_file) {
1572        Ok(read) => AsyncFetchResult {
1573            line: Some(format_one_line_output(
1574                target_file,
1575                &read.entry.non_weekly_label,
1576                read.entry.non_weekly_remaining,
1577                read.entry.weekly_remaining,
1578                read.entry.weekly_reset_epoch,
1579            )),
1580            rc: 0,
1581            stale: read.stale,
1582            ..Default::default()
1583        },
1584        Err(err) => AsyncFetchResult {
1585            line: None,
1586            rc: 1,
1587            err: err.to_string(),
1588            ..Default::default()
1589        },
1590    }
1591}
1592
1593fn format_one_line_output(
1594    target_file: &Path,
1595    non_weekly_label: &str,
1596    non_weekly_remaining: i64,
1597    weekly_remaining: i64,
1598    weekly_reset_epoch: i64,
1599) -> String {
1600    let _ = target_file;
1601    let weekly_reset_iso =
1602        render::format_epoch_local_datetime(weekly_reset_epoch).unwrap_or_else(|| "?".to_string());
1603
1604    format!(
1605        "{}:{}% W:{}% {}",
1606        non_weekly_label, non_weekly_remaining, weekly_remaining, weekly_reset_iso
1607    )
1608}
1609
1610fn normalize_one_line(line: String) -> String {
1611    line.replace(['\n', '\r', '\t'], " ")
1612}
1613
1614fn sync_auth_silent() -> Result<(i32, Option<String>)> {
1615    let auth_file = match crate::paths::resolve_auth_file() {
1616        Some(path) => path,
1617        None => return Ok((0, None)),
1618    };
1619
1620    let sync_result = match sync_auth_to_matching_secrets(
1621        &CODEX_PROVIDER_PROFILE,
1622        &auth_file,
1623        fs::SECRET_FILE_MODE,
1624        TimestampPolicy::Strict,
1625    ) {
1626        Ok(result) => result,
1627        Err(SyncSecretsError::HashAuthFile { path, .. })
1628        | Err(SyncSecretsError::HashSecretFile { path, .. }) => {
1629            return Ok((1, Some(format!("codex: failed to hash {}", path.display()))));
1630        }
1631        Err(err) => return Err(err.into()),
1632    };
1633    if !sync_result.auth_file_present || !sync_result.auth_identity_present {
1634        return Ok((0, None));
1635    }
1636
1637    Ok((0, None))
1638}
1639
1640fn maybe_sync_all_mode_auth_silent(debug_mode: bool) {
1641    match sync_auth_silent() {
1642        Ok((0, _)) => {}
1643        Ok((_, sync_err)) => {
1644            if debug_mode
1645                && let Some(message) = sync_err
1646                && !message.trim().is_empty()
1647            {
1648                eprintln!("{message}");
1649            }
1650        }
1651        Err(err) => {
1652            if debug_mode {
1653                eprintln!("codex-rate-limits: failed to sync auth and secrets: {err}");
1654            }
1655        }
1656    }
1657}
1658
1659fn run_all_mode(args: &RateLimitsOptions, cached_mode: bool, debug_mode: bool) -> Result<i32> {
1660    let secret_dir = crate::paths::resolve_secret_dir().unwrap_or_default();
1661    if !secret_dir.is_dir() {
1662        eprintln!(
1663            "codex-rate-limits: CODEX_SECRET_DIR not found: {}",
1664            secret_dir.display()
1665        );
1666        return Ok(1);
1667    }
1668
1669    let mut secret_files: Vec<PathBuf> = std::fs::read_dir(&secret_dir)?
1670        .flatten()
1671        .map(|entry| entry.path())
1672        .filter(|path| path.extension().and_then(|s| s.to_str()) == Some("json"))
1673        .collect();
1674
1675    if secret_files.is_empty() {
1676        eprintln!(
1677            "codex-rate-limits: no secrets found in {}",
1678            secret_dir.display()
1679        );
1680        return Ok(1);
1681    }
1682
1683    secret_files.sort();
1684
1685    let current_name = current_secret_basename(&secret_files);
1686
1687    let total = secret_files.len();
1688    let progress = if total > 1 {
1689        Some(Progress::new(
1690            total as u64,
1691            ProgressOptions::default()
1692                .with_prefix("codex-rate-limits ")
1693                .with_finish(ProgressFinish::Clear),
1694        ))
1695    } else {
1696        None
1697    };
1698
1699    let mut rc = 0;
1700    let mut rows: Vec<Row> = Vec::new();
1701    let mut window_labels = std::collections::HashSet::new();
1702
1703    for secret_file in secret_files {
1704        let secret_name = secret_file
1705            .file_name()
1706            .and_then(|name| name.to_str())
1707            .unwrap_or("")
1708            .to_string();
1709        if let Some(progress) = &progress {
1710            progress.set_message(secret_name.clone());
1711        }
1712
1713        let mut row = Row::empty(secret_name.trim_end_matches(".json").to_string());
1714        let output =
1715            match single_one_line(&secret_file, cached_mode, args.no_refresh_auth, debug_mode) {
1716                Ok(Some(line)) => line,
1717                Ok(None) => String::new(),
1718                Err(_) => String::new(),
1719            };
1720
1721        if output.is_empty() {
1722            if !cached_mode {
1723                rc = 1;
1724            }
1725            rows.push(row);
1726            continue;
1727        }
1728
1729        if let Some(parsed) = parse_one_line_output(&output) {
1730            row.window_label = parsed.window_label.clone();
1731            row.non_weekly_remaining = parsed.non_weekly_remaining;
1732            row.weekly_remaining = parsed.weekly_remaining;
1733            row.weekly_reset_iso = parsed.weekly_reset_iso.clone();
1734
1735            if cached_mode {
1736                if let Ok(cache_entry) = cache::read_cache_entry_for_cached_mode(&secret_file) {
1737                    row.non_weekly_reset_epoch = cache_entry.non_weekly_reset_epoch;
1738                    row.weekly_reset_epoch = Some(cache_entry.weekly_reset_epoch);
1739                }
1740            } else {
1741                let values = crate::json::read_json(&secret_file).ok();
1742                if let Some(values) = values {
1743                    row.non_weekly_reset_epoch = crate::json::i64_at(
1744                        &values,
1745                        &["codex_rate_limits", "non_weekly_reset_at_epoch"],
1746                    );
1747                    row.weekly_reset_epoch = crate::json::i64_at(
1748                        &values,
1749                        &["codex_rate_limits", "weekly_reset_at_epoch"],
1750                    );
1751                }
1752            }
1753
1754            window_labels.insert(row.window_label.clone());
1755            rows.push(row);
1756        } else {
1757            if !cached_mode {
1758                rc = 1;
1759            }
1760            rows.push(row);
1761        }
1762
1763        if let Some(progress) = &progress {
1764            progress.inc(1);
1765        }
1766    }
1767
1768    if let Some(progress) = progress {
1769        progress.finish_and_clear();
1770    }
1771
1772    println!("\n🚦 Codex rate limits for all accounts\n");
1773
1774    let mut non_weekly_header = "Non-weekly".to_string();
1775    let multiple_labels = window_labels.len() != 1;
1776    if !multiple_labels && let Some(label) = window_labels.iter().next() {
1777        non_weekly_header = label.clone();
1778    }
1779
1780    let now_epoch = Utc::now().timestamp();
1781
1782    println!(
1783        "{:<15}  {:>8}  {:>7}  {:>8}  {:>7}  {:<18}",
1784        "Name", non_weekly_header, "Left", "Weekly", "Left", "Reset"
1785    );
1786    println!("----------------------------------------------------------------------------");
1787
1788    rows.sort_by_key(|row| row.sort_key());
1789
1790    for row in rows {
1791        let display_non_weekly = if multiple_labels && !row.window_label.is_empty() {
1792            if row.non_weekly_remaining >= 0 {
1793                format!("{}:{}%", row.window_label, row.non_weekly_remaining)
1794            } else {
1795                "-".to_string()
1796            }
1797        } else if row.non_weekly_remaining >= 0 {
1798            format!("{}%", row.non_weekly_remaining)
1799        } else {
1800            "-".to_string()
1801        };
1802
1803        let non_weekly_left = row
1804            .non_weekly_reset_epoch
1805            .and_then(|epoch| render::format_until_epoch_compact(epoch, now_epoch))
1806            .unwrap_or_else(|| "-".to_string());
1807        let weekly_left = row
1808            .weekly_reset_epoch
1809            .and_then(|epoch| render::format_until_epoch_compact(epoch, now_epoch))
1810            .unwrap_or_else(|| "-".to_string());
1811        let reset_display = row
1812            .weekly_reset_epoch
1813            .and_then(render::format_epoch_local_datetime_with_offset)
1814            .unwrap_or_else(|| "-".to_string());
1815
1816        let non_weekly_display = ansi::format_percent_cell(&display_non_weekly, 8, None);
1817        let weekly_display = if row.weekly_remaining >= 0 {
1818            ansi::format_percent_cell(&format!("{}%", row.weekly_remaining), 8, None)
1819        } else {
1820            ansi::format_percent_cell("-", 8, None)
1821        };
1822
1823        let is_current = current_name.as_deref() == Some(row.name.as_str());
1824        let name_display = ansi::format_name_cell(&row.name, 15, is_current, None);
1825
1826        println!(
1827            "{}  {}  {:>7}  {}  {:>7}  {:<18}",
1828            name_display,
1829            non_weekly_display,
1830            non_weekly_left,
1831            weekly_display,
1832            weekly_left,
1833            reset_display
1834        );
1835    }
1836
1837    Ok(rc)
1838}
1839
1840fn current_secret_basename(secret_files: &[PathBuf]) -> Option<String> {
1841    let auth_file = crate::paths::resolve_auth_file()?;
1842    if !auth_file.is_file() {
1843        return None;
1844    }
1845
1846    let auth_key = auth::identity_key_from_auth_file(&auth_file).ok().flatten();
1847    let auth_hash = fs::sha256_file(&auth_file).ok();
1848
1849    if let Some(auth_hash) = auth_hash.as_deref() {
1850        for secret_file in secret_files {
1851            if let Ok(secret_hash) = fs::sha256_file(secret_file)
1852                && secret_hash == auth_hash
1853                && let Some(name) = secret_file.file_name().and_then(|name| name.to_str())
1854            {
1855                return Some(name.trim_end_matches(".json").to_string());
1856            }
1857        }
1858    }
1859
1860    if let Some(auth_key) = auth_key.as_deref() {
1861        for secret_file in secret_files {
1862            if let Ok(Some(candidate_key)) = auth::identity_key_from_auth_file(secret_file)
1863                && candidate_key == auth_key
1864                && let Some(name) = secret_file.file_name().and_then(|name| name.to_str())
1865            {
1866                return Some(name.trim_end_matches(".json").to_string());
1867            }
1868        }
1869    }
1870
1871    None
1872}
1873
1874fn run_single_mode(
1875    args: &RateLimitsOptions,
1876    cached_mode: bool,
1877    one_line: bool,
1878    output_json: bool,
1879) -> Result<i32> {
1880    let target_file = match resolve_target(args.secret.as_deref()) {
1881        Ok(path) => path,
1882        Err(code) => return Ok(code),
1883    };
1884
1885    if !target_file.is_file() {
1886        if output_json {
1887            diag_output::emit_error(
1888                DIAG_SCHEMA_VERSION,
1889                DIAG_COMMAND,
1890                "target-not-found",
1891                format!("codex-rate-limits: {} not found", target_file.display()),
1892                Some(serde_json::json!({
1893                    "target_file": target_file.display().to_string(),
1894                })),
1895            )?;
1896        } else {
1897            eprintln!("codex-rate-limits: {} not found", target_file.display());
1898        }
1899        return Ok(1);
1900    }
1901
1902    if cached_mode {
1903        match cache::read_cache_entry_for_cached_mode(&target_file) {
1904            Ok(entry) => {
1905                let weekly_reset_iso =
1906                    render::format_epoch_local_datetime(entry.weekly_reset_epoch)
1907                        .unwrap_or_else(|| "?".to_string());
1908                println!(
1909                    "{}:{}% W:{}% {}",
1910                    entry.non_weekly_label,
1911                    entry.non_weekly_remaining,
1912                    entry.weekly_remaining,
1913                    weekly_reset_iso
1914                );
1915                return Ok(0);
1916            }
1917            Err(err) => {
1918                eprintln!("{err}");
1919                return Ok(1);
1920            }
1921        }
1922    }
1923
1924    let base_url = std::env::var("CODEX_CHATGPT_BASE_URL")
1925        .unwrap_or_else(|_| "https://chatgpt.com/backend-api/".to_string());
1926    let connect_timeout = env_timeout("CODEX_RATE_LIMITS_CURL_CONNECT_TIMEOUT_SECONDS", 2);
1927    let max_time = env_timeout("CODEX_RATE_LIMITS_CURL_MAX_TIME_SECONDS", 8);
1928
1929    let usage_request = UsageRequest {
1930        target_file: target_file.clone(),
1931        refresh_on_401: refresh_on_401_enabled(args.no_refresh_auth),
1932        suppress_auth_refresh_output: false,
1933        base_url,
1934        connect_timeout_seconds: connect_timeout,
1935        max_time_seconds: max_time,
1936    };
1937
1938    let usage = match fetch_usage(&usage_request) {
1939        Ok(value) => value,
1940        Err(err) => {
1941            let msg = err.to_string();
1942            if msg.contains("missing access_token") {
1943                if output_json {
1944                    diag_output::emit_error(
1945                        DIAG_SCHEMA_VERSION,
1946                        DIAG_COMMAND,
1947                        "missing-access-token",
1948                        format!(
1949                            "codex-rate-limits: missing access_token in {}",
1950                            target_file.display()
1951                        ),
1952                        Some(serde_json::json!({
1953                            "target_file": target_file.display().to_string(),
1954                        })),
1955                    )?;
1956                } else {
1957                    eprintln!(
1958                        "codex-rate-limits: missing access_token in {}",
1959                        target_file.display()
1960                    );
1961                }
1962                return Ok(2);
1963            }
1964            if output_json {
1965                diag_output::emit_error(
1966                    DIAG_SCHEMA_VERSION,
1967                    DIAG_COMMAND,
1968                    "request-failed",
1969                    msg,
1970                    Some(serde_json::json!({
1971                        "target_file": target_file.display().to_string(),
1972                    })),
1973                )?;
1974            } else {
1975                eprintln!("{msg}");
1976            }
1977            return Ok(3);
1978        }
1979    };
1980
1981    if should_writeback_usage(&target_file)
1982        && let Err(err) = writeback::write_weekly(&target_file, &usage.json)
1983    {
1984        if output_json {
1985            diag_output::emit_error(
1986                DIAG_SCHEMA_VERSION,
1987                DIAG_COMMAND,
1988                "writeback-failed",
1989                err.to_string(),
1990                Some(serde_json::json!({
1991                    "target_file": target_file.display().to_string(),
1992                })),
1993            )?;
1994        } else {
1995            eprintln!("{err}");
1996        }
1997        return Ok(4);
1998    }
1999
2000    if is_auth_file(&target_file) {
2001        let sync_rc = auth::sync::run_with_json(false)?;
2002        if sync_rc != 0 {
2003            if output_json {
2004                diag_output::emit_error(
2005                    DIAG_SCHEMA_VERSION,
2006                    DIAG_COMMAND,
2007                    "sync-failed",
2008                    "codex-rate-limits: failed to sync auth file",
2009                    Some(serde_json::json!({
2010                        "target_file": target_file.display().to_string(),
2011                    })),
2012                )?;
2013            }
2014            return Ok(5);
2015        }
2016    }
2017
2018    let usage_data = match render::parse_usage(&usage.json) {
2019        Some(value) => value,
2020        None => {
2021            if render::rate_limit_is_explicit_null(&usage.json) {
2022                return emit_single_no_window(&target_file, output_json, one_line);
2023            }
2024            if output_json {
2025                diag_output::emit_error(
2026                    DIAG_SCHEMA_VERSION,
2027                    DIAG_COMMAND,
2028                    "invalid-usage-payload",
2029                    "codex-rate-limits: invalid usage payload",
2030                    Some(serde_json::json!({
2031                        "target_file": target_file.display().to_string(),
2032                        "raw_usage": redact_sensitive_json(&usage.json),
2033                    })),
2034                )?;
2035            } else {
2036                eprintln!("codex-rate-limits: invalid usage payload");
2037            }
2038            return Ok(3);
2039        }
2040    };
2041
2042    let values = render::render_values(&usage_data);
2043    let weekly = render::weekly_values(&values);
2044
2045    let fetched_at_epoch = Utc::now().timestamp();
2046    if fetched_at_epoch > 0 {
2047        let _ = cache::write_prompt_segment_cache(
2048            &target_file,
2049            fetched_at_epoch,
2050            &weekly.non_weekly_label,
2051            weekly.non_weekly_remaining,
2052            weekly.weekly_remaining,
2053            weekly.weekly_reset_epoch,
2054            weekly.non_weekly_reset_epoch,
2055        );
2056    }
2057
2058    if output_json {
2059        let windows = windows_from_usage_values(&usage_data, &values);
2060        let result = RateLimitJsonResult {
2061            provider: "codex".to_string(),
2062            name: secret_display_name(&target_file),
2063            target_file: target_file_name(&target_file),
2064            status: "ok".to_string(),
2065            ok: true,
2066            source: "network".to_string(),
2067            summary: Some(RateLimitSummary {
2068                non_weekly_label: weekly.non_weekly_label,
2069                non_weekly_remaining: weekly.non_weekly_remaining,
2070                non_weekly_reset_epoch: weekly.non_weekly_reset_epoch,
2071                weekly_remaining: weekly.weekly_remaining,
2072                weekly_reset_epoch: weekly.weekly_reset_epoch,
2073                weekly_reset_local: render::format_epoch_local_datetime_with_offset(
2074                    weekly.weekly_reset_epoch,
2075                ),
2076            }),
2077            windows: Some(windows),
2078            raw_usage: Some(redact_sensitive_json(&usage.json)),
2079            error: None,
2080        };
2081        diag_output::emit_json(&RateLimitSingleEnvelope {
2082            schema_version: DIAG_SCHEMA_VERSION.to_string(),
2083            command: DIAG_COMMAND.to_string(),
2084            mode: "single".to_string(),
2085            ok: true,
2086            result,
2087        })?;
2088        return Ok(0);
2089    }
2090
2091    if one_line {
2092        let weekly_reset_iso = render::format_epoch_local_datetime(weekly.weekly_reset_epoch)
2093            .unwrap_or_else(|| "?".to_string());
2094
2095        println!(
2096            "{}:{}% W:{}% {}",
2097            weekly.non_weekly_label,
2098            weekly.non_weekly_remaining,
2099            weekly.weekly_remaining,
2100            weekly_reset_iso
2101        );
2102        return Ok(0);
2103    }
2104
2105    println!("Rate limits remaining");
2106    let primary_reset = render::format_epoch_local_datetime(values.primary_reset_epoch)
2107        .unwrap_or_else(|| "?".to_string());
2108    let secondary_reset = render::format_epoch_local_datetime(values.secondary_reset_epoch)
2109        .unwrap_or_else(|| "?".to_string());
2110
2111    println!(
2112        "{} {}% • {}",
2113        values.primary_label, values.primary_remaining, primary_reset
2114    );
2115    println!(
2116        "{} {}% • {}",
2117        values.secondary_label, values.secondary_remaining, secondary_reset
2118    );
2119
2120    Ok(0)
2121}
2122
2123/// Renders a benign `rate_limit: null` response in single mode: serve the
2124/// last-known cached values (marked stale) when available, otherwise report
2125/// "no active rate-limit window" as a success rather than a malformed payload.
2126fn emit_single_no_window(target_file: &Path, output_json: bool, one_line: bool) -> Result<i32> {
2127    if let Ok(read) = cache::read_cache_entry_allow_stale(target_file) {
2128        if output_json {
2129            let result = collect_json_from_cache(target_file, "cache-fallback", false);
2130            let ok = result.ok;
2131            diag_output::emit_json(&RateLimitSingleEnvelope {
2132                schema_version: DIAG_SCHEMA_VERSION.to_string(),
2133                command: DIAG_COMMAND.to_string(),
2134                mode: "single".to_string(),
2135                ok,
2136                result,
2137            })?;
2138            return Ok(0);
2139        }
2140
2141        let entry = read.entry;
2142        let stale_suffix = if read.stale { " (stale)" } else { "" };
2143        if one_line {
2144            let weekly_reset_iso = render::format_epoch_local_datetime(entry.weekly_reset_epoch)
2145                .unwrap_or_else(|| "?".to_string());
2146            println!(
2147                "{}:{}% W:{}% {}{}",
2148                entry.non_weekly_label,
2149                entry.non_weekly_remaining,
2150                entry.weekly_remaining,
2151                weekly_reset_iso,
2152                stale_suffix
2153            );
2154            return Ok(0);
2155        }
2156
2157        let weekly_reset = render::format_epoch_local_datetime(entry.weekly_reset_epoch)
2158            .unwrap_or_else(|| "?".to_string());
2159        let non_weekly_reset = entry
2160            .non_weekly_reset_epoch
2161            .and_then(render::format_epoch_local_datetime)
2162            .unwrap_or_else(|| "?".to_string());
2163        println!("Rate limits remaining{stale_suffix}");
2164        println!(
2165            "{} {}% • {}",
2166            entry.non_weekly_label, entry.non_weekly_remaining, non_weekly_reset
2167        );
2168        println!("Weekly {}% • {}", entry.weekly_remaining, weekly_reset);
2169        return Ok(0);
2170    }
2171
2172    if output_json {
2173        let result = json_result_no_window(target_file);
2174        diag_output::emit_json(&RateLimitSingleEnvelope {
2175            schema_version: DIAG_SCHEMA_VERSION.to_string(),
2176            command: DIAG_COMMAND.to_string(),
2177            mode: "single".to_string(),
2178            ok: true,
2179            result,
2180        })?;
2181        return Ok(0);
2182    }
2183
2184    println!("No active rate-limit window");
2185    Ok(0)
2186}
2187
2188fn single_one_line(
2189    target_file: &Path,
2190    cached_mode: bool,
2191    no_refresh_auth: bool,
2192    debug_mode: bool,
2193) -> Result<Option<String>> {
2194    if !target_file.is_file() {
2195        if debug_mode {
2196            eprintln!("codex-rate-limits: target file not found");
2197        }
2198        return Ok(None);
2199    }
2200
2201    if cached_mode {
2202        return match cache::read_cache_entry_for_cached_mode(target_file) {
2203            Ok(entry) => {
2204                let weekly_reset_iso =
2205                    render::format_epoch_local_datetime(entry.weekly_reset_epoch)
2206                        .unwrap_or_else(|| "?".to_string());
2207                Ok(Some(format!(
2208                    "{}:{}% W:{}% {}",
2209                    entry.non_weekly_label,
2210                    entry.non_weekly_remaining,
2211                    entry.weekly_remaining,
2212                    weekly_reset_iso
2213                )))
2214            }
2215            Err(err) => {
2216                if debug_mode {
2217                    eprintln!("{err}");
2218                }
2219                Ok(None)
2220            }
2221        };
2222    }
2223
2224    let base_url = std::env::var("CODEX_CHATGPT_BASE_URL")
2225        .unwrap_or_else(|_| "https://chatgpt.com/backend-api/".to_string());
2226    let connect_timeout = env_timeout("CODEX_RATE_LIMITS_CURL_CONNECT_TIMEOUT_SECONDS", 2);
2227    let max_time = env_timeout("CODEX_RATE_LIMITS_CURL_MAX_TIME_SECONDS", 8);
2228
2229    let usage_request = UsageRequest {
2230        target_file: target_file.to_path_buf(),
2231        refresh_on_401: refresh_on_401_enabled(no_refresh_auth),
2232        suppress_auth_refresh_output: false,
2233        base_url,
2234        connect_timeout_seconds: connect_timeout,
2235        max_time_seconds: max_time,
2236    };
2237
2238    let usage = match fetch_usage(&usage_request) {
2239        Ok(value) => value,
2240        Err(err) => {
2241            if debug_mode {
2242                eprintln!("{err}");
2243            }
2244            return Ok(None);
2245        }
2246    };
2247
2248    if should_writeback_usage(target_file) {
2249        let _ = writeback::write_weekly(target_file, &usage.json);
2250    }
2251    if is_auth_file(target_file) {
2252        let _ = auth::sync::run();
2253    }
2254
2255    let usage_data = match render::parse_usage(&usage.json) {
2256        Some(value) => value,
2257        None => return Ok(None),
2258    };
2259    let values = render::render_values(&usage_data);
2260    let weekly = render::weekly_values(&values);
2261    let fetched_at_epoch = Utc::now().timestamp();
2262    if fetched_at_epoch > 0 {
2263        let _ = cache::write_prompt_segment_cache(
2264            target_file,
2265            fetched_at_epoch,
2266            &weekly.non_weekly_label,
2267            weekly.non_weekly_remaining,
2268            weekly.weekly_remaining,
2269            weekly.weekly_reset_epoch,
2270            weekly.non_weekly_reset_epoch,
2271        );
2272    }
2273    let weekly_reset_iso = render::format_epoch_local_datetime(weekly.weekly_reset_epoch)
2274        .unwrap_or_else(|| "?".to_string());
2275
2276    Ok(Some(format!(
2277        "{}:{}% W:{}% {}",
2278        weekly.non_weekly_label,
2279        weekly.non_weekly_remaining,
2280        weekly.weekly_remaining,
2281        weekly_reset_iso
2282    )))
2283}
2284
2285fn resolve_target(secret: Option<&str>) -> std::result::Result<PathBuf, i32> {
2286    if let Some(secret_name) = secret {
2287        if secret_name.is_empty() || secret_name.contains('/') || secret_name.contains("..") {
2288            eprintln!("codex-rate-limits: invalid secret file name");
2289            return Err(64);
2290        }
2291        let secret_dir = crate::paths::resolve_secret_dir().unwrap_or_default();
2292        return Ok(secret_dir.join(secret_name));
2293    }
2294
2295    if let Some(auth_file) = existing_active_auth_file() {
2296        return Ok(auth_file);
2297    }
2298
2299    if let Some(auth_file) = official_codex_auth_file() {
2300        return Ok(auth_file);
2301    }
2302
2303    if let Some(auth_file) = crate::paths::resolve_auth_file() {
2304        return Ok(auth_file);
2305    }
2306
2307    Err(1)
2308}
2309
2310fn is_auth_file(target_file: &Path) -> bool {
2311    if let Some(auth_file) = crate::paths::resolve_auth_file() {
2312        return auth_file == target_file;
2313    }
2314    false
2315}
2316
2317fn env_timeout(key: &str, default: u64) -> u64 {
2318    std::env::var(key)
2319        .ok()
2320        .and_then(|raw| raw.parse::<u64>().ok())
2321        .unwrap_or(default)
2322}
2323
2324#[derive(Clone, Copy, PartialEq, Eq)]
2325enum RowState {
2326    /// No value available (genuine fetch failure with no cache to fall back to).
2327    Missing,
2328    /// Fresh live (or within-TTL cache) values.
2329    Filled,
2330    /// Last-known values served from cache past the freshness TTL.
2331    Stale,
2332    /// Backend reported no active rate-limit window and no cache was available.
2333    NoWindow,
2334}
2335
2336struct Row {
2337    name: String,
2338    window_label: String,
2339    non_weekly_remaining: i64,
2340    non_weekly_reset_epoch: Option<i64>,
2341    weekly_remaining: i64,
2342    weekly_reset_epoch: Option<i64>,
2343    weekly_reset_iso: String,
2344    state: RowState,
2345}
2346
2347impl Row {
2348    fn empty(name: String) -> Self {
2349        Self {
2350            name,
2351            window_label: String::new(),
2352            non_weekly_remaining: -1,
2353            non_weekly_reset_epoch: None,
2354            weekly_remaining: -1,
2355            weekly_reset_epoch: None,
2356            weekly_reset_iso: String::new(),
2357            state: RowState::Missing,
2358        }
2359    }
2360
2361    fn sort_key(&self) -> (i32, i64, String) {
2362        if let Some(epoch) = self.weekly_reset_epoch {
2363            (0, epoch, self.name.clone())
2364        } else {
2365            (1, i64::MAX, self.name.clone())
2366        }
2367    }
2368}
2369
2370struct ParsedOneLine {
2371    window_label: String,
2372    non_weekly_remaining: i64,
2373    weekly_remaining: i64,
2374    weekly_reset_iso: String,
2375}
2376
2377fn parse_one_line_output(line: &str) -> Option<ParsedOneLine> {
2378    let parts: Vec<&str> = line.split_whitespace().collect();
2379    if parts.len() < 3 {
2380        return None;
2381    }
2382
2383    fn parse_fields(
2384        window_field: &str,
2385        weekly_field: &str,
2386        reset_iso: String,
2387    ) -> Option<ParsedOneLine> {
2388        let window_label = window_field
2389            .split(':')
2390            .next()?
2391            .trim_matches('"')
2392            .to_string();
2393        let non_weekly_remaining = window_field.split(':').nth(1)?;
2394        let non_weekly_remaining = non_weekly_remaining
2395            .trim_end_matches('%')
2396            .parse::<i64>()
2397            .ok()?;
2398
2399        let weekly_remaining = weekly_field.trim_start_matches("W:").trim_end_matches('%');
2400        let weekly_remaining = weekly_remaining.parse::<i64>().ok()?;
2401
2402        Some(ParsedOneLine {
2403            window_label,
2404            non_weekly_remaining,
2405            weekly_remaining,
2406            weekly_reset_iso: reset_iso,
2407        })
2408    }
2409
2410    let len = parts.len();
2411    let window_field = parts[len - 3];
2412    let weekly_field = parts[len - 2];
2413    let reset_iso = parts[len - 1].to_string();
2414
2415    if let Some(parsed) = parse_fields(window_field, weekly_field, reset_iso) {
2416        return Some(parsed);
2417    }
2418
2419    if len < 4 {
2420        return None;
2421    }
2422
2423    parse_fields(
2424        parts[len - 4],
2425        parts[len - 3],
2426        format!("{} {}", parts[len - 2], parts[len - 1]),
2427    )
2428}
2429
2430#[cfg(test)]
2431mod tests {
2432    use super::{
2433        async_fetch_one_line, cache, collect_json_from_cache, collect_secret_files,
2434        collect_secret_files_for_async_text, current_secret_basename, env_timeout,
2435        fetch_one_line_cached, is_auth_file, normalize_one_line, parse_one_line_output,
2436        redact_sensitive_json, resolve_target, secret_display_name, single_one_line,
2437        sync_auth_silent, target_file_name,
2438    };
2439    use chrono::Utc;
2440    use nils_test_support::{EnvGuard, GlobalStateLock};
2441    use serde_json::json;
2442    use std::fs;
2443    use std::path::Path;
2444
2445    const HEADER: &str = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0";
2446    const PAYLOAD_ALPHA: &str = "eyJzdWIiOiJ1c2VyXzEyMyIsImVtYWlsIjoiYWxwaGFAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF91c2VyX2lkIjoidXNlcl8xMjMiLCJlbWFpbCI6ImFscGhhQGV4YW1wbGUuY29tIn19";
2447    const PAYLOAD_BETA: &str = "eyJzdWIiOiJ1c2VyXzQ1NiIsImVtYWlsIjoiYmV0YUBleGFtcGxlLmNvbSIsImh0dHBzOi8vYXBpLm9wZW5haS5jb20vYXV0aCI6eyJjaGF0Z3B0X3VzZXJfaWQiOiJ1c2VyXzQ1NiIsImVtYWlsIjoiYmV0YUBleGFtcGxlLmNvbSJ9fQ";
2448
2449    fn token(payload: &str) -> String {
2450        format!("{HEADER}.{payload}.sig")
2451    }
2452
2453    fn auth_json(
2454        payload: &str,
2455        account_id: &str,
2456        refresh_token: &str,
2457        last_refresh: &str,
2458    ) -> String {
2459        format!(
2460            r#"{{"tokens":{{"access_token":"{}","id_token":"{}","refresh_token":"{}","account_id":"{}"}},"last_refresh":"{}"}}"#,
2461            token(payload),
2462            token(payload),
2463            refresh_token,
2464            account_id,
2465            last_refresh
2466        )
2467    }
2468
2469    fn fresh_fetched_at() -> i64 {
2470        Utc::now().timestamp()
2471    }
2472
2473    #[test]
2474    fn redact_sensitive_json_removes_tokens_recursively() {
2475        let input = json!({
2476            "tokens": {
2477                "access_token": "a",
2478                "refresh_token": "b",
2479                "nested": {
2480                    "id_token": "c",
2481                    "Authorization": "Bearer x",
2482                    "ok": 1
2483                }
2484            },
2485            "items": [
2486                {"authorization": "Bearer y", "value": 2}
2487            ],
2488            "safe": true
2489        });
2490
2491        let redacted = redact_sensitive_json(&input);
2492        assert_eq!(redacted["tokens"]["nested"]["ok"], 1);
2493        assert_eq!(redacted["safe"], true);
2494        assert!(
2495            redacted["tokens"].get("access_token").is_none(),
2496            "access_token should be removed"
2497        );
2498        assert!(
2499            redacted["tokens"]["nested"].get("id_token").is_none(),
2500            "id_token should be removed"
2501        );
2502        assert!(
2503            redacted["tokens"]["nested"].get("Authorization").is_none(),
2504            "Authorization should be removed"
2505        );
2506        assert!(
2507            redacted["items"][0].get("authorization").is_none(),
2508            "authorization should be removed"
2509        );
2510    }
2511
2512    #[test]
2513    fn collect_secret_files_reports_missing_secret_dir() {
2514        let lock = GlobalStateLock::new();
2515        let dir = tempfile::TempDir::new().expect("tempdir");
2516        let missing = dir.path().join("missing");
2517        let _secret = EnvGuard::set(
2518            &lock,
2519            "CODEX_SECRET_DIR",
2520            missing.to_str().expect("missing path"),
2521        );
2522
2523        let err = collect_secret_files().expect_err("expected missing dir error");
2524        assert_eq!(err.0, 1);
2525        assert!(err.1.contains("CODEX_SECRET_DIR not found"));
2526    }
2527
2528    #[test]
2529    fn collect_secret_files_returns_sorted_json_files_only() {
2530        let lock = GlobalStateLock::new();
2531        let dir = tempfile::TempDir::new().expect("tempdir");
2532        let secrets = dir.path().join("secrets");
2533        fs::create_dir_all(&secrets).expect("secrets dir");
2534        fs::write(secrets.join("beta.json"), "{}").expect("write beta");
2535        fs::write(secrets.join("alpha.json"), "{}").expect("write alpha");
2536        fs::write(secrets.join("note.txt"), "ignore").expect("write note");
2537        let _secret = EnvGuard::set(
2538            &lock,
2539            "CODEX_SECRET_DIR",
2540            secrets.to_str().expect("secrets path"),
2541        );
2542
2543        let files = collect_secret_files().expect("secret files");
2544        assert_eq!(files.len(), 2);
2545        assert_eq!(
2546            files[0].file_name().and_then(|name| name.to_str()),
2547            Some("alpha.json")
2548        );
2549        assert_eq!(
2550            files[1].file_name().and_then(|name| name.to_str()),
2551            Some("beta.json")
2552        );
2553    }
2554
2555    #[test]
2556    fn collect_secret_files_for_async_text_allows_empty_secret_dir() {
2557        let lock = GlobalStateLock::new();
2558        let dir = tempfile::TempDir::new().expect("tempdir");
2559        let secret_dir = dir.path().join("secrets");
2560        fs::create_dir_all(&secret_dir).expect("secret dir");
2561        let _secret = EnvGuard::set(
2562            &lock,
2563            "CODEX_SECRET_DIR",
2564            secret_dir.to_str().expect("secret"),
2565        );
2566
2567        let files = collect_secret_files_for_async_text().expect("async text secret files");
2568        assert!(files.is_empty());
2569    }
2570
2571    #[test]
2572    fn rate_limits_helper_env_timeout_supports_default_and_parse() {
2573        let lock = GlobalStateLock::new();
2574        let key = "CODEX_RATE_LIMITS_CURL_MAX_TIME_SECONDS";
2575
2576        let _removed = EnvGuard::remove(&lock, key);
2577        assert_eq!(env_timeout(key, 7), 7);
2578
2579        let _set = EnvGuard::set(&lock, key, "11");
2580        assert_eq!(env_timeout(key, 7), 11);
2581
2582        let _invalid = EnvGuard::set(&lock, key, "oops");
2583        assert_eq!(env_timeout(key, 7), 7);
2584    }
2585
2586    #[test]
2587    fn rate_limits_helper_resolve_target_and_is_auth_file() {
2588        let lock = GlobalStateLock::new();
2589        let dir = tempfile::TempDir::new().expect("tempdir");
2590        let secret_dir = dir.path().join("secrets");
2591        fs::create_dir_all(&secret_dir).expect("secret dir");
2592        let auth_file = dir.path().join("auth.json");
2593        fs::write(&auth_file, "{}").expect("auth");
2594
2595        let _secret = EnvGuard::set(
2596            &lock,
2597            "CODEX_SECRET_DIR",
2598            secret_dir.to_str().expect("secret"),
2599        );
2600        let _auth = EnvGuard::set(&lock, "CODEX_AUTH_FILE", auth_file.to_str().expect("auth"));
2601
2602        assert_eq!(
2603            resolve_target(Some("alpha.json")).expect("target"),
2604            secret_dir.join("alpha.json")
2605        );
2606        assert_eq!(resolve_target(Some("../bad")).expect_err("usage"), 64);
2607        assert_eq!(resolve_target(None).expect("auth default"), auth_file);
2608        assert!(is_auth_file(&auth_file));
2609        assert!(!is_auth_file(&secret_dir.join("alpha.json")));
2610    }
2611
2612    #[test]
2613    fn rate_limits_helper_resolve_target_without_auth_returns_default_active_path() {
2614        let lock = GlobalStateLock::new();
2615        let dir = tempfile::TempDir::new().expect("tempdir");
2616        let home = dir.path().join("home");
2617        let codex_home = dir.path().join("codex-home");
2618        let _auth = EnvGuard::remove(&lock, "CODEX_AUTH_FILE");
2619        let _codex_home = EnvGuard::set(&lock, "CODEX_HOME", codex_home.to_str().expect("home"));
2620        let _home = EnvGuard::set(&lock, "HOME", home.to_str().expect("home"));
2621
2622        assert_eq!(
2623            resolve_target(None).expect("default active auth"),
2624            home.join(".agents").join("auth.json")
2625        );
2626    }
2627
2628    #[test]
2629    fn rate_limits_helper_collect_json_from_cache_covers_hit_and_miss() {
2630        let lock = GlobalStateLock::new();
2631        let dir = tempfile::TempDir::new().expect("tempdir");
2632        let secret_dir = dir.path().join("secrets");
2633        let cache_root = dir.path().join("cache-root");
2634        fs::create_dir_all(&secret_dir).expect("secrets");
2635        fs::create_dir_all(&cache_root).expect("cache");
2636
2637        let alpha = secret_dir.join("alpha.json");
2638        fs::write(&alpha, "{}").expect("alpha");
2639
2640        let _secret = EnvGuard::set(
2641            &lock,
2642            "CODEX_SECRET_DIR",
2643            secret_dir.to_str().expect("secret"),
2644        );
2645        let _cache = EnvGuard::set(&lock, "ZSH_CACHE_DIR", cache_root.to_str().expect("cache"));
2646        cache::write_prompt_segment_cache(
2647            &alpha,
2648            fresh_fetched_at(),
2649            "3h",
2650            92,
2651            88,
2652            1_700_003_600,
2653            Some(1_700_001_200),
2654        )
2655        .expect("write cache");
2656
2657        let hit = collect_json_from_cache(&alpha, "cache", true);
2658        assert!(hit.ok);
2659        assert_eq!(hit.status, "ok");
2660        let summary = hit.summary.expect("summary");
2661        assert_eq!(summary.non_weekly_label, "3h");
2662        assert_eq!(summary.non_weekly_remaining, 92);
2663        assert_eq!(summary.weekly_remaining, 88);
2664
2665        let missing_target = secret_dir.join("missing.json");
2666        let miss = collect_json_from_cache(&missing_target, "cache", true);
2667        assert!(!miss.ok);
2668        let error = miss.error.expect("error");
2669        assert_eq!(error.code, "cache-read-failed");
2670        assert!(error.message.contains("cache not found"));
2671    }
2672
2673    #[test]
2674    fn rate_limits_helper_fetch_one_line_cached_covers_success_and_error() {
2675        let lock = GlobalStateLock::new();
2676        let dir = tempfile::TempDir::new().expect("tempdir");
2677        let secret_dir = dir.path().join("secrets");
2678        let cache_root = dir.path().join("cache-root");
2679        fs::create_dir_all(&secret_dir).expect("secrets");
2680        fs::create_dir_all(&cache_root).expect("cache");
2681
2682        let alpha = secret_dir.join("alpha.json");
2683        fs::write(&alpha, "{}").expect("alpha");
2684
2685        let _secret = EnvGuard::set(
2686            &lock,
2687            "CODEX_SECRET_DIR",
2688            secret_dir.to_str().expect("secret"),
2689        );
2690        let _cache = EnvGuard::set(&lock, "ZSH_CACHE_DIR", cache_root.to_str().expect("cache"));
2691        cache::write_prompt_segment_cache(
2692            &alpha,
2693            fresh_fetched_at(),
2694            "3h",
2695            70,
2696            55,
2697            1_700_003_600,
2698            Some(1_700_001_200),
2699        )
2700        .expect("write cache");
2701
2702        let cached = fetch_one_line_cached(&alpha);
2703        assert_eq!(cached.rc, 0);
2704        assert!(cached.err.is_empty());
2705        assert!(cached.line.expect("line").contains("3h:70%"));
2706
2707        let miss = fetch_one_line_cached(&secret_dir.join("beta.json"));
2708        assert_eq!(miss.rc, 1);
2709        assert!(miss.line.is_none());
2710        assert!(miss.err.contains("cache not found"));
2711    }
2712
2713    #[test]
2714    fn rate_limits_helper_async_fetch_one_line_uses_cache_fallback() {
2715        let lock = GlobalStateLock::new();
2716        let dir = tempfile::TempDir::new().expect("tempdir");
2717        let secret_dir = dir.path().join("secrets");
2718        let cache_root = dir.path().join("cache-root");
2719        fs::create_dir_all(&secret_dir).expect("secrets");
2720        fs::create_dir_all(&cache_root).expect("cache");
2721
2722        let missing = secret_dir.join("ghost.json");
2723        let _secret = EnvGuard::set(
2724            &lock,
2725            "CODEX_SECRET_DIR",
2726            secret_dir.to_str().expect("secret"),
2727        );
2728        let _cache = EnvGuard::set(&lock, "ZSH_CACHE_DIR", cache_root.to_str().expect("cache"));
2729        cache::write_prompt_segment_cache(
2730            &missing,
2731            fresh_fetched_at(),
2732            "3h",
2733            68,
2734            42,
2735            1_700_003_600,
2736            Some(1_700_001_200),
2737        )
2738        .expect("write cache");
2739
2740        let result = async_fetch_one_line(&missing, false, true, "ghost");
2741        assert_eq!(result.rc, 0);
2742        let line = result.line.expect("line");
2743        assert!(line.contains("3h:68%"));
2744        assert!(result.err.contains("falling back to cache"));
2745    }
2746
2747    #[test]
2748    fn rate_limits_helper_single_one_line_cached_mode_handles_hit_and_miss() {
2749        let lock = GlobalStateLock::new();
2750        let dir = tempfile::TempDir::new().expect("tempdir");
2751        let secret_dir = dir.path().join("secrets");
2752        let cache_root = dir.path().join("cache-root");
2753        fs::create_dir_all(&secret_dir).expect("secrets");
2754        fs::create_dir_all(&cache_root).expect("cache");
2755
2756        let alpha = secret_dir.join("alpha.json");
2757        let beta = secret_dir.join("beta.json");
2758        fs::write(&alpha, "{}").expect("alpha");
2759        fs::write(&beta, "{}").expect("beta");
2760
2761        let _secret = EnvGuard::set(
2762            &lock,
2763            "CODEX_SECRET_DIR",
2764            secret_dir.to_str().expect("secret"),
2765        );
2766        let _cache = EnvGuard::set(&lock, "ZSH_CACHE_DIR", cache_root.to_str().expect("cache"));
2767        cache::write_prompt_segment_cache(
2768            &alpha,
2769            fresh_fetched_at(),
2770            "3h",
2771            61,
2772            39,
2773            1_700_003_600,
2774            Some(1_700_001_200),
2775        )
2776        .expect("write cache");
2777
2778        let hit = single_one_line(&alpha, true, true, false).expect("single");
2779        assert!(hit.expect("line").contains("3h:61%"));
2780
2781        let miss = single_one_line(&beta, true, true, true).expect("single");
2782        assert!(miss.is_none());
2783
2784        let missing =
2785            single_one_line(&secret_dir.join("missing.json"), true, true, true).expect("single");
2786        assert!(missing.is_none());
2787    }
2788
2789    #[test]
2790    fn rate_limits_helper_sync_auth_silent_updates_matching_secret_and_timestamps() {
2791        let lock = GlobalStateLock::new();
2792        let dir = tempfile::TempDir::new().expect("tempdir");
2793        let secret_dir = dir.path().join("secrets");
2794        let cache_dir = dir.path().join("cache");
2795        fs::create_dir_all(&secret_dir).expect("secrets");
2796        fs::create_dir_all(&cache_dir).expect("cache");
2797
2798        let auth_file = dir.path().join("auth.json");
2799        let alpha = secret_dir.join("alpha.json");
2800        let beta = secret_dir.join("beta.json");
2801        fs::write(
2802            &auth_file,
2803            auth_json(
2804                PAYLOAD_ALPHA,
2805                "acct_001",
2806                "refresh_new",
2807                "2025-01-20T12:34:56Z",
2808            ),
2809        )
2810        .expect("auth");
2811        fs::write(
2812            &alpha,
2813            auth_json(
2814                PAYLOAD_ALPHA,
2815                "acct_001",
2816                "refresh_old",
2817                "2025-01-19T12:34:56Z",
2818            ),
2819        )
2820        .expect("alpha");
2821        fs::write(
2822            &beta,
2823            auth_json(
2824                PAYLOAD_BETA,
2825                "acct_002",
2826                "refresh_beta",
2827                "2025-01-18T12:34:56Z",
2828            ),
2829        )
2830        .expect("beta");
2831        fs::write(secret_dir.join("invalid.json"), "{invalid").expect("invalid");
2832        fs::write(secret_dir.join("note.txt"), "ignore").expect("note");
2833
2834        let _auth = EnvGuard::set(&lock, "CODEX_AUTH_FILE", auth_file.to_str().expect("auth"));
2835        let _secret = EnvGuard::set(
2836            &lock,
2837            "CODEX_SECRET_DIR",
2838            secret_dir.to_str().expect("secret"),
2839        );
2840        let _cache = EnvGuard::set(
2841            &lock,
2842            "CODEX_SECRET_CACHE_DIR",
2843            cache_dir.to_str().expect("cache"),
2844        );
2845
2846        let (rc, err) = sync_auth_silent().expect("sync");
2847        assert_eq!(rc, 0);
2848        assert!(err.is_none());
2849        assert_eq!(
2850            fs::read(&alpha).expect("alpha"),
2851            fs::read(&auth_file).expect("auth")
2852        );
2853        assert_ne!(
2854            fs::read(&beta).expect("beta"),
2855            fs::read(&auth_file).expect("auth")
2856        );
2857        assert!(cache_dir.join("alpha.json.timestamp").is_file());
2858        assert!(cache_dir.join("auth.json.timestamp").is_file());
2859    }
2860
2861    #[test]
2862    fn rate_limits_helper_parsers_and_name_helpers_cover_fallbacks() {
2863        let parsed =
2864            parse_one_line_output("alpha 3h:90% W:80% 2025-01-20 12:00:00+00:00").expect("parsed");
2865        assert_eq!(parsed.window_label, "3h");
2866        assert_eq!(parsed.non_weekly_remaining, 90);
2867        assert_eq!(parsed.weekly_remaining, 80);
2868        assert_eq!(parsed.weekly_reset_iso, "2025-01-20 12:00:00+00:00");
2869        assert!(parse_one_line_output("bad").is_none());
2870
2871        assert_eq!(normalize_one_line("a\tb\nc\r".to_string()), "a b c ");
2872        assert_eq!(target_file_name(Path::new("alpha.json")), "alpha.json");
2873        assert_eq!(target_file_name(Path::new("")), "");
2874        assert_eq!(secret_display_name(Path::new("alpha.json")), "alpha");
2875    }
2876
2877    #[test]
2878    fn rate_limits_helper_current_secret_basename_tracks_auth_switch() {
2879        let lock = GlobalStateLock::new();
2880        let dir = tempfile::TempDir::new().expect("tempdir");
2881        let secret_dir = dir.path().join("secrets");
2882        fs::create_dir_all(&secret_dir).expect("secrets");
2883
2884        let auth_file = dir.path().join("auth.json");
2885        let alpha = secret_dir.join("alpha.json");
2886        let beta = secret_dir.join("beta.json");
2887
2888        let alpha_json = auth_json(
2889            PAYLOAD_ALPHA,
2890            "acct_001",
2891            "refresh_alpha",
2892            "2025-01-20T12:34:56Z",
2893        );
2894        let beta_json = auth_json(
2895            PAYLOAD_BETA,
2896            "acct_002",
2897            "refresh_beta",
2898            "2025-01-21T12:34:56Z",
2899        );
2900        fs::write(&alpha, &alpha_json).expect("alpha");
2901        fs::write(&beta, &beta_json).expect("beta");
2902        fs::write(&auth_file, &alpha_json).expect("auth alpha");
2903
2904        let _auth = EnvGuard::set(&lock, "CODEX_AUTH_FILE", auth_file.to_str().expect("auth"));
2905
2906        let secret_files = vec![alpha.clone(), beta.clone()];
2907        assert_eq!(
2908            current_secret_basename(&secret_files).as_deref(),
2909            Some("alpha")
2910        );
2911
2912        fs::write(&auth_file, &beta_json).expect("auth beta");
2913        assert_eq!(
2914            current_secret_basename(&secret_files).as_deref(),
2915            Some("beta")
2916        );
2917    }
2918}