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