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