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