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");
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");
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(target_file: &Path, source: &str) -> RateLimitJsonResult {
488    match cache::read_cache_entry(target_file) {
489        Ok(entry) => RateLimitJsonResult {
490            name: secret_display_name(target_file),
491            target_file: target_file_name(target_file),
492            status: "ok".to_string(),
493            ok: true,
494            source: source.to_string(),
495            summary: Some(summary_from_cache(&entry)),
496            raw_usage: None,
497            error: None,
498        },
499        Err(err) => json_result_error(
500            target_file,
501            source,
502            "cache-read-failed",
503            err.to_string(),
504            None,
505        ),
506    }
507}
508
509fn json_result_error(
510    target_file: &Path,
511    source: &str,
512    code: &str,
513    message: String,
514    details: Option<Value>,
515) -> RateLimitJsonResult {
516    RateLimitJsonResult {
517        name: secret_display_name(target_file),
518        target_file: target_file_name(target_file),
519        status: "error".to_string(),
520        ok: false,
521        source: source.to_string(),
522        summary: None,
523        raw_usage: None,
524        error: Some(diag_output::ErrorEnvelope {
525            code: code.to_string(),
526            message,
527            details,
528        }),
529    }
530}
531
532fn secret_display_name(target_file: &Path) -> String {
533    cache::secret_name_for_target(target_file).unwrap_or_else(|| {
534        target_file
535            .file_name()
536            .and_then(|name| name.to_str())
537            .unwrap_or_default()
538            .trim_end_matches(".json")
539            .to_string()
540    })
541}
542
543fn target_file_name(target_file: &Path) -> String {
544    target_file
545        .file_name()
546        .and_then(|name| name.to_str())
547        .unwrap_or_default()
548        .to_string()
549}
550
551fn summary_from_usage(usage_json: &Value) -> Option<RateLimitSummary> {
552    let usage_data = render::parse_usage(usage_json)?;
553    let values = render::render_values(&usage_data);
554    let weekly = render::weekly_values(&values);
555    Some(RateLimitSummary {
556        non_weekly_label: weekly.non_weekly_label,
557        non_weekly_remaining: weekly.non_weekly_remaining,
558        non_weekly_reset_epoch: weekly.non_weekly_reset_epoch,
559        weekly_remaining: weekly.weekly_remaining,
560        weekly_reset_epoch: weekly.weekly_reset_epoch,
561        weekly_reset_local: render::format_epoch_local_datetime_with_offset(
562            weekly.weekly_reset_epoch,
563        ),
564    })
565}
566
567fn summary_from_cache(entry: &cache::CacheEntry) -> RateLimitSummary {
568    RateLimitSummary {
569        non_weekly_label: entry.non_weekly_label.clone(),
570        non_weekly_remaining: entry.non_weekly_remaining,
571        non_weekly_reset_epoch: entry.non_weekly_reset_epoch,
572        weekly_remaining: entry.weekly_remaining,
573        weekly_reset_epoch: entry.weekly_reset_epoch,
574        weekly_reset_local: render::format_epoch_local_datetime_with_offset(
575            entry.weekly_reset_epoch,
576        ),
577    }
578}
579
580fn redact_sensitive_json(value: &Value) -> Value {
581    match value {
582        Value::Object(map) => {
583            let mut next = serde_json::Map::new();
584            for (key, val) in map {
585                if is_sensitive_key(key) {
586                    continue;
587                }
588                next.insert(key.clone(), redact_sensitive_json(val));
589            }
590            Value::Object(next)
591        }
592        Value::Array(items) => Value::Array(items.iter().map(redact_sensitive_json).collect()),
593        _ => value.clone(),
594    }
595}
596
597fn is_sensitive_key(key: &str) -> bool {
598    matches!(
599        key,
600        "access_token" | "refresh_token" | "id_token" | "authorization" | "Authorization"
601    )
602}
603
604struct AsyncEvent {
605    secret_name: String,
606    line: Option<String>,
607    rc: i32,
608    err: String,
609}
610
611struct AsyncFetchResult {
612    line: Option<String>,
613    rc: i32,
614    err: String,
615}
616
617fn run_async_mode(args: &RateLimitsOptions, debug_mode: bool) -> Result<i32> {
618    run_async_mode_impl(args, debug_mode, false)
619}
620
621fn run_async_watch_mode(args: &RateLimitsOptions, debug_mode: bool) -> Result<i32> {
622    run_async_mode_impl(args, debug_mode, true)
623}
624
625fn run_async_mode_impl(
626    args: &RateLimitsOptions,
627    debug_mode: bool,
628    watch_mode: bool,
629) -> Result<i32> {
630    if args.json {
631        eprintln!("codex-rate-limits: --async does not support --json");
632        return Ok(64);
633    }
634    if args.one_line {
635        eprintln!("codex-rate-limits: --async does not support --one-line");
636        return Ok(64);
637    }
638    if let Some(secret) = args.secret.as_deref() {
639        eprintln!(
640            "codex-rate-limits: --async does not accept positional args: {}",
641            secret
642        );
643        eprintln!(
644            "codex-rate-limits: hint: async always queries all secrets under CODEX_SECRET_DIR"
645        );
646        return Ok(64);
647    }
648    if args.clear_cache && args.cached {
649        eprintln!("codex-rate-limits: --async: -c is not compatible with --cached");
650        return Ok(64);
651    }
652
653    let jobs = args
654        .jobs
655        .as_deref()
656        .and_then(|raw| raw.parse::<i64>().ok())
657        .filter(|value| *value > 0)
658        .map(|value| value as usize)
659        .unwrap_or(5);
660
661    if args.clear_cache
662        && let Err(err) = cache::clear_starship_cache()
663    {
664        eprintln!("{err}");
665        return Ok(1);
666    }
667
668    let secret_dir = crate::paths::resolve_secret_dir().unwrap_or_default();
669    if !secret_dir.is_dir() {
670        eprintln!(
671            "codex-rate-limits-async: CODEX_SECRET_DIR not found: {}",
672            secret_dir.display()
673        );
674        return Ok(1);
675    }
676
677    let mut secret_files: Vec<PathBuf> = std::fs::read_dir(&secret_dir)?
678        .flatten()
679        .map(|entry| entry.path())
680        .filter(|path| path.extension().and_then(|s| s.to_str()) == Some("json"))
681        .collect();
682
683    if secret_files.is_empty() {
684        eprintln!(
685            "codex-rate-limits-async: no secrets found in {}",
686            secret_dir.display()
687        );
688        return Ok(1);
689    }
690
691    secret_files.sort();
692
693    let current_name = current_secret_basename(&secret_files);
694
695    if !watch_mode {
696        let round = collect_async_round(&secret_files, args.cached, args.no_refresh_auth, jobs);
697        render_all_accounts_table(
698            round.rows,
699            &round.window_labels,
700            current_name.as_deref(),
701            None,
702        );
703        emit_async_debug(debug_mode, &secret_files, &round.stderr_map);
704        return Ok(round.rc);
705    }
706
707    let mut overall_rc = 0;
708    let mut rendered_rounds = 0u64;
709    let max_rounds = watch_max_rounds_for_test();
710    let is_terminal_stdout = std::io::stdout().is_terminal();
711
712    loop {
713        let round = collect_async_round(&secret_files, args.cached, args.no_refresh_auth, jobs);
714        if round.rc != 0 {
715            overall_rc = 1;
716        }
717
718        if is_terminal_stdout {
719            print!("{ANSI_CLEAR_SCREEN_AND_HOME}");
720        }
721
722        let now_epoch = Utc::now().timestamp();
723        let update_time = format_watch_update_time(now_epoch);
724        render_all_accounts_table(
725            round.rows,
726            &round.window_labels,
727            current_name.as_deref(),
728            Some(update_time.as_str()),
729        );
730        emit_async_debug(debug_mode, &secret_files, &round.stderr_map);
731        let _ = std::io::stdout().flush();
732
733        rendered_rounds += 1;
734        if let Some(limit) = max_rounds
735            && rendered_rounds >= limit
736        {
737            break;
738        }
739
740        thread::sleep(Duration::from_secs(WATCH_INTERVAL_SECONDS));
741    }
742
743    Ok(overall_rc)
744}
745
746struct AsyncRound {
747    rc: i32,
748    rows: Vec<Row>,
749    window_labels: std::collections::HashSet<String>,
750    stderr_map: std::collections::HashMap<String, String>,
751}
752
753fn collect_async_round(
754    secret_files: &[PathBuf],
755    cached_mode: bool,
756    no_refresh_auth: bool,
757    jobs: usize,
758) -> AsyncRound {
759    let total = secret_files.len();
760    let progress = if total > 1 {
761        Some(Progress::new(
762            total as u64,
763            ProgressOptions::default()
764                .with_prefix("codex-rate-limits ")
765                .with_finish(ProgressFinish::Clear),
766        ))
767    } else {
768        None
769    };
770
771    let (tx, rx) = mpsc::channel();
772    let mut handles = Vec::new();
773    let mut index = 0usize;
774    let worker_count = jobs.min(total);
775
776    let spawn_worker = |path: PathBuf,
777                        cached_mode: bool,
778                        no_refresh_auth: bool,
779                        tx: mpsc::Sender<AsyncEvent>|
780     -> thread::JoinHandle<()> {
781        thread::spawn(move || {
782            let secret_name = path
783                .file_name()
784                .and_then(|name| name.to_str())
785                .unwrap_or("")
786                .to_string();
787            let result = async_fetch_one_line(&path, cached_mode, no_refresh_auth, &secret_name);
788            let _ = tx.send(AsyncEvent {
789                secret_name,
790                line: result.line,
791                rc: result.rc,
792                err: result.err,
793            });
794        })
795    };
796
797    while index < total && handles.len() < worker_count {
798        let path = secret_files[index].clone();
799        index += 1;
800        handles.push(spawn_worker(path, cached_mode, no_refresh_auth, tx.clone()));
801    }
802
803    let mut events: std::collections::HashMap<String, AsyncEvent> =
804        std::collections::HashMap::new();
805    while events.len() < total {
806        let event = match rx.recv() {
807            Ok(event) => event,
808            Err(_) => break,
809        };
810        if let Some(progress) = &progress {
811            progress.set_message(event.secret_name.clone());
812            progress.inc(1);
813        }
814        events.insert(event.secret_name.clone(), event);
815
816        if index < total {
817            let path = secret_files[index].clone();
818            index += 1;
819            handles.push(spawn_worker(path, cached_mode, no_refresh_auth, tx.clone()));
820        }
821    }
822
823    if let Some(progress) = progress {
824        progress.finish_and_clear();
825    }
826
827    drop(tx);
828    for handle in handles {
829        let _ = handle.join();
830    }
831
832    let mut rc = 0;
833    let mut rows: Vec<Row> = Vec::new();
834    let mut window_labels = std::collections::HashSet::new();
835    let mut stderr_map: std::collections::HashMap<String, String> =
836        std::collections::HashMap::new();
837
838    for secret_file in secret_files {
839        let secret_name = secret_file
840            .file_name()
841            .and_then(|name| name.to_str())
842            .unwrap_or("")
843            .to_string();
844
845        let mut row = Row::empty(secret_name.trim_end_matches(".json").to_string());
846        let event = events.get(&secret_name);
847        if let Some(event) = event {
848            if !event.err.is_empty() {
849                stderr_map.insert(secret_name.clone(), event.err.clone());
850            }
851            if !cached_mode && event.rc != 0 {
852                rc = 1;
853            }
854
855            if let Some(line) = &event.line
856                && let Some(parsed) = parse_one_line_output(line)
857            {
858                row.window_label = parsed.window_label.clone();
859                row.non_weekly_remaining = parsed.non_weekly_remaining;
860                row.weekly_remaining = parsed.weekly_remaining;
861                row.weekly_reset_iso = parsed.weekly_reset_iso.clone();
862
863                if cached_mode {
864                    if let Ok(cache_entry) = cache::read_cache_entry(secret_file) {
865                        row.non_weekly_reset_epoch = cache_entry.non_weekly_reset_epoch;
866                        row.weekly_reset_epoch = Some(cache_entry.weekly_reset_epoch);
867                    }
868                } else {
869                    let values = crate::json::read_json(secret_file).ok();
870                    if let Some(values) = values {
871                        row.non_weekly_reset_epoch = crate::json::i64_at(
872                            &values,
873                            &["codex_rate_limits", "non_weekly_reset_at_epoch"],
874                        );
875                        row.weekly_reset_epoch = crate::json::i64_at(
876                            &values,
877                            &["codex_rate_limits", "weekly_reset_at_epoch"],
878                        );
879                    }
880                    if (row.non_weekly_reset_epoch.is_none() || row.weekly_reset_epoch.is_none())
881                        && let Ok(cache_entry) = cache::read_cache_entry(secret_file)
882                    {
883                        if row.non_weekly_reset_epoch.is_none() {
884                            row.non_weekly_reset_epoch = cache_entry.non_weekly_reset_epoch;
885                        }
886                        if row.weekly_reset_epoch.is_none() {
887                            row.weekly_reset_epoch = Some(cache_entry.weekly_reset_epoch);
888                        }
889                    }
890                }
891
892                window_labels.insert(row.window_label.clone());
893                rows.push(row);
894                continue;
895            }
896        }
897
898        if !cached_mode {
899            rc = 1;
900        }
901        rows.push(row);
902    }
903
904    AsyncRound {
905        rc,
906        rows,
907        window_labels,
908        stderr_map,
909    }
910}
911
912fn render_all_accounts_table(
913    mut rows: Vec<Row>,
914    window_labels: &std::collections::HashSet<String>,
915    current_name: Option<&str>,
916    update_time: Option<&str>,
917) {
918    println!("\n🚦 Codex rate limits for all accounts\n");
919
920    let mut non_weekly_header = "Non-weekly".to_string();
921    let multiple_labels = window_labels.len() != 1;
922    if !multiple_labels && let Some(label) = window_labels.iter().next() {
923        non_weekly_header = label.clone();
924    }
925
926    let now_epoch = Utc::now().timestamp();
927
928    println!(
929        "{:<15}  {:>8}  {:>7}  {:>8}  {:>7}  {:<18}",
930        "Name", non_weekly_header, "Left", "Weekly", "Left", "Reset"
931    );
932    println!("----------------------------------------------------------------------------");
933
934    rows.sort_by_key(|row| row.sort_key());
935
936    for row in rows {
937        let display_non_weekly = if multiple_labels && !row.window_label.is_empty() {
938            if row.non_weekly_remaining >= 0 {
939                format!("{}:{}%", row.window_label, row.non_weekly_remaining)
940            } else {
941                "-".to_string()
942            }
943        } else if row.non_weekly_remaining >= 0 {
944            format!("{}%", row.non_weekly_remaining)
945        } else {
946            "-".to_string()
947        };
948
949        let non_weekly_left = row
950            .non_weekly_reset_epoch
951            .and_then(|epoch| render::format_until_epoch_compact(epoch, now_epoch))
952            .unwrap_or_else(|| "-".to_string());
953        let weekly_left = row
954            .weekly_reset_epoch
955            .and_then(|epoch| render::format_until_epoch_compact(epoch, now_epoch))
956            .unwrap_or_else(|| "-".to_string());
957        let reset_display = row
958            .weekly_reset_epoch
959            .and_then(render::format_epoch_local_datetime_with_offset)
960            .unwrap_or_else(|| "-".to_string());
961
962        let non_weekly_display = ansi::format_percent_cell(&display_non_weekly, 8, None);
963        let weekly_display = if row.weekly_remaining >= 0 {
964            ansi::format_percent_cell(&format!("{}%", row.weekly_remaining), 8, None)
965        } else {
966            ansi::format_percent_cell("-", 8, None)
967        };
968
969        let is_current = current_name == Some(row.name.as_str());
970        let name_display = ansi::format_name_cell(&row.name, 15, is_current, None);
971
972        println!(
973            "{}  {}  {:>7}  {}  {:>7}  {:<18}",
974            name_display,
975            non_weekly_display,
976            non_weekly_left,
977            weekly_display,
978            weekly_left,
979            reset_display
980        );
981    }
982
983    if let Some(update_time) = update_time {
984        println!();
985        println!("Last update: {update_time}");
986    }
987}
988
989fn emit_async_debug(
990    debug_mode: bool,
991    secret_files: &[PathBuf],
992    stderr_map: &std::collections::HashMap<String, String>,
993) {
994    if !debug_mode {
995        return;
996    }
997
998    let mut printed = false;
999    for secret_file in secret_files {
1000        let secret_name = secret_file
1001            .file_name()
1002            .and_then(|name| name.to_str())
1003            .unwrap_or("")
1004            .to_string();
1005        if let Some(err) = stderr_map.get(&secret_name) {
1006            if err.is_empty() {
1007                continue;
1008            }
1009            if !printed {
1010                printed = true;
1011                eprintln!();
1012                eprintln!("codex-rate-limits-async: per-account stderr (captured):");
1013            }
1014            eprintln!("---- {} ----", secret_name);
1015            eprintln!("{err}");
1016        }
1017    }
1018}
1019
1020fn watch_max_rounds_for_test() -> Option<u64> {
1021    std::env::var("CODEX_RATE_LIMITS_WATCH_MAX_ROUNDS")
1022        .ok()
1023        .and_then(|raw| raw.parse::<u64>().ok())
1024        .filter(|value| *value > 0)
1025}
1026
1027fn format_watch_update_time(now_epoch: i64) -> String {
1028    render::format_epoch_local(now_epoch, "%Y-%m-%d %H:%M:%S %:z")
1029        .unwrap_or_else(|| now_epoch.to_string())
1030}
1031
1032fn async_fetch_one_line(
1033    target_file: &Path,
1034    cached_mode: bool,
1035    no_refresh_auth: bool,
1036    secret_name: &str,
1037) -> AsyncFetchResult {
1038    if cached_mode {
1039        return fetch_one_line_cached(target_file);
1040    }
1041
1042    let mut attempt = 1;
1043    let max_attempts = 2;
1044    let mut network_err: Option<String> = None;
1045
1046    let mut result = fetch_one_line_network(target_file, no_refresh_auth);
1047    if !result.err.is_empty() {
1048        network_err = Some(result.err.clone());
1049    }
1050
1051    while attempt < max_attempts && result.rc == 3 {
1052        thread::sleep(Duration::from_millis(250));
1053        let next = fetch_one_line_network(target_file, no_refresh_auth);
1054        if !next.err.is_empty() {
1055            network_err = Some(next.err.clone());
1056        }
1057        result = next;
1058        attempt += 1;
1059        if result.rc != 3 {
1060            break;
1061        }
1062    }
1063
1064    let mut errors: Vec<String> = Vec::new();
1065    if let Some(err) = network_err {
1066        errors.push(err);
1067    }
1068
1069    let missing_line = result
1070        .line
1071        .as_ref()
1072        .map(|line| line.trim().is_empty())
1073        .unwrap_or(true);
1074
1075    if result.rc != 0 || missing_line {
1076        let cached = fetch_one_line_cached(target_file);
1077        if !cached.err.is_empty() {
1078            errors.push(cached.err.clone());
1079        }
1080        if cached.rc == 0
1081            && cached
1082                .line
1083                .as_ref()
1084                .map(|line| !line.trim().is_empty())
1085                .unwrap_or(false)
1086        {
1087            if result.rc != 0 {
1088                errors.push(format!(
1089                    "codex-rate-limits-async: falling back to cache for {} (rc={})",
1090                    secret_name, result.rc
1091                ));
1092            }
1093            result = AsyncFetchResult {
1094                line: cached.line,
1095                rc: 0,
1096                err: String::new(),
1097            };
1098        }
1099    }
1100
1101    let line = result.line.map(normalize_one_line);
1102    let err = errors.join("\n");
1103    AsyncFetchResult {
1104        line,
1105        rc: result.rc,
1106        err,
1107    }
1108}
1109
1110fn fetch_one_line_network(target_file: &Path, no_refresh_auth: bool) -> AsyncFetchResult {
1111    if !target_file.is_file() {
1112        return AsyncFetchResult {
1113            line: None,
1114            rc: 1,
1115            err: format!("codex-rate-limits: {} not found", target_file.display()),
1116        };
1117    }
1118
1119    let base_url = std::env::var("CODEX_CHATGPT_BASE_URL")
1120        .unwrap_or_else(|_| "https://chatgpt.com/backend-api/".to_string());
1121    let connect_timeout = env_timeout("CODEX_RATE_LIMITS_CURL_CONNECT_TIMEOUT_SECONDS", 2);
1122    let max_time = env_timeout("CODEX_RATE_LIMITS_CURL_MAX_TIME_SECONDS", 8);
1123
1124    let usage_request = UsageRequest {
1125        target_file: target_file.to_path_buf(),
1126        refresh_on_401: !no_refresh_auth,
1127        base_url,
1128        connect_timeout_seconds: connect_timeout,
1129        max_time_seconds: max_time,
1130    };
1131
1132    let usage = match fetch_usage(&usage_request) {
1133        Ok(value) => value,
1134        Err(err) => {
1135            let msg = err.to_string();
1136            if msg.contains("missing access_token") {
1137                return AsyncFetchResult {
1138                    line: None,
1139                    rc: 2,
1140                    err: format!(
1141                        "codex-rate-limits: missing access_token in {}",
1142                        target_file.display()
1143                    ),
1144                };
1145            }
1146            return AsyncFetchResult {
1147                line: None,
1148                rc: 3,
1149                err: msg,
1150            };
1151        }
1152    };
1153
1154    if let Err(err) = writeback::write_weekly(target_file, &usage.json) {
1155        return AsyncFetchResult {
1156            line: None,
1157            rc: 4,
1158            err: err.to_string(),
1159        };
1160    }
1161
1162    if is_auth_file(target_file) {
1163        match sync_auth_silent() {
1164            Ok((sync_rc, sync_err)) => {
1165                if sync_rc != 0 {
1166                    return AsyncFetchResult {
1167                        line: None,
1168                        rc: 5,
1169                        err: sync_err.unwrap_or_default(),
1170                    };
1171                }
1172            }
1173            Err(_) => {
1174                return AsyncFetchResult {
1175                    line: None,
1176                    rc: 1,
1177                    err: String::new(),
1178                };
1179            }
1180        }
1181    }
1182
1183    let usage_data = match render::parse_usage(&usage.json) {
1184        Some(value) => value,
1185        None => {
1186            return AsyncFetchResult {
1187                line: None,
1188                rc: 3,
1189                err: "codex-rate-limits: invalid usage payload".to_string(),
1190            };
1191        }
1192    };
1193
1194    let values = render::render_values(&usage_data);
1195    let weekly = render::weekly_values(&values);
1196
1197    let fetched_at_epoch = Utc::now().timestamp();
1198    if fetched_at_epoch > 0 {
1199        let _ = cache::write_starship_cache(
1200            target_file,
1201            fetched_at_epoch,
1202            &weekly.non_weekly_label,
1203            weekly.non_weekly_remaining,
1204            weekly.weekly_remaining,
1205            weekly.weekly_reset_epoch,
1206            weekly.non_weekly_reset_epoch,
1207        );
1208    }
1209
1210    AsyncFetchResult {
1211        line: Some(format_one_line_output(
1212            target_file,
1213            &weekly.non_weekly_label,
1214            weekly.non_weekly_remaining,
1215            weekly.weekly_remaining,
1216            weekly.weekly_reset_epoch,
1217        )),
1218        rc: 0,
1219        err: String::new(),
1220    }
1221}
1222
1223fn fetch_one_line_cached(target_file: &Path) -> AsyncFetchResult {
1224    match cache::read_cache_entry(target_file) {
1225        Ok(entry) => AsyncFetchResult {
1226            line: Some(format_one_line_output(
1227                target_file,
1228                &entry.non_weekly_label,
1229                entry.non_weekly_remaining,
1230                entry.weekly_remaining,
1231                entry.weekly_reset_epoch,
1232            )),
1233            rc: 0,
1234            err: String::new(),
1235        },
1236        Err(err) => AsyncFetchResult {
1237            line: None,
1238            rc: 1,
1239            err: err.to_string(),
1240        },
1241    }
1242}
1243
1244fn format_one_line_output(
1245    target_file: &Path,
1246    non_weekly_label: &str,
1247    non_weekly_remaining: i64,
1248    weekly_remaining: i64,
1249    weekly_reset_epoch: i64,
1250) -> String {
1251    let prefix = cache::secret_name_for_target(target_file)
1252        .map(|name| format!("{name} "))
1253        .unwrap_or_default();
1254    let weekly_reset_iso =
1255        render::format_epoch_local_datetime(weekly_reset_epoch).unwrap_or_else(|| "?".to_string());
1256
1257    format!(
1258        "{}{}:{}% W:{}% {}",
1259        prefix, non_weekly_label, non_weekly_remaining, weekly_remaining, weekly_reset_iso
1260    )
1261}
1262
1263fn normalize_one_line(line: String) -> String {
1264    line.replace(['\n', '\r', '\t'], " ")
1265}
1266
1267fn sync_auth_silent() -> Result<(i32, Option<String>)> {
1268    let auth_file = match crate::paths::resolve_auth_file() {
1269        Some(path) => path,
1270        None => return Ok((0, None)),
1271    };
1272
1273    if !auth_file.is_file() {
1274        return Ok((0, None));
1275    }
1276
1277    let auth_key = match auth::identity_key_from_auth_file(&auth_file) {
1278        Ok(Some(key)) => key,
1279        _ => return Ok((0, None)),
1280    };
1281
1282    let auth_last_refresh = auth::last_refresh_from_auth_file(&auth_file).unwrap_or(None);
1283    let auth_hash = match crate::fs::sha256_file(&auth_file) {
1284        Ok(hash) => hash,
1285        Err(_) => {
1286            return Ok((
1287                1,
1288                Some(format!("codex: failed to hash {}", auth_file.display())),
1289            ));
1290        }
1291    };
1292
1293    if let Some(secret_dir) = crate::paths::resolve_secret_dir()
1294        && let Ok(entries) = std::fs::read_dir(&secret_dir)
1295    {
1296        for entry in entries.flatten() {
1297            let path = entry.path();
1298            if path.extension().and_then(|s| s.to_str()) != Some("json") {
1299                continue;
1300            }
1301            let candidate_key = match auth::identity_key_from_auth_file(&path) {
1302                Ok(Some(key)) => key,
1303                _ => continue,
1304            };
1305            if candidate_key != auth_key {
1306                continue;
1307            }
1308
1309            let secret_hash = match crate::fs::sha256_file(&path) {
1310                Ok(hash) => hash,
1311                Err(_) => {
1312                    return Ok((1, Some(format!("codex: failed to hash {}", path.display()))));
1313                }
1314            };
1315            if secret_hash == auth_hash {
1316                continue;
1317            }
1318
1319            let contents = std::fs::read(&auth_file)?;
1320            crate::fs::write_atomic(&path, &contents, crate::fs::SECRET_FILE_MODE)?;
1321
1322            let timestamp_path = secret_timestamp_path(&path)?;
1323            crate::fs::write_timestamp(&timestamp_path, auth_last_refresh.as_deref())?;
1324        }
1325    }
1326
1327    let auth_timestamp = secret_timestamp_path(&auth_file)?;
1328    crate::fs::write_timestamp(&auth_timestamp, auth_last_refresh.as_deref())?;
1329
1330    Ok((0, None))
1331}
1332
1333fn maybe_sync_all_mode_auth_silent(debug_mode: bool) {
1334    match sync_auth_silent() {
1335        Ok((0, _)) => {}
1336        Ok((_, sync_err)) => {
1337            if debug_mode
1338                && let Some(message) = sync_err
1339                && !message.trim().is_empty()
1340            {
1341                eprintln!("{message}");
1342            }
1343        }
1344        Err(err) => {
1345            if debug_mode {
1346                eprintln!("codex-rate-limits: failed to sync auth and secrets: {err}");
1347            }
1348        }
1349    }
1350}
1351
1352fn secret_timestamp_path(target_file: &Path) -> Result<PathBuf> {
1353    let cache_dir = crate::paths::resolve_secret_cache_dir()
1354        .ok_or_else(|| anyhow::anyhow!("CODEX_SECRET_CACHE_DIR not resolved"))?;
1355    let name = target_file
1356        .file_name()
1357        .and_then(|name| name.to_str())
1358        .unwrap_or("auth.json");
1359    Ok(cache_dir.join(format!("{name}.timestamp")))
1360}
1361
1362fn run_all_mode(args: &RateLimitsOptions, cached_mode: bool, debug_mode: bool) -> Result<i32> {
1363    let secret_dir = crate::paths::resolve_secret_dir().unwrap_or_default();
1364    if !secret_dir.is_dir() {
1365        eprintln!(
1366            "codex-rate-limits: CODEX_SECRET_DIR not found: {}",
1367            secret_dir.display()
1368        );
1369        return Ok(1);
1370    }
1371
1372    let mut secret_files: Vec<PathBuf> = std::fs::read_dir(&secret_dir)?
1373        .flatten()
1374        .map(|entry| entry.path())
1375        .filter(|path| path.extension().and_then(|s| s.to_str()) == Some("json"))
1376        .collect();
1377
1378    if secret_files.is_empty() {
1379        eprintln!(
1380            "codex-rate-limits: no secrets found in {}",
1381            secret_dir.display()
1382        );
1383        return Ok(1);
1384    }
1385
1386    secret_files.sort();
1387
1388    let current_name = current_secret_basename(&secret_files);
1389
1390    let total = secret_files.len();
1391    let progress = if total > 1 {
1392        Some(Progress::new(
1393            total as u64,
1394            ProgressOptions::default()
1395                .with_prefix("codex-rate-limits ")
1396                .with_finish(ProgressFinish::Clear),
1397        ))
1398    } else {
1399        None
1400    };
1401
1402    let mut rc = 0;
1403    let mut rows: Vec<Row> = Vec::new();
1404    let mut window_labels = std::collections::HashSet::new();
1405
1406    for secret_file in secret_files {
1407        let secret_name = secret_file
1408            .file_name()
1409            .and_then(|name| name.to_str())
1410            .unwrap_or("")
1411            .to_string();
1412        if let Some(progress) = &progress {
1413            progress.set_message(secret_name.clone());
1414        }
1415
1416        let mut row = Row::empty(secret_name.trim_end_matches(".json").to_string());
1417        let output =
1418            match single_one_line(&secret_file, cached_mode, args.no_refresh_auth, debug_mode) {
1419                Ok(Some(line)) => line,
1420                Ok(None) => String::new(),
1421                Err(_) => String::new(),
1422            };
1423
1424        if output.is_empty() {
1425            if !cached_mode {
1426                rc = 1;
1427            }
1428            rows.push(row);
1429            continue;
1430        }
1431
1432        if let Some(parsed) = parse_one_line_output(&output) {
1433            row.window_label = parsed.window_label.clone();
1434            row.non_weekly_remaining = parsed.non_weekly_remaining;
1435            row.weekly_remaining = parsed.weekly_remaining;
1436            row.weekly_reset_iso = parsed.weekly_reset_iso.clone();
1437
1438            if cached_mode {
1439                if let Ok(cache_entry) = cache::read_cache_entry(&secret_file) {
1440                    row.non_weekly_reset_epoch = cache_entry.non_weekly_reset_epoch;
1441                    row.weekly_reset_epoch = Some(cache_entry.weekly_reset_epoch);
1442                }
1443            } else {
1444                let values = crate::json::read_json(&secret_file).ok();
1445                if let Some(values) = values {
1446                    row.non_weekly_reset_epoch = crate::json::i64_at(
1447                        &values,
1448                        &["codex_rate_limits", "non_weekly_reset_at_epoch"],
1449                    );
1450                    row.weekly_reset_epoch = crate::json::i64_at(
1451                        &values,
1452                        &["codex_rate_limits", "weekly_reset_at_epoch"],
1453                    );
1454                }
1455            }
1456
1457            window_labels.insert(row.window_label.clone());
1458            rows.push(row);
1459        } else {
1460            if !cached_mode {
1461                rc = 1;
1462            }
1463            rows.push(row);
1464        }
1465
1466        if let Some(progress) = &progress {
1467            progress.inc(1);
1468        }
1469    }
1470
1471    if let Some(progress) = progress {
1472        progress.finish_and_clear();
1473    }
1474
1475    println!("\n🚦 Codex rate limits for all accounts\n");
1476
1477    let mut non_weekly_header = "Non-weekly".to_string();
1478    let multiple_labels = window_labels.len() != 1;
1479    if !multiple_labels && let Some(label) = window_labels.iter().next() {
1480        non_weekly_header = label.clone();
1481    }
1482
1483    let now_epoch = Utc::now().timestamp();
1484
1485    println!(
1486        "{:<15}  {:>8}  {:>7}  {:>8}  {:>7}  {:<18}",
1487        "Name", non_weekly_header, "Left", "Weekly", "Left", "Reset"
1488    );
1489    println!("----------------------------------------------------------------------------");
1490
1491    rows.sort_by_key(|row| row.sort_key());
1492
1493    for row in rows {
1494        let display_non_weekly = if multiple_labels && !row.window_label.is_empty() {
1495            if row.non_weekly_remaining >= 0 {
1496                format!("{}:{}%", row.window_label, row.non_weekly_remaining)
1497            } else {
1498                "-".to_string()
1499            }
1500        } else if row.non_weekly_remaining >= 0 {
1501            format!("{}%", row.non_weekly_remaining)
1502        } else {
1503            "-".to_string()
1504        };
1505
1506        let non_weekly_left = row
1507            .non_weekly_reset_epoch
1508            .and_then(|epoch| render::format_until_epoch_compact(epoch, now_epoch))
1509            .unwrap_or_else(|| "-".to_string());
1510        let weekly_left = row
1511            .weekly_reset_epoch
1512            .and_then(|epoch| render::format_until_epoch_compact(epoch, now_epoch))
1513            .unwrap_or_else(|| "-".to_string());
1514        let reset_display = row
1515            .weekly_reset_epoch
1516            .and_then(render::format_epoch_local_datetime_with_offset)
1517            .unwrap_or_else(|| "-".to_string());
1518
1519        let non_weekly_display = ansi::format_percent_cell(&display_non_weekly, 8, None);
1520        let weekly_display = if row.weekly_remaining >= 0 {
1521            ansi::format_percent_cell(&format!("{}%", row.weekly_remaining), 8, None)
1522        } else {
1523            ansi::format_percent_cell("-", 8, None)
1524        };
1525
1526        let is_current = current_name.as_deref() == Some(row.name.as_str());
1527        let name_display = ansi::format_name_cell(&row.name, 15, is_current, None);
1528
1529        println!(
1530            "{}  {}  {:>7}  {}  {:>7}  {:<18}",
1531            name_display,
1532            non_weekly_display,
1533            non_weekly_left,
1534            weekly_display,
1535            weekly_left,
1536            reset_display
1537        );
1538    }
1539
1540    Ok(rc)
1541}
1542
1543fn current_secret_basename(secret_files: &[PathBuf]) -> Option<String> {
1544    let auth_file = crate::paths::resolve_auth_file()?;
1545    if !auth_file.is_file() {
1546        return None;
1547    }
1548
1549    let auth_key = auth::identity_key_from_auth_file(&auth_file).ok().flatten();
1550    let auth_hash = crate::fs::sha256_file(&auth_file).ok();
1551
1552    if let Some(auth_hash) = auth_hash.as_deref() {
1553        for secret_file in secret_files {
1554            if let Ok(secret_hash) = crate::fs::sha256_file(secret_file)
1555                && secret_hash == auth_hash
1556                && let Some(name) = secret_file.file_name().and_then(|name| name.to_str())
1557            {
1558                return Some(name.trim_end_matches(".json").to_string());
1559            }
1560        }
1561    }
1562
1563    if let Some(auth_key) = auth_key.as_deref() {
1564        for secret_file in secret_files {
1565            if let Ok(Some(candidate_key)) = auth::identity_key_from_auth_file(secret_file)
1566                && candidate_key == auth_key
1567                && let Some(name) = secret_file.file_name().and_then(|name| name.to_str())
1568            {
1569                return Some(name.trim_end_matches(".json").to_string());
1570            }
1571        }
1572    }
1573
1574    None
1575}
1576
1577fn run_single_mode(
1578    args: &RateLimitsOptions,
1579    cached_mode: bool,
1580    one_line: bool,
1581    output_json: bool,
1582) -> Result<i32> {
1583    let target_file = match resolve_target(args.secret.as_deref()) {
1584        Ok(path) => path,
1585        Err(code) => return Ok(code),
1586    };
1587
1588    if !target_file.is_file() {
1589        if output_json {
1590            diag_output::emit_error(
1591                DIAG_SCHEMA_VERSION,
1592                DIAG_COMMAND,
1593                "target-not-found",
1594                format!("codex-rate-limits: {} not found", target_file.display()),
1595                Some(serde_json::json!({
1596                    "target_file": target_file.display().to_string(),
1597                })),
1598            )?;
1599        } else {
1600            eprintln!("codex-rate-limits: {} not found", target_file.display());
1601        }
1602        return Ok(1);
1603    }
1604
1605    if cached_mode {
1606        match cache::read_cache_entry(&target_file) {
1607            Ok(entry) => {
1608                let weekly_reset_iso =
1609                    render::format_epoch_local_datetime(entry.weekly_reset_epoch)
1610                        .unwrap_or_else(|| "?".to_string());
1611                let prefix = cache::secret_name_for_target(&target_file)
1612                    .map(|name| format!("{name} "))
1613                    .unwrap_or_default();
1614                println!(
1615                    "{}{}:{}% W:{}% {}",
1616                    prefix,
1617                    entry.non_weekly_label,
1618                    entry.non_weekly_remaining,
1619                    entry.weekly_remaining,
1620                    weekly_reset_iso
1621                );
1622                return Ok(0);
1623            }
1624            Err(err) => {
1625                eprintln!("{err}");
1626                return Ok(1);
1627            }
1628        }
1629    }
1630
1631    let base_url = std::env::var("CODEX_CHATGPT_BASE_URL")
1632        .unwrap_or_else(|_| "https://chatgpt.com/backend-api/".to_string());
1633    let connect_timeout = env_timeout("CODEX_RATE_LIMITS_CURL_CONNECT_TIMEOUT_SECONDS", 2);
1634    let max_time = env_timeout("CODEX_RATE_LIMITS_CURL_MAX_TIME_SECONDS", 8);
1635
1636    let usage_request = UsageRequest {
1637        target_file: target_file.clone(),
1638        refresh_on_401: !args.no_refresh_auth,
1639        base_url,
1640        connect_timeout_seconds: connect_timeout,
1641        max_time_seconds: max_time,
1642    };
1643
1644    let usage = match fetch_usage(&usage_request) {
1645        Ok(value) => value,
1646        Err(err) => {
1647            let msg = err.to_string();
1648            if msg.contains("missing access_token") {
1649                if output_json {
1650                    diag_output::emit_error(
1651                        DIAG_SCHEMA_VERSION,
1652                        DIAG_COMMAND,
1653                        "missing-access-token",
1654                        format!(
1655                            "codex-rate-limits: missing access_token in {}",
1656                            target_file.display()
1657                        ),
1658                        Some(serde_json::json!({
1659                            "target_file": target_file.display().to_string(),
1660                        })),
1661                    )?;
1662                } else {
1663                    eprintln!(
1664                        "codex-rate-limits: missing access_token in {}",
1665                        target_file.display()
1666                    );
1667                }
1668                return Ok(2);
1669            }
1670            if output_json {
1671                diag_output::emit_error(
1672                    DIAG_SCHEMA_VERSION,
1673                    DIAG_COMMAND,
1674                    "request-failed",
1675                    msg,
1676                    Some(serde_json::json!({
1677                        "target_file": target_file.display().to_string(),
1678                    })),
1679                )?;
1680            } else {
1681                eprintln!("{msg}");
1682            }
1683            return Ok(3);
1684        }
1685    };
1686
1687    if let Err(err) = writeback::write_weekly(&target_file, &usage.json) {
1688        if output_json {
1689            diag_output::emit_error(
1690                DIAG_SCHEMA_VERSION,
1691                DIAG_COMMAND,
1692                "writeback-failed",
1693                err.to_string(),
1694                Some(serde_json::json!({
1695                    "target_file": target_file.display().to_string(),
1696                })),
1697            )?;
1698        } else {
1699            eprintln!("{err}");
1700        }
1701        return Ok(4);
1702    }
1703
1704    if is_auth_file(&target_file) {
1705        let sync_rc = auth::sync::run_with_json(false)?;
1706        if sync_rc != 0 {
1707            if output_json {
1708                diag_output::emit_error(
1709                    DIAG_SCHEMA_VERSION,
1710                    DIAG_COMMAND,
1711                    "sync-failed",
1712                    "codex-rate-limits: failed to sync auth file",
1713                    Some(serde_json::json!({
1714                        "target_file": target_file.display().to_string(),
1715                    })),
1716                )?;
1717            }
1718            return Ok(5);
1719        }
1720    }
1721
1722    let usage_data = match render::parse_usage(&usage.json) {
1723        Some(value) => value,
1724        None => {
1725            if output_json {
1726                diag_output::emit_error(
1727                    DIAG_SCHEMA_VERSION,
1728                    DIAG_COMMAND,
1729                    "invalid-usage-payload",
1730                    "codex-rate-limits: invalid usage payload",
1731                    Some(serde_json::json!({
1732                        "target_file": target_file.display().to_string(),
1733                        "raw_usage": redact_sensitive_json(&usage.json),
1734                    })),
1735                )?;
1736            } else {
1737                eprintln!("codex-rate-limits: invalid usage payload");
1738            }
1739            return Ok(3);
1740        }
1741    };
1742
1743    let values = render::render_values(&usage_data);
1744    let weekly = render::weekly_values(&values);
1745
1746    let fetched_at_epoch = Utc::now().timestamp();
1747    if fetched_at_epoch > 0 {
1748        let _ = cache::write_starship_cache(
1749            &target_file,
1750            fetched_at_epoch,
1751            &weekly.non_weekly_label,
1752            weekly.non_weekly_remaining,
1753            weekly.weekly_remaining,
1754            weekly.weekly_reset_epoch,
1755            weekly.non_weekly_reset_epoch,
1756        );
1757    }
1758
1759    if output_json {
1760        let result = RateLimitJsonResult {
1761            name: secret_display_name(&target_file),
1762            target_file: target_file_name(&target_file),
1763            status: "ok".to_string(),
1764            ok: true,
1765            source: "network".to_string(),
1766            summary: Some(RateLimitSummary {
1767                non_weekly_label: weekly.non_weekly_label,
1768                non_weekly_remaining: weekly.non_weekly_remaining,
1769                non_weekly_reset_epoch: weekly.non_weekly_reset_epoch,
1770                weekly_remaining: weekly.weekly_remaining,
1771                weekly_reset_epoch: weekly.weekly_reset_epoch,
1772                weekly_reset_local: render::format_epoch_local_datetime_with_offset(
1773                    weekly.weekly_reset_epoch,
1774                ),
1775            }),
1776            raw_usage: Some(redact_sensitive_json(&usage.json)),
1777            error: None,
1778        };
1779        diag_output::emit_json(&RateLimitSingleEnvelope {
1780            schema_version: DIAG_SCHEMA_VERSION.to_string(),
1781            command: DIAG_COMMAND.to_string(),
1782            mode: "single".to_string(),
1783            ok: true,
1784            result,
1785        })?;
1786        return Ok(0);
1787    }
1788
1789    if one_line {
1790        let prefix = cache::secret_name_for_target(&target_file)
1791            .map(|name| format!("{name} "))
1792            .unwrap_or_default();
1793        let weekly_reset_iso = render::format_epoch_local_datetime(weekly.weekly_reset_epoch)
1794            .unwrap_or_else(|| "?".to_string());
1795
1796        println!(
1797            "{}{}:{}% W:{}% {}",
1798            prefix,
1799            weekly.non_weekly_label,
1800            weekly.non_weekly_remaining,
1801            weekly.weekly_remaining,
1802            weekly_reset_iso
1803        );
1804        return Ok(0);
1805    }
1806
1807    println!("Rate limits remaining");
1808    let primary_reset = render::format_epoch_local_datetime(values.primary_reset_epoch)
1809        .unwrap_or_else(|| "?".to_string());
1810    let secondary_reset = render::format_epoch_local_datetime(values.secondary_reset_epoch)
1811        .unwrap_or_else(|| "?".to_string());
1812
1813    println!(
1814        "{} {}% • {}",
1815        values.primary_label, values.primary_remaining, primary_reset
1816    );
1817    println!(
1818        "{} {}% • {}",
1819        values.secondary_label, values.secondary_remaining, secondary_reset
1820    );
1821
1822    Ok(0)
1823}
1824
1825fn single_one_line(
1826    target_file: &Path,
1827    cached_mode: bool,
1828    no_refresh_auth: bool,
1829    debug_mode: bool,
1830) -> Result<Option<String>> {
1831    if !target_file.is_file() {
1832        if debug_mode {
1833            eprintln!("codex-rate-limits: {} not found", target_file.display());
1834        }
1835        return Ok(None);
1836    }
1837
1838    if cached_mode {
1839        return match cache::read_cache_entry(target_file) {
1840            Ok(entry) => {
1841                let weekly_reset_iso =
1842                    render::format_epoch_local_datetime(entry.weekly_reset_epoch)
1843                        .unwrap_or_else(|| "?".to_string());
1844                let prefix = cache::secret_name_for_target(target_file)
1845                    .map(|name| format!("{name} "))
1846                    .unwrap_or_default();
1847                Ok(Some(format!(
1848                    "{}{}:{}% W:{}% {}",
1849                    prefix,
1850                    entry.non_weekly_label,
1851                    entry.non_weekly_remaining,
1852                    entry.weekly_remaining,
1853                    weekly_reset_iso
1854                )))
1855            }
1856            Err(err) => {
1857                if debug_mode {
1858                    eprintln!("{err}");
1859                }
1860                Ok(None)
1861            }
1862        };
1863    }
1864
1865    let base_url = std::env::var("CODEX_CHATGPT_BASE_URL")
1866        .unwrap_or_else(|_| "https://chatgpt.com/backend-api/".to_string());
1867    let connect_timeout = env_timeout("CODEX_RATE_LIMITS_CURL_CONNECT_TIMEOUT_SECONDS", 2);
1868    let max_time = env_timeout("CODEX_RATE_LIMITS_CURL_MAX_TIME_SECONDS", 8);
1869
1870    let usage_request = UsageRequest {
1871        target_file: target_file.to_path_buf(),
1872        refresh_on_401: !no_refresh_auth,
1873        base_url,
1874        connect_timeout_seconds: connect_timeout,
1875        max_time_seconds: max_time,
1876    };
1877
1878    let usage = match fetch_usage(&usage_request) {
1879        Ok(value) => value,
1880        Err(err) => {
1881            if debug_mode {
1882                eprintln!("{err}");
1883            }
1884            return Ok(None);
1885        }
1886    };
1887
1888    let _ = writeback::write_weekly(target_file, &usage.json);
1889    if is_auth_file(target_file) {
1890        let _ = auth::sync::run();
1891    }
1892
1893    let usage_data = match render::parse_usage(&usage.json) {
1894        Some(value) => value,
1895        None => return Ok(None),
1896    };
1897    let values = render::render_values(&usage_data);
1898    let weekly = render::weekly_values(&values);
1899    let prefix = cache::secret_name_for_target(target_file)
1900        .map(|name| format!("{name} "))
1901        .unwrap_or_default();
1902    let weekly_reset_iso = render::format_epoch_local_datetime(weekly.weekly_reset_epoch)
1903        .unwrap_or_else(|| "?".to_string());
1904
1905    Ok(Some(format!(
1906        "{}{}:{}% W:{}% {}",
1907        prefix,
1908        weekly.non_weekly_label,
1909        weekly.non_weekly_remaining,
1910        weekly.weekly_remaining,
1911        weekly_reset_iso
1912    )))
1913}
1914
1915fn resolve_target(secret: Option<&str>) -> std::result::Result<PathBuf, i32> {
1916    if let Some(secret_name) = secret {
1917        if secret_name.is_empty() || secret_name.contains('/') || secret_name.contains("..") {
1918            eprintln!("codex-rate-limits: invalid secret file name: {secret_name}");
1919            return Err(64);
1920        }
1921        let secret_dir = crate::paths::resolve_secret_dir().unwrap_or_default();
1922        return Ok(secret_dir.join(secret_name));
1923    }
1924
1925    if let Some(auth_file) = crate::paths::resolve_auth_file() {
1926        return Ok(auth_file);
1927    }
1928
1929    Err(1)
1930}
1931
1932fn is_auth_file(target_file: &Path) -> bool {
1933    if let Some(auth_file) = crate::paths::resolve_auth_file() {
1934        return auth_file == target_file;
1935    }
1936    false
1937}
1938
1939fn env_timeout(key: &str, default: u64) -> u64 {
1940    std::env::var(key)
1941        .ok()
1942        .and_then(|raw| raw.parse::<u64>().ok())
1943        .unwrap_or(default)
1944}
1945
1946struct Row {
1947    name: String,
1948    window_label: String,
1949    non_weekly_remaining: i64,
1950    non_weekly_reset_epoch: Option<i64>,
1951    weekly_remaining: i64,
1952    weekly_reset_epoch: Option<i64>,
1953    weekly_reset_iso: String,
1954}
1955
1956impl Row {
1957    fn empty(name: String) -> Self {
1958        Self {
1959            name,
1960            window_label: String::new(),
1961            non_weekly_remaining: -1,
1962            non_weekly_reset_epoch: None,
1963            weekly_remaining: -1,
1964            weekly_reset_epoch: None,
1965            weekly_reset_iso: String::new(),
1966        }
1967    }
1968
1969    fn sort_key(&self) -> (i32, i64, String) {
1970        if let Some(epoch) = self.weekly_reset_epoch {
1971            (0, epoch, self.name.clone())
1972        } else {
1973            (1, i64::MAX, self.name.clone())
1974        }
1975    }
1976}
1977
1978struct ParsedOneLine {
1979    window_label: String,
1980    non_weekly_remaining: i64,
1981    weekly_remaining: i64,
1982    weekly_reset_iso: String,
1983}
1984
1985fn parse_one_line_output(line: &str) -> Option<ParsedOneLine> {
1986    let parts: Vec<&str> = line.split_whitespace().collect();
1987    if parts.len() < 3 {
1988        return None;
1989    }
1990
1991    fn parse_fields(
1992        window_field: &str,
1993        weekly_field: &str,
1994        reset_iso: String,
1995    ) -> Option<ParsedOneLine> {
1996        let window_label = window_field
1997            .split(':')
1998            .next()?
1999            .trim_matches('"')
2000            .to_string();
2001        let non_weekly_remaining = window_field.split(':').nth(1)?;
2002        let non_weekly_remaining = non_weekly_remaining
2003            .trim_end_matches('%')
2004            .parse::<i64>()
2005            .ok()?;
2006
2007        let weekly_remaining = weekly_field.trim_start_matches("W:").trim_end_matches('%');
2008        let weekly_remaining = weekly_remaining.parse::<i64>().ok()?;
2009
2010        Some(ParsedOneLine {
2011            window_label,
2012            non_weekly_remaining,
2013            weekly_remaining,
2014            weekly_reset_iso: reset_iso,
2015        })
2016    }
2017
2018    let len = parts.len();
2019    let window_field = parts[len - 3];
2020    let weekly_field = parts[len - 2];
2021    let reset_iso = parts[len - 1].to_string();
2022
2023    if let Some(parsed) = parse_fields(window_field, weekly_field, reset_iso) {
2024        return Some(parsed);
2025    }
2026
2027    if len < 4 {
2028        return None;
2029    }
2030
2031    parse_fields(
2032        parts[len - 4],
2033        parts[len - 3],
2034        format!("{} {}", parts[len - 2], parts[len - 1]),
2035    )
2036}
2037
2038#[cfg(test)]
2039mod tests {
2040    use super::{
2041        async_fetch_one_line, cache, collect_json_from_cache, collect_secret_files, env_timeout,
2042        fetch_one_line_cached, is_auth_file, normalize_one_line, parse_one_line_output,
2043        redact_sensitive_json, resolve_target, secret_display_name, single_one_line,
2044        sync_auth_silent, target_file_name,
2045    };
2046    use nils_test_support::{EnvGuard, GlobalStateLock};
2047    use serde_json::json;
2048    use std::fs;
2049    use std::path::Path;
2050
2051    const HEADER: &str = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0";
2052    const PAYLOAD_ALPHA: &str = "eyJzdWIiOiJ1c2VyXzEyMyIsImVtYWlsIjoiYWxwaGFAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF91c2VyX2lkIjoidXNlcl8xMjMiLCJlbWFpbCI6ImFscGhhQGV4YW1wbGUuY29tIn19";
2053    const PAYLOAD_BETA: &str = "eyJzdWIiOiJ1c2VyXzQ1NiIsImVtYWlsIjoiYmV0YUBleGFtcGxlLmNvbSIsImh0dHBzOi8vYXBpLm9wZW5haS5jb20vYXV0aCI6eyJjaGF0Z3B0X3VzZXJfaWQiOiJ1c2VyXzQ1NiIsImVtYWlsIjoiYmV0YUBleGFtcGxlLmNvbSJ9fQ";
2054
2055    fn token(payload: &str) -> String {
2056        format!("{HEADER}.{payload}.sig")
2057    }
2058
2059    fn auth_json(
2060        payload: &str,
2061        account_id: &str,
2062        refresh_token: &str,
2063        last_refresh: &str,
2064    ) -> String {
2065        format!(
2066            r#"{{"tokens":{{"access_token":"{}","id_token":"{}","refresh_token":"{}","account_id":"{}"}},"last_refresh":"{}"}}"#,
2067            token(payload),
2068            token(payload),
2069            refresh_token,
2070            account_id,
2071            last_refresh
2072        )
2073    }
2074
2075    #[test]
2076    fn redact_sensitive_json_removes_tokens_recursively() {
2077        let input = json!({
2078            "tokens": {
2079                "access_token": "a",
2080                "refresh_token": "b",
2081                "nested": {
2082                    "id_token": "c",
2083                    "Authorization": "Bearer x",
2084                    "ok": 1
2085                }
2086            },
2087            "items": [
2088                {"authorization": "Bearer y", "value": 2}
2089            ],
2090            "safe": true
2091        });
2092
2093        let redacted = redact_sensitive_json(&input);
2094        assert_eq!(redacted["tokens"]["nested"]["ok"], 1);
2095        assert_eq!(redacted["safe"], true);
2096        assert!(
2097            redacted["tokens"].get("access_token").is_none(),
2098            "access_token should be removed"
2099        );
2100        assert!(
2101            redacted["tokens"]["nested"].get("id_token").is_none(),
2102            "id_token should be removed"
2103        );
2104        assert!(
2105            redacted["tokens"]["nested"].get("Authorization").is_none(),
2106            "Authorization should be removed"
2107        );
2108        assert!(
2109            redacted["items"][0].get("authorization").is_none(),
2110            "authorization should be removed"
2111        );
2112    }
2113
2114    #[test]
2115    fn collect_secret_files_reports_missing_secret_dir() {
2116        let lock = GlobalStateLock::new();
2117        let dir = tempfile::TempDir::new().expect("tempdir");
2118        let missing = dir.path().join("missing");
2119        let _secret = EnvGuard::set(
2120            &lock,
2121            "CODEX_SECRET_DIR",
2122            missing.to_str().expect("missing path"),
2123        );
2124
2125        let err = collect_secret_files().expect_err("expected missing dir error");
2126        assert_eq!(err.0, 1);
2127        assert!(err.1.contains("CODEX_SECRET_DIR not found"));
2128    }
2129
2130    #[test]
2131    fn collect_secret_files_returns_sorted_json_files_only() {
2132        let lock = GlobalStateLock::new();
2133        let dir = tempfile::TempDir::new().expect("tempdir");
2134        let secrets = dir.path().join("secrets");
2135        fs::create_dir_all(&secrets).expect("secrets dir");
2136        fs::write(secrets.join("beta.json"), "{}").expect("write beta");
2137        fs::write(secrets.join("alpha.json"), "{}").expect("write alpha");
2138        fs::write(secrets.join("note.txt"), "ignore").expect("write note");
2139        let _secret = EnvGuard::set(
2140            &lock,
2141            "CODEX_SECRET_DIR",
2142            secrets.to_str().expect("secrets path"),
2143        );
2144
2145        let files = collect_secret_files().expect("secret files");
2146        assert_eq!(files.len(), 2);
2147        assert_eq!(
2148            files[0].file_name().and_then(|name| name.to_str()),
2149            Some("alpha.json")
2150        );
2151        assert_eq!(
2152            files[1].file_name().and_then(|name| name.to_str()),
2153            Some("beta.json")
2154        );
2155    }
2156
2157    #[test]
2158    fn rate_limits_helper_env_timeout_supports_default_and_parse() {
2159        let lock = GlobalStateLock::new();
2160        let key = "CODEX_RATE_LIMITS_CURL_MAX_TIME_SECONDS";
2161
2162        let _removed = EnvGuard::remove(&lock, key);
2163        assert_eq!(env_timeout(key, 7), 7);
2164
2165        let _set = EnvGuard::set(&lock, key, "11");
2166        assert_eq!(env_timeout(key, 7), 11);
2167
2168        let _invalid = EnvGuard::set(&lock, key, "oops");
2169        assert_eq!(env_timeout(key, 7), 7);
2170    }
2171
2172    #[test]
2173    fn rate_limits_helper_resolve_target_and_is_auth_file() {
2174        let lock = GlobalStateLock::new();
2175        let dir = tempfile::TempDir::new().expect("tempdir");
2176        let secret_dir = dir.path().join("secrets");
2177        fs::create_dir_all(&secret_dir).expect("secret dir");
2178        let auth_file = dir.path().join("auth.json");
2179        fs::write(&auth_file, "{}").expect("auth");
2180
2181        let _secret = EnvGuard::set(
2182            &lock,
2183            "CODEX_SECRET_DIR",
2184            secret_dir.to_str().expect("secret"),
2185        );
2186        let _auth = EnvGuard::set(&lock, "CODEX_AUTH_FILE", auth_file.to_str().expect("auth"));
2187
2188        assert_eq!(
2189            resolve_target(Some("alpha.json")).expect("target"),
2190            secret_dir.join("alpha.json")
2191        );
2192        assert_eq!(resolve_target(Some("../bad")).expect_err("usage"), 64);
2193        assert_eq!(resolve_target(None).expect("auth default"), auth_file);
2194        assert!(is_auth_file(&auth_file));
2195        assert!(!is_auth_file(&secret_dir.join("alpha.json")));
2196    }
2197
2198    #[test]
2199    fn rate_limits_helper_resolve_target_without_auth_returns_err() {
2200        let lock = GlobalStateLock::new();
2201        let _auth = EnvGuard::remove(&lock, "CODEX_AUTH_FILE");
2202        let _home = EnvGuard::set(&lock, "HOME", "");
2203
2204        assert_eq!(resolve_target(None).expect_err("missing auth"), 1);
2205    }
2206
2207    #[test]
2208    fn rate_limits_helper_collect_json_from_cache_covers_hit_and_miss() {
2209        let lock = GlobalStateLock::new();
2210        let dir = tempfile::TempDir::new().expect("tempdir");
2211        let secret_dir = dir.path().join("secrets");
2212        let cache_root = dir.path().join("cache-root");
2213        fs::create_dir_all(&secret_dir).expect("secrets");
2214        fs::create_dir_all(&cache_root).expect("cache");
2215
2216        let alpha = secret_dir.join("alpha.json");
2217        fs::write(&alpha, "{}").expect("alpha");
2218
2219        let _secret = EnvGuard::set(
2220            &lock,
2221            "CODEX_SECRET_DIR",
2222            secret_dir.to_str().expect("secret"),
2223        );
2224        let _cache = EnvGuard::set(&lock, "ZSH_CACHE_DIR", cache_root.to_str().expect("cache"));
2225        cache::write_starship_cache(
2226            &alpha,
2227            1_700_000_000,
2228            "3h",
2229            92,
2230            88,
2231            1_700_003_600,
2232            Some(1_700_001_200),
2233        )
2234        .expect("write cache");
2235
2236        let hit = collect_json_from_cache(&alpha, "cache");
2237        assert!(hit.ok);
2238        assert_eq!(hit.status, "ok");
2239        let summary = hit.summary.expect("summary");
2240        assert_eq!(summary.non_weekly_label, "3h");
2241        assert_eq!(summary.non_weekly_remaining, 92);
2242        assert_eq!(summary.weekly_remaining, 88);
2243
2244        let missing_target = secret_dir.join("missing.json");
2245        let miss = collect_json_from_cache(&missing_target, "cache");
2246        assert!(!miss.ok);
2247        let error = miss.error.expect("error");
2248        assert_eq!(error.code, "cache-read-failed");
2249        assert!(error.message.contains("cache not found"));
2250    }
2251
2252    #[test]
2253    fn rate_limits_helper_fetch_one_line_cached_covers_success_and_error() {
2254        let lock = GlobalStateLock::new();
2255        let dir = tempfile::TempDir::new().expect("tempdir");
2256        let secret_dir = dir.path().join("secrets");
2257        let cache_root = dir.path().join("cache-root");
2258        fs::create_dir_all(&secret_dir).expect("secrets");
2259        fs::create_dir_all(&cache_root).expect("cache");
2260
2261        let alpha = secret_dir.join("alpha.json");
2262        fs::write(&alpha, "{}").expect("alpha");
2263
2264        let _secret = EnvGuard::set(
2265            &lock,
2266            "CODEX_SECRET_DIR",
2267            secret_dir.to_str().expect("secret"),
2268        );
2269        let _cache = EnvGuard::set(&lock, "ZSH_CACHE_DIR", cache_root.to_str().expect("cache"));
2270        cache::write_starship_cache(
2271            &alpha,
2272            1_700_000_000,
2273            "3h",
2274            70,
2275            55,
2276            1_700_003_600,
2277            Some(1_700_001_200),
2278        )
2279        .expect("write cache");
2280
2281        let cached = fetch_one_line_cached(&alpha);
2282        assert_eq!(cached.rc, 0);
2283        assert!(cached.err.is_empty());
2284        assert!(cached.line.expect("line").contains("3h:70%"));
2285
2286        let miss = fetch_one_line_cached(&secret_dir.join("beta.json"));
2287        assert_eq!(miss.rc, 1);
2288        assert!(miss.line.is_none());
2289        assert!(miss.err.contains("cache not found"));
2290    }
2291
2292    #[test]
2293    fn rate_limits_helper_async_fetch_one_line_uses_cache_fallback() {
2294        let lock = GlobalStateLock::new();
2295        let dir = tempfile::TempDir::new().expect("tempdir");
2296        let secret_dir = dir.path().join("secrets");
2297        let cache_root = dir.path().join("cache-root");
2298        fs::create_dir_all(&secret_dir).expect("secrets");
2299        fs::create_dir_all(&cache_root).expect("cache");
2300
2301        let missing = secret_dir.join("ghost.json");
2302        let _secret = EnvGuard::set(
2303            &lock,
2304            "CODEX_SECRET_DIR",
2305            secret_dir.to_str().expect("secret"),
2306        );
2307        let _cache = EnvGuard::set(&lock, "ZSH_CACHE_DIR", cache_root.to_str().expect("cache"));
2308        cache::write_starship_cache(
2309            &missing,
2310            1_700_000_000,
2311            "3h",
2312            68,
2313            42,
2314            1_700_003_600,
2315            Some(1_700_001_200),
2316        )
2317        .expect("write cache");
2318
2319        let result = async_fetch_one_line(&missing, false, true, "ghost");
2320        assert_eq!(result.rc, 0);
2321        let line = result.line.expect("line");
2322        assert!(line.contains("3h:68%"));
2323        assert!(result.err.contains("falling back to cache"));
2324    }
2325
2326    #[test]
2327    fn rate_limits_helper_single_one_line_cached_mode_handles_hit_and_miss() {
2328        let lock = GlobalStateLock::new();
2329        let dir = tempfile::TempDir::new().expect("tempdir");
2330        let secret_dir = dir.path().join("secrets");
2331        let cache_root = dir.path().join("cache-root");
2332        fs::create_dir_all(&secret_dir).expect("secrets");
2333        fs::create_dir_all(&cache_root).expect("cache");
2334
2335        let alpha = secret_dir.join("alpha.json");
2336        let beta = secret_dir.join("beta.json");
2337        fs::write(&alpha, "{}").expect("alpha");
2338        fs::write(&beta, "{}").expect("beta");
2339
2340        let _secret = EnvGuard::set(
2341            &lock,
2342            "CODEX_SECRET_DIR",
2343            secret_dir.to_str().expect("secret"),
2344        );
2345        let _cache = EnvGuard::set(&lock, "ZSH_CACHE_DIR", cache_root.to_str().expect("cache"));
2346        cache::write_starship_cache(
2347            &alpha,
2348            1_700_000_000,
2349            "3h",
2350            61,
2351            39,
2352            1_700_003_600,
2353            Some(1_700_001_200),
2354        )
2355        .expect("write cache");
2356
2357        let hit = single_one_line(&alpha, true, true, false).expect("single");
2358        assert!(hit.expect("line").contains("3h:61%"));
2359
2360        let miss = single_one_line(&beta, true, true, true).expect("single");
2361        assert!(miss.is_none());
2362
2363        let missing =
2364            single_one_line(&secret_dir.join("missing.json"), true, true, true).expect("single");
2365        assert!(missing.is_none());
2366    }
2367
2368    #[test]
2369    fn rate_limits_helper_sync_auth_silent_updates_matching_secret_and_timestamps() {
2370        let lock = GlobalStateLock::new();
2371        let dir = tempfile::TempDir::new().expect("tempdir");
2372        let secret_dir = dir.path().join("secrets");
2373        let cache_dir = dir.path().join("cache");
2374        fs::create_dir_all(&secret_dir).expect("secrets");
2375        fs::create_dir_all(&cache_dir).expect("cache");
2376
2377        let auth_file = dir.path().join("auth.json");
2378        let alpha = secret_dir.join("alpha.json");
2379        let beta = secret_dir.join("beta.json");
2380        fs::write(
2381            &auth_file,
2382            auth_json(
2383                PAYLOAD_ALPHA,
2384                "acct_001",
2385                "refresh_new",
2386                "2025-01-20T12:34:56Z",
2387            ),
2388        )
2389        .expect("auth");
2390        fs::write(
2391            &alpha,
2392            auth_json(
2393                PAYLOAD_ALPHA,
2394                "acct_001",
2395                "refresh_old",
2396                "2025-01-19T12:34:56Z",
2397            ),
2398        )
2399        .expect("alpha");
2400        fs::write(
2401            &beta,
2402            auth_json(
2403                PAYLOAD_BETA,
2404                "acct_002",
2405                "refresh_beta",
2406                "2025-01-18T12:34:56Z",
2407            ),
2408        )
2409        .expect("beta");
2410        fs::write(secret_dir.join("invalid.json"), "{invalid").expect("invalid");
2411        fs::write(secret_dir.join("note.txt"), "ignore").expect("note");
2412
2413        let _auth = EnvGuard::set(&lock, "CODEX_AUTH_FILE", auth_file.to_str().expect("auth"));
2414        let _secret = EnvGuard::set(
2415            &lock,
2416            "CODEX_SECRET_DIR",
2417            secret_dir.to_str().expect("secret"),
2418        );
2419        let _cache = EnvGuard::set(
2420            &lock,
2421            "CODEX_SECRET_CACHE_DIR",
2422            cache_dir.to_str().expect("cache"),
2423        );
2424
2425        let (rc, err) = sync_auth_silent().expect("sync");
2426        assert_eq!(rc, 0);
2427        assert!(err.is_none());
2428        assert_eq!(
2429            fs::read(&alpha).expect("alpha"),
2430            fs::read(&auth_file).expect("auth")
2431        );
2432        assert_ne!(
2433            fs::read(&beta).expect("beta"),
2434            fs::read(&auth_file).expect("auth")
2435        );
2436        assert!(cache_dir.join("alpha.json.timestamp").is_file());
2437        assert!(cache_dir.join("auth.json.timestamp").is_file());
2438    }
2439
2440    #[test]
2441    fn rate_limits_helper_parsers_and_name_helpers_cover_fallbacks() {
2442        let parsed =
2443            parse_one_line_output("alpha 3h:90% W:80% 2025-01-20 12:00:00+00:00").expect("parsed");
2444        assert_eq!(parsed.window_label, "3h");
2445        assert_eq!(parsed.non_weekly_remaining, 90);
2446        assert_eq!(parsed.weekly_remaining, 80);
2447        assert_eq!(parsed.weekly_reset_iso, "2025-01-20 12:00:00+00:00");
2448        assert!(parse_one_line_output("bad").is_none());
2449
2450        assert_eq!(normalize_one_line("a\tb\nc\r".to_string()), "a b c ");
2451        assert_eq!(target_file_name(Path::new("alpha.json")), "alpha.json");
2452        assert_eq!(target_file_name(Path::new("")), "");
2453        assert_eq!(secret_display_name(Path::new("alpha.json")), "alpha");
2454    }
2455}