Skip to main content

codex_cli/rate_limits/
mod.rs

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