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