Skip to main content

codex_profiles/
usage.rs

1use chrono::{DateTime, Local, TimeZone, Utc};
2use colored::Colorize;
3use fslock::LockFile;
4use serde::{Deserialize, Serialize};
5use std::fs;
6use std::thread;
7use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
8
9use crate::{
10    Paths, UI_INFO_PREFIX, USAGE_ERR_INVALID_RESPONSE, USAGE_ERR_LOCK_ACQUIRE, USAGE_ERR_LOCK_HELD,
11    USAGE_ERR_LOCK_OPEN, USAGE_ERR_SERVICE_UNREACHABLE, USAGE_UNAVAILABLE_DEFAULT, command_name,
12};
13use crate::{is_plain, style_text, use_color_stdout};
14
15const DEFAULT_BASE_URL: &str = "https://chatgpt.com/backend-api";
16const USER_AGENT: &str = "codex-profiles";
17const USAGE_RETRY_ATTEMPTS: usize = 3;
18const USAGE_RETRY_BASE_MS: u64 = 250;
19const USAGE_BACKOFF_MAX_MS: u64 = 3_000;
20const USAGE_RETRY_JITTER_MS: u64 = 125;
21#[cfg(not(test))]
22const LOCK_TIMEOUT: Duration = Duration::from_secs(10);
23const LOCK_RETRY_DELAY: Duration = Duration::from_secs(1);
24
25#[cfg(test)]
26use std::cell::Cell;
27
28#[cfg(test)]
29const LOCK_FAIL_ERR: usize = 1;
30#[cfg(test)]
31const LOCK_FAIL_BUSY: usize = 2;
32#[cfg(test)]
33thread_local! {
34    static LOCK_FAILPOINT: Cell<usize> = const { Cell::new(0) };
35}
36
37#[derive(Clone, Default)]
38pub(crate) struct UsageLimits {
39    pub(crate) five_hour: Option<UsageWindow>,
40    pub(crate) weekly: Option<UsageWindow>,
41}
42
43#[derive(Clone, Debug)]
44pub(crate) struct UsageWindow {
45    pub(crate) left_percent: f64,
46    pub(crate) reset_at: i64,
47}
48
49#[derive(Clone, Serialize)]
50pub(crate) struct UsageSnapshotWindow {
51    pub(crate) left_percent: i64,
52    pub(crate) reset_at: i64,
53}
54
55#[derive(Clone, Serialize)]
56pub(crate) struct UsageSnapshotBucket {
57    pub(crate) id: String,
58    pub(crate) label: String,
59    pub(crate) five_hour: Option<UsageSnapshotWindow>,
60    pub(crate) weekly: Option<UsageSnapshotWindow>,
61}
62
63#[derive(Debug)]
64pub enum UsageFetchError {
65    Http(Box<crate::UnexpectedHttpError>),
66    Transport(String),
67    Parse(String),
68}
69
70impl UsageFetchError {
71    pub fn status_code(&self) -> Option<u16> {
72        match self {
73            UsageFetchError::Http(err) => Some(err.status_code()),
74            _ => None,
75        }
76    }
77
78    pub fn message(&self) -> String {
79        match self {
80            UsageFetchError::Http(err) => err.to_string(),
81            UsageFetchError::Transport(err) => crate::msg1(USAGE_ERR_SERVICE_UNREACHABLE, err),
82            UsageFetchError::Parse(err) => crate::msg1(USAGE_ERR_INVALID_RESPONSE, err),
83        }
84    }
85
86    pub fn plain_message(&self) -> String {
87        match self {
88            UsageFetchError::Http(err) => err.plain_message(),
89            UsageFetchError::Transport(err) => crate::msg1(USAGE_ERR_SERVICE_UNREACHABLE, err),
90            UsageFetchError::Parse(err) => crate::msg1(USAGE_ERR_INVALID_RESPONSE, err),
91        }
92    }
93}
94
95impl std::fmt::Display for UsageFetchError {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        f.write_str(&self.message())
98    }
99}
100
101#[derive(Debug, Deserialize)]
102struct UsagePayload {
103    #[serde(default)]
104    rate_limit: Option<RateLimitDetails>,
105    #[serde(default)]
106    additional_rate_limits: Option<Vec<AdditionalRateLimitDetails>>,
107}
108
109#[derive(Clone, Debug, Deserialize)]
110struct RateLimitDetails {
111    #[serde(default)]
112    primary_window: Option<RateLimitWindowSnapshot>,
113    #[serde(default)]
114    secondary_window: Option<RateLimitWindowSnapshot>,
115}
116
117#[derive(Clone, Debug, Deserialize)]
118struct AdditionalRateLimitDetails {
119    #[serde(default)]
120    limit_name: Option<String>,
121    #[serde(default)]
122    metered_feature: Option<String>,
123    #[serde(default)]
124    rate_limit: Option<RateLimitDetails>,
125}
126
127#[derive(Clone, Debug, Deserialize)]
128struct RateLimitWindowSnapshot {
129    used_percent: f64,
130    limit_window_seconds: i64,
131    reset_at: i64,
132}
133
134#[derive(Clone, Debug)]
135struct UsageBucket {
136    limit_id: String,
137    label: String,
138    rate_limit: Option<RateLimitDetails>,
139}
140
141pub fn read_base_url(paths: &Paths) -> Result<String, String> {
142    let config_path = paths.codex.join("config.toml");
143    if let Ok(contents) = fs::read_to_string(config_path) {
144        for line in contents.lines() {
145            if let Some(value) = parse_config_value(line, "chatgpt_base_url") {
146                return validate_base_url(&value);
147            }
148        }
149    }
150    Ok(DEFAULT_BASE_URL.to_string())
151}
152
153fn parse_config_value(line: &str, key: &str) -> Option<String> {
154    let line = line.trim();
155    if line.is_empty() || line.starts_with('#') {
156        return None;
157    }
158    let (config_key, raw_value) = line.split_once('=')?;
159    if config_key.trim() != key {
160        return None;
161    }
162    let value = strip_inline_comment(raw_value).trim();
163    if value.is_empty() {
164        return None;
165    }
166    let value = value.trim_matches('"').trim_matches('\'').trim();
167    if value.is_empty() {
168        return None;
169    }
170    Some(value.to_string())
171}
172
173fn strip_inline_comment(value: &str) -> &str {
174    let mut in_single = false;
175    let mut in_double = false;
176    let mut escape = false;
177    for (idx, ch) in value.char_indices() {
178        match ch {
179            '"' if !in_single && !escape => in_double = !in_double,
180            '\'' if !in_double => in_single = !in_single,
181            '#' if !in_single && !in_double => return value[..idx].trim_end(),
182            _ => {}
183        }
184        escape = in_double && ch == '\\' && !escape;
185        if ch != '\\' {
186            escape = false;
187        }
188    }
189    value.trim_end()
190}
191
192fn normalize_base_url(value: &str) -> String {
193    let mut base = value.trim_end_matches('/').to_string();
194    if let Some((scheme, host)) = parsed_url_scheme_and_host(&base)
195        && scheme == "https"
196        && matches!(host.as_str(), "chatgpt.com" | "chat.openai.com")
197        && !base.contains("/backend-api")
198    {
199        base = format!("{base}/backend-api");
200    }
201    base
202}
203
204fn validate_base_url(value: &str) -> Result<String, String> {
205    let base = normalize_base_url(value);
206    if is_allowed_base_url(&base) {
207        return Ok(base);
208    }
209    Err(format!(
210        "Unsupported chatgpt_base_url `{base}`. Use an official ChatGPT host or a loopback address."
211    ))
212}
213
214fn is_allowed_base_url(base_url: &str) -> bool {
215    let Some((scheme, host)) = parsed_url_scheme_and_host(base_url) else {
216        return false;
217    };
218    if is_loopback_host(&host) {
219        return matches!(scheme.as_str(), "http" | "https");
220    }
221    scheme == "https" && matches!(host.as_str(), "chatgpt.com" | "chat.openai.com")
222}
223
224fn parsed_url_scheme_and_host(base_url: &str) -> Option<(String, String)> {
225    let (scheme, rest) = base_url
226        .split_once("://")
227        .map(|(scheme, rest)| (scheme.to_ascii_lowercase(), rest))?;
228    let authority = rest.split('/').next().unwrap_or(rest);
229    if authority.is_empty() || authority.contains('@') {
230        return None;
231    }
232
233    let host = if authority.starts_with('[') {
234        authority
235            .trim_start_matches('[')
236            .split(']')
237            .next()
238            .unwrap_or(authority)
239            .to_ascii_lowercase()
240    } else {
241        authority
242            .split(':')
243            .next()
244            .unwrap_or(authority)
245            .trim_end_matches('.')
246            .to_ascii_lowercase()
247    };
248
249    if host.is_empty() {
250        return None;
251    }
252
253    Some((scheme, host))
254}
255
256fn is_loopback_host(host: &str) -> bool {
257    host == "localhost"
258        || host
259            .parse::<std::net::IpAddr>()
260            .ok()
261            .is_some_and(|ip| ip.is_loopback())
262        || is_ipv4_loopback_shorthand(host)
263}
264
265fn is_ipv4_loopback_shorthand(host: &str) -> bool {
266    let mut parts = host.split('.');
267    if parts.next() != Some("127") {
268        return false;
269    }
270
271    let mut count = 1usize;
272    for part in parts {
273        if part.is_empty() || part.parse::<u8>().is_err() {
274            return false;
275        }
276        count += 1;
277    }
278
279    count >= 2
280}
281
282fn usage_endpoint(base_url: &str) -> String {
283    if base_url.contains("/backend-api") {
284        format!("{base_url}/wham/usage")
285    } else {
286        format!("{base_url}/api/codex/usage")
287    }
288}
289
290fn fetch_usage_payload(
291    base_url: &str,
292    access_token: &str,
293    account_id: &str,
294) -> Result<UsagePayload, UsageFetchError> {
295    let endpoint = usage_endpoint(base_url);
296    let config = ureq::Agent::config_builder()
297        .timeout_global(Some(Duration::from_secs(5)))
298        .http_status_as_error(false)
299        .build();
300    let agent: ureq::Agent = config.into();
301    for attempt in 0..USAGE_RETRY_ATTEMPTS {
302        let response = match agent
303            .get(&endpoint)
304            .header("Authorization", &format!("Bearer {access_token}"))
305            .header("ChatGPT-Account-Id", account_id)
306            .header("User-Agent", USER_AGENT)
307            .call()
308        {
309            Ok(response) => response,
310            Err(err) => {
311                if usage_should_retry_transport_error(&err)
312                    && let Some(delay) = usage_retry_delay(attempt, None)
313                {
314                    thread::sleep(delay);
315                    continue;
316                }
317                return Err(UsageFetchError::Transport(err.to_string()));
318            }
319        };
320        let status = response.status();
321        if usage_should_retry_status(status.as_u16()) {
322            let retry_after = response
323                .headers()
324                .get("Retry-After")
325                .and_then(|value| value.to_str().ok());
326            if let Some(delay) = usage_retry_delay(attempt, retry_after) {
327                thread::sleep(delay);
328                continue;
329            }
330        }
331        if !status.is_success() {
332            return Err(UsageFetchError::Http(Box::new(
333                crate::UnexpectedHttpError::from_ureq_response(response, Some(&endpoint)),
334            )));
335        }
336        return response
337            .into_body()
338            .read_json::<UsagePayload>()
339            .map_err(|err| UsageFetchError::Parse(err.to_string()));
340    }
341    unreachable!("usage retry loop should always return or continue")
342}
343
344fn usage_should_retry_status(status: u16) -> bool {
345    status == 429 || (500..=599).contains(&status)
346}
347
348fn usage_should_retry_transport_error(err: &ureq::Error) -> bool {
349    matches!(
350        err,
351        ureq::Error::Timeout(_)
352            | ureq::Error::Io(_)
353            | ureq::Error::HostNotFound
354            | ureq::Error::ConnectionFailed
355    )
356}
357
358fn usage_retry_delay(attempt: usize, retry_after: Option<&str>) -> Option<Duration> {
359    if attempt + 1 >= USAGE_RETRY_ATTEMPTS {
360        return None;
361    }
362    if let Some(delay) = retry_after.and_then(parse_retry_after) {
363        return Some(delay);
364    }
365    let shift = attempt.min(10) as u32;
366    let base = USAGE_RETRY_BASE_MS.saturating_mul(1u64 << shift);
367    let mut delay = Duration::from_millis(base.min(USAGE_BACKOFF_MAX_MS));
368    let jitter = usage_retry_jitter();
369    delay += jitter;
370    Some(delay.min(Duration::from_millis(USAGE_BACKOFF_MAX_MS)))
371}
372
373fn usage_retry_jitter() -> Duration {
374    if USAGE_RETRY_JITTER_MS == 0 {
375        return Duration::from_millis(0);
376    }
377    let nanos = SystemTime::now()
378        .duration_since(UNIX_EPOCH)
379        .unwrap_or_default()
380        .subsec_nanos() as u64;
381    Duration::from_millis(nanos % (USAGE_RETRY_JITTER_MS + 1))
382}
383
384fn parse_retry_after(value: &str) -> Option<Duration> {
385    let value = value.trim();
386    if value.is_empty() {
387        return None;
388    }
389    if let Ok(seconds) = value.parse::<u64>() {
390        return Some(Duration::from_secs(seconds));
391    }
392    let parsed = chrono::DateTime::parse_from_rfc2822(value).ok()?;
393    let retry_at = parsed.with_timezone(&Utc).timestamp();
394    let now = Utc::now().timestamp();
395    if retry_at <= now {
396        return Some(Duration::from_millis(0));
397    }
398    Some(Duration::from_secs((retry_at - now) as u64))
399}
400
401pub(crate) fn fetch_usage_status(
402    base_url: &str,
403    access_token: &str,
404    account_id: &str,
405    unavailable_text: &str,
406    now: DateTime<Local>,
407) -> Result<(Vec<String>, Vec<UsageSnapshotBucket>), UsageFetchError> {
408    let payload = fetch_usage_payload(base_url, access_token, account_id)?;
409    Ok((
410        usage_lines_from_payload(&payload, unavailable_text, now),
411        usage_snapshot_from_payload(&payload),
412    ))
413}
414
415#[cfg(test)]
416fn build_usage_limits(payload: &UsagePayload) -> UsageLimits {
417    let buckets = ordered_usage_buckets(usage_buckets(payload));
418    let Some(preferred_bucket) = buckets.first() else {
419        return UsageLimits::default();
420    };
421    build_usage_limits_for_rate_limit(preferred_bucket.rate_limit.as_ref())
422}
423
424fn usage_lines_from_payload(
425    payload: &UsagePayload,
426    unavailable_text: &str,
427    now: DateTime<Local>,
428) -> Vec<String> {
429    let buckets = ordered_usage_buckets(usage_buckets(payload));
430    if buckets.is_empty() {
431        return vec![format_usage_unavailable(
432            unavailable_text,
433            use_color_stdout(),
434        )];
435    }
436    let multi_bucket = buckets.len() > 1;
437    let mut lines = Vec::new();
438    for bucket in buckets {
439        let limits = build_usage_limits_for_rate_limit(bucket.rate_limit.as_ref());
440        let has_data = limits.five_hour.is_some() || limits.weekly.is_some();
441        if !has_data {
442            continue;
443        }
444        let mut bucket_lines = format_usage(
445            format_limit(limits.five_hour.as_ref(), now, unavailable_text),
446            format_limit(limits.weekly.as_ref(), now, unavailable_text),
447            unavailable_text,
448        );
449        if limits.five_hour.is_some() && limits.weekly.is_some() {
450            bucket_lines = label_dual_window_lines(bucket_lines);
451        }
452        if multi_bucket {
453            let label = usage_bucket_label(&bucket);
454            lines.push(label.to_string());
455            lines.extend(bucket_lines.into_iter().map(|line| format!("  {line}")));
456        } else {
457            lines.extend(bucket_lines);
458        }
459    }
460    if lines.is_empty() {
461        vec![format_usage_unavailable(
462            unavailable_text,
463            use_color_stdout(),
464        )]
465    } else {
466        lines
467    }
468}
469
470fn label_dual_window_lines(mut lines: Vec<String>) -> Vec<String> {
471    if let Some(first) = lines.get_mut(0) {
472        *first = format!("5 hour: {first}");
473    }
474    if let Some(second) = lines.get_mut(1) {
475        *second = format!("Weekly: {second}");
476    }
477    lines
478}
479
480fn usage_buckets(payload: &UsagePayload) -> Vec<UsageBucket> {
481    let mut buckets = Vec::new();
482    if let Some(rate_limit) = payload.rate_limit.clone() {
483        buckets.push(UsageBucket {
484            limit_id: "codex".to_string(),
485            label: "codex".to_string(),
486            rate_limit: Some(rate_limit),
487        });
488    }
489    if let Some(additional) = payload.additional_rate_limits.as_ref() {
490        buckets.extend(additional.iter().map(|details| {
491            let limit_id = details
492                .metered_feature
493                .as_deref()
494                .map(str::trim)
495                .filter(|value| !value.is_empty())
496                .unwrap_or("unknown")
497                .to_string();
498            let label = details
499                .limit_name
500                .as_deref()
501                .map(str::trim)
502                .filter(|value| !value.is_empty())
503                .unwrap_or(limit_id.as_str())
504                .to_string();
505            UsageBucket {
506                limit_id,
507                label,
508                rate_limit: details.rate_limit.clone(),
509            }
510        }));
511    }
512    buckets
513}
514
515fn ordered_usage_buckets(mut buckets: Vec<UsageBucket>) -> Vec<UsageBucket> {
516    if let Some(index) = buckets.iter().position(|bucket| bucket.limit_id == "codex")
517        && index != 0
518    {
519        let preferred = buckets.remove(index);
520        buckets.insert(0, preferred);
521    }
522    buckets
523}
524
525fn usage_bucket_label(bucket: &UsageBucket) -> &str {
526    if bucket.label.trim().is_empty() {
527        "unknown"
528    } else {
529        bucket.label.as_str()
530    }
531}
532
533fn build_usage_limits_for_rate_limit(rate_limit: Option<&RateLimitDetails>) -> UsageLimits {
534    let mut limits = UsageLimits::default();
535    let Some(rate_limit) = rate_limit else {
536        return limits;
537    };
538    let mut windows: Vec<(i64, UsageWindow)> = [
539        rate_limit.primary_window.as_ref(),
540        rate_limit.secondary_window.as_ref(),
541    ]
542    .into_iter()
543    .flatten()
544    .map(|window| (window.limit_window_seconds, usage_window_output(window)))
545    .collect();
546    if windows.is_empty() {
547        return limits;
548    }
549    windows.sort_by_key(|(secs, _)| *secs);
550    if let Some((_, first)) = windows.first() {
551        limits.five_hour = Some(first.clone());
552    }
553    if let Some((_, second)) = windows.get(1) {
554        limits.weekly = Some(second.clone());
555    }
556    limits
557}
558
559fn usage_snapshot_from_payload(payload: &UsagePayload) -> Vec<UsageSnapshotBucket> {
560    ordered_usage_buckets(usage_buckets(payload))
561        .into_iter()
562        .filter_map(|bucket| {
563            let limits = build_usage_limits_for_rate_limit(bucket.rate_limit.as_ref());
564            let five_hour = limits.five_hour.as_ref().map(usage_snapshot_window);
565            let weekly = limits.weekly.as_ref().map(usage_snapshot_window);
566            if five_hour.is_none() && weekly.is_none() {
567                return None;
568            }
569            let label = usage_bucket_label(&bucket).to_string();
570            Some(UsageSnapshotBucket {
571                id: bucket.limit_id,
572                label,
573                five_hour,
574                weekly,
575            })
576        })
577        .collect()
578}
579
580fn usage_snapshot_window(window: &UsageWindow) -> UsageSnapshotWindow {
581    UsageSnapshotWindow {
582        left_percent: window.left_percent.round() as i64,
583        reset_at: window.reset_at,
584    }
585}
586
587fn usage_window_output(window: &RateLimitWindowSnapshot) -> UsageWindow {
588    let left_percent = (100.0 - window.used_percent).clamp(0.0, 100.0);
589    let reset_at = window.reset_at;
590    UsageWindow {
591        left_percent,
592        reset_at,
593    }
594}
595
596pub(crate) struct UsageLine {
597    pub(crate) bar: String,
598    pub(crate) percent: String,
599    pub(crate) reset: String,
600    pub(crate) left_percent: Option<i64>,
601}
602
603impl UsageLine {
604    fn unavailable(text: &str) -> Self {
605        UsageLine {
606            bar: text.to_string(),
607            percent: String::new(),
608            reset: String::new(),
609            left_percent: None,
610        }
611    }
612}
613
614pub(crate) fn format_limit(
615    window: Option<&UsageWindow>,
616    now: DateTime<Local>,
617    unavailable_text: &str,
618) -> UsageLine {
619    let Some(window) = window else {
620        return UsageLine::unavailable(unavailable_text);
621    };
622    let left_percent = window.left_percent;
623    let left_percent_rounded = left_percent.round() as i64;
624    let bar = render_bar(left_percent);
625    let bar = style_usage_bar(&bar, left_percent);
626    let percent = format!("{left_percent_rounded}%");
627    let reset =
628        format_reset_timestamp(window.reset_at, now).unwrap_or_else(|| "unknown".to_string());
629    UsageLine {
630        bar,
631        percent,
632        reset,
633        left_percent: Some(left_percent_rounded),
634    }
635}
636
637pub fn usage_unavailable() -> &'static str {
638    USAGE_UNAVAILABLE_DEFAULT
639}
640
641pub fn format_usage_unavailable(text: &str, use_color: bool) -> String {
642    if is_plain() {
643        crate::msg1(UI_INFO_PREFIX, text)
644    } else if use_color {
645        text.red().bold().to_string()
646    } else {
647        text.to_string()
648    }
649}
650
651pub(crate) fn format_usage(
652    five: UsageLine,
653    weekly: UsageLine,
654    unavailable_text: &str,
655) -> Vec<String> {
656    let use_color = use_color_stdout();
657    let available: Vec<UsageLine> = [five, weekly]
658        .into_iter()
659        .filter(|line| line.left_percent.is_some())
660        .collect();
661    if available.is_empty() {
662        return vec![format_usage_unavailable(unavailable_text, use_color)];
663    }
664    let has_zero = available.iter().any(|line| line.left_percent == Some(0));
665    let multiple = available.len() > 1;
666    available
667        .into_iter()
668        .map(|line| {
669            let dim = use_color && multiple && has_zero && line.left_percent != Some(0);
670            format_usage_line(&line, dim, use_color)
671        })
672        .collect()
673}
674
675pub(crate) fn format_reset_timestamp(reset_at: i64, now: DateTime<Local>) -> Option<String> {
676    let reset_at = local_from_timestamp(reset_at)?;
677    let time = reset_at.format("%H:%M").to_string();
678    if reset_at.date_naive() == now.date_naive() {
679        Some(time)
680    } else {
681        Some(format!("{time} on {}", reset_at.format("%-d %b")))
682    }
683}
684
685fn format_usage_line(line: &UsageLine, dim: bool, use_color: bool) -> String {
686    let reset = reset_label(&line.reset);
687    let reset = reset.to_string();
688    let percent = if line.percent.is_empty() {
689        String::new()
690    } else {
691        format!("{} left", line.percent)
692    };
693    let resets = format_resets_suffix(&reset, use_color);
694    if is_plain() {
695        let mut out = String::new();
696        if !percent.is_empty() {
697            out.push_str(&percent);
698        }
699        if !resets.is_empty() {
700            if !out.is_empty() {
701                out.push(' ');
702            }
703            out.push_str(&resets);
704        }
705        return out;
706    }
707    let resets = if resets.is_empty() {
708        resets
709    } else {
710        format!(" {resets}")
711    };
712    let bar = if dim {
713        crate::ui::strip_ansi(&line.bar)
714    } else {
715        line.bar.clone()
716    };
717    let formatted = if percent.is_empty() {
718        format!("{bar}{resets}")
719    } else {
720        format!("{bar} {percent}{resets}")
721    };
722    if dim && use_color {
723        formatted.dimmed().to_string()
724    } else {
725        formatted
726    }
727}
728
729fn reset_label(reset: &str) -> &str {
730    if reset.is_empty() { "unknown" } else { reset }
731}
732
733fn format_resets_suffix(reset: &str, use_color: bool) -> String {
734    let text = format!("(resets {reset})");
735    style_text(&text, use_color, |text| text.dimmed().italic())
736}
737
738fn render_bar(left_percent: f64) -> String {
739    let total = 20;
740    let filled = ((left_percent / 100.0) * total as f64).round() as usize;
741    let filled = filled.min(total);
742    let empty = total.saturating_sub(filled);
743    format!(
744        "{}{}",
745        "▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮"
746            .chars()
747            .take(filled)
748            .collect::<String>(),
749        "▯▯▯▯▯▯▯▯▯▯▯▯▯▯▯▯▯▯▯▯"
750            .chars()
751            .take(empty)
752            .collect::<String>()
753    )
754}
755
756fn style_usage_bar(bar: &str, left_percent: f64) -> String {
757    if !use_color_stdout() {
758        return bar.to_string();
759    }
760    if left_percent >= 66.0 {
761        bar.green().to_string()
762    } else if left_percent >= 33.0 {
763        bar.yellow().to_string()
764    } else {
765        bar.red().to_string()
766    }
767}
768
769fn local_from_timestamp(ts: i64) -> Option<DateTime<Local>> {
770    let dt = chrono::Utc.timestamp_opt(ts, 0).single()?;
771    Some(dt.with_timezone(&Local))
772}
773
774#[derive(Debug)]
775pub struct UsageLock {
776    _lock: LockFile,
777}
778
779pub fn lock_usage(paths: &Paths) -> Result<UsageLock, String> {
780    let start = Instant::now();
781    let mut lock = LockFile::open(&paths.profiles_lock)
782        .map_err(|err| crate::msg1(USAGE_ERR_LOCK_OPEN, err))?;
783    loop {
784        match try_lock(&mut lock) {
785            Ok(true) => break,
786            Ok(false) => {
787                if start.elapsed() > lock_timeout() {
788                    return Err(crate::msg1(USAGE_ERR_LOCK_ACQUIRE, command_name()));
789                }
790                thread::sleep(LOCK_RETRY_DELAY);
791            }
792            Err(err) => {
793                return Err(crate::msg1(USAGE_ERR_LOCK_HELD, err));
794            }
795        }
796    }
797    Ok(UsageLock { _lock: lock })
798}
799
800#[cfg(not(test))]
801fn lock_timeout() -> Duration {
802    LOCK_TIMEOUT
803}
804
805#[cfg(not(test))]
806fn try_lock(lock: &mut LockFile) -> Result<bool, fslock::Error> {
807    lock.try_lock()
808}
809
810#[cfg(test)]
811fn lock_timeout() -> Duration {
812    Duration::from_millis(50)
813}
814
815#[cfg(test)]
816fn try_lock(lock: &mut LockFile) -> Result<bool, fslock::Error> {
817    let fail_mode = LOCK_FAILPOINT.with(|failpoint| failpoint.get());
818    match fail_mode {
819        LOCK_FAIL_ERR => Err(std::io::Error::other("fail")),
820        LOCK_FAIL_BUSY => Ok(false),
821        _ => lock.try_lock(),
822    }
823}
824
825#[cfg(test)]
826mod tests {
827    use super::*;
828    use crate::test_utils::{
829        http_ok_response, make_paths, set_env_guard, set_plain_guard, spawn_server,
830    };
831    use std::fs;
832    use std::io::{Read, Write};
833    use std::net::TcpListener;
834    use std::sync::Mutex;
835    use std::thread;
836
837    static LOCK_TEST_MUTEX: Mutex<()> = Mutex::new(());
838
839    enum TestServerStep {
840        Close,
841        Respond(String),
842    }
843
844    fn spawn_server_sequence(steps: Vec<TestServerStep>) -> String {
845        let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
846        let addr = listener.local_addr().expect("addr");
847        thread::spawn(move || {
848            for step in steps {
849                let Ok((mut stream, _)) = listener.accept() else {
850                    break;
851                };
852                let mut buf = [0_u8; 4096];
853                let _ = stream.read(&mut buf);
854                match step {
855                    TestServerStep::Close => {}
856                    TestServerStep::Respond(response) => {
857                        let _ = stream.write_all(response.as_bytes());
858                    }
859                }
860            }
861        });
862        format!("http://{addr}")
863    }
864
865    #[test]
866    fn config_parsing_paths() {
867        assert!(parse_config_value("", "key").is_none());
868        assert!(parse_config_value("# comment", "key").is_none());
869        assert!(parse_config_value("other = 1", "key").is_none());
870        assert!(parse_config_value("key =", "key").is_none());
871        assert_eq!(
872            parse_config_value("key = 'value'", "key"),
873            Some("value".to_string())
874        );
875        assert_eq!(
876            parse_config_value(
877                r#"chatgpt_base_url = "https://chatgpt.com/backend-api" # comment"#,
878                "chatgpt_base_url"
879            ),
880            Some("https://chatgpt.com/backend-api".to_string())
881        );
882        assert_eq!(
883            parse_config_value(
884                r#"chatgpt_base_url = "https://example.com/#/foo" # tail"#,
885                "chatgpt_base_url"
886            ),
887            Some("https://example.com/#/foo".to_string())
888        );
889        assert!(parse_config_value("other = \"value\"", "chatgpt_base_url").is_none());
890        assert!(
891            parse_config_value("chatgpt_base_url = '' # comment", "chatgpt_base_url").is_none()
892        );
893        assert_eq!(strip_inline_comment("value # comment"), "value");
894    }
895
896    #[test]
897    fn normalize_base_url_and_endpoint() {
898        let url = normalize_base_url("https://chatgpt.com");
899        assert!(url.ends_with("/backend-api"));
900        assert!(usage_endpoint(&url).contains("wham/usage"));
901        assert!(usage_endpoint("http://example.com").contains("api/codex/usage"));
902    }
903
904    #[test]
905    fn read_base_url_rejects_unsafe_remote_hosts() {
906        let dir = tempfile::tempdir().expect("tempdir");
907        let paths = make_paths(dir.path());
908        fs::create_dir_all(&paths.codex).unwrap();
909        fs::write(
910            paths.codex.join("config.toml"),
911            "chatgpt_base_url = \"http://example.com\"\n",
912        )
913        .unwrap();
914
915        let err = read_base_url(&paths).unwrap_err();
916
917        assert!(err.contains("Unsupported chatgpt_base_url"));
918    }
919
920    #[test]
921    fn read_base_url_rejects_spoofed_official_hosts() {
922        let dir = tempfile::tempdir().expect("tempdir");
923        let paths = make_paths(dir.path());
924        fs::create_dir_all(&paths.codex).unwrap();
925
926        for value in [
927            "https://chatgpt.com.evil.test",
928            "https://chatgpt.com@evil.test",
929        ] {
930            fs::write(
931                paths.codex.join("config.toml"),
932                format!("chatgpt_base_url = \"{value}\"\n"),
933            )
934            .unwrap();
935
936            let err = read_base_url(&paths).unwrap_err();
937            assert!(err.contains("Unsupported chatgpt_base_url"));
938        }
939    }
940
941    #[test]
942    fn read_base_url_allows_loopback_hosts() {
943        let dir = tempfile::tempdir().expect("tempdir");
944        let paths = make_paths(dir.path());
945        fs::create_dir_all(&paths.codex).unwrap();
946        for value in [
947            "http://127.0.0.1:8765",
948            "http://127.0.0.2:8765",
949            "http://127.1:8765",
950            "http://localhost:8765",
951            "http://[::1]:8765",
952        ] {
953            fs::write(
954                paths.codex.join("config.toml"),
955                format!("chatgpt_base_url = \"{value}\"\n"),
956            )
957            .unwrap();
958
959            let base_url = read_base_url(&paths).unwrap();
960
961            assert_eq!(base_url, value);
962        }
963    }
964
965    #[test]
966    fn read_base_url_rejects_invalid_loopback_shorthand_hosts() {
967        let dir = tempfile::tempdir().expect("tempdir");
968        let paths = make_paths(dir.path());
969        fs::create_dir_all(&paths.codex).unwrap();
970        for value in [
971            "http://127..1:8765",
972            "http://127.a:8765",
973            "http://127.256:8765",
974        ] {
975            fs::write(
976                paths.codex.join("config.toml"),
977                format!("chatgpt_base_url = \"{value}\"\n"),
978            )
979            .unwrap();
980
981            let err = read_base_url(&paths).unwrap_err();
982            assert!(err.contains("Unsupported chatgpt_base_url"));
983        }
984    }
985
986    #[test]
987    fn fetch_usage_payload_paths() {
988        let payload = r#"{"rate_limit":{"primary_window":{"used_percent":50.0,"limit_window_seconds":3600,"reset_at":1}}}"#;
989        let resp = http_ok_response(payload, "application/json");
990        let url = spawn_server(resp);
991        let base_url = format!("{url}/backend-api");
992        fetch_usage_payload(&base_url, "token", "acct").unwrap();
993
994        let err_body = "server exploded";
995        let err_resp = format!(
996            "HTTP/1.1 500 Internal Server Error\r\nContent-Length: {}\r\n\r\n{}",
997            err_body.len(),
998            err_body
999        );
1000        let err_steps = (0..USAGE_RETRY_ATTEMPTS)
1001            .map(|_| TestServerStep::Respond(err_resp.clone()))
1002            .collect();
1003        let err_url = spawn_server_sequence(err_steps);
1004        let base_url = format!("{err_url}/backend-api");
1005        let err = fetch_usage_payload(&base_url, "token", "acct").unwrap_err();
1006        assert!(matches!(err, UsageFetchError::Http(_)));
1007        assert!(
1008            err.message()
1009                .contains("unexpected status 500 Internal Server Error: server exploded")
1010        );
1011
1012        let code_body = r#"{"detail":{"code":"deactivated_workspace"}}"#;
1013        let code_resp = format!(
1014            "HTTP/1.1 402 Payment Required\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
1015            code_body.len(),
1016            code_body
1017        );
1018        let code_url = spawn_server(code_resp);
1019        let base_url = format!("{code_url}/backend-api");
1020        let err = fetch_usage_payload(&base_url, "token", "acct").unwrap_err();
1021        assert!(err.message().contains("unexpected status 402 Payment Required: {\"detail\":{\"code\":\"deactivated_workspace\"}}"));
1022
1023        let bad_resp =
1024            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 1\r\n\r\n{"
1025                .to_string();
1026        let bad_url = spawn_server(bad_resp);
1027        let base_url = format!("{bad_url}/backend-api");
1028        let err = fetch_usage_payload(&base_url, "token", "acct").unwrap_err();
1029        assert!(matches!(err, UsageFetchError::Parse(_)));
1030    }
1031
1032    #[test]
1033    fn fetch_usage_payload_retries_http_5xx_before_success() {
1034        let ok_payload = r#"{"rate_limit":{"primary_window":{"used_percent":50.0,"limit_window_seconds":3600,"reset_at":1}}}"#;
1035        let ok_response = http_ok_response(ok_payload, "application/json");
1036        let error_body = "temporary failure";
1037        let error_response = format!(
1038            "HTTP/1.1 500 Internal Server Error\r\nContent-Length: {}\r\n\r\n{}",
1039            error_body.len(),
1040            error_body
1041        );
1042        let url = spawn_server_sequence(vec![
1043            TestServerStep::Respond(error_response),
1044            TestServerStep::Respond(ok_response),
1045        ]);
1046        let base_url = format!("{url}/backend-api");
1047
1048        let payload = fetch_usage_payload(&base_url, "token", "acct").unwrap();
1049        assert!(payload.rate_limit.is_some());
1050    }
1051
1052    #[test]
1053    fn fetch_usage_payload_retries_transport_errors_before_success() {
1054        let ok_payload = r#"{"rate_limit":{"primary_window":{"used_percent":50.0,"limit_window_seconds":3600,"reset_at":1}}}"#;
1055        let ok_response = http_ok_response(ok_payload, "application/json");
1056        let url = spawn_server_sequence(vec![
1057            TestServerStep::Close,
1058            TestServerStep::Respond(ok_response),
1059        ]);
1060        let base_url = format!("{url}/backend-api");
1061
1062        let payload = fetch_usage_payload(&base_url, "token", "acct").unwrap();
1063        assert!(payload.rate_limit.is_some());
1064    }
1065
1066    #[test]
1067    fn fetch_usage_payload_returns_transport_error_after_retry_budget() {
1068        let steps = (0..USAGE_RETRY_ATTEMPTS)
1069            .map(|_| TestServerStep::Close)
1070            .collect();
1071        let url = spawn_server_sequence(steps);
1072        let base_url = format!("{url}/backend-api");
1073
1074        let err = fetch_usage_payload(&base_url, "token", "acct").unwrap_err();
1075        assert!(matches!(err, UsageFetchError::Transport(_)));
1076    }
1077
1078    #[test]
1079    fn fetch_usage_details_paths() {
1080        let payload = r#"{"rate_limit":{"primary_window":{"used_percent":10.0,"limit_window_seconds":3600,"reset_at":1}}}"#;
1081        let resp = http_ok_response(payload, "application/json");
1082        let url = spawn_server(resp);
1083        let base_url = format!("{url}/backend-api");
1084        let (lines, buckets) =
1085            fetch_usage_status(&base_url, "token", "acct", "unavailable", Local::now()).unwrap();
1086        assert!(!lines.is_empty());
1087        assert!(!buckets.is_empty());
1088    }
1089
1090    #[test]
1091    fn retry_after_parsing_paths() {
1092        assert_eq!(parse_retry_after("2"), Some(Duration::from_secs(2)));
1093        assert!(parse_retry_after("Thu, 01 Jan 1970 00:00:00 GMT").is_some());
1094        assert!(parse_retry_after("not-a-date").is_none());
1095        assert!(usage_retry_delay(USAGE_RETRY_ATTEMPTS - 1, Some("1")).is_none());
1096        assert!(usage_retry_delay(0, Some("2")).is_some());
1097        assert_eq!(
1098            usage_retry_delay(0, Some("7")),
1099            Some(Duration::from_secs(7))
1100        );
1101    }
1102
1103    #[test]
1104    fn usage_limits_and_formatting() {
1105        let payload = UsagePayload {
1106            rate_limit: None,
1107            additional_rate_limits: None,
1108        };
1109        let limits = build_usage_limits(&payload);
1110        assert!(limits.five_hour.is_none());
1111
1112        let window = RateLimitWindowSnapshot {
1113            used_percent: 50.0,
1114            limit_window_seconds: 10,
1115            reset_at: Local::now().timestamp(),
1116        };
1117        let rate_limit = RateLimitDetails {
1118            primary_window: Some(window.clone()),
1119            secondary_window: Some(window.clone()),
1120        };
1121        let payload = UsagePayload {
1122            rate_limit: Some(rate_limit),
1123            additional_rate_limits: None,
1124        };
1125        let limits = build_usage_limits(&payload);
1126        assert!(limits.five_hour.is_some());
1127        let line = format_limit(limits.five_hour.as_ref(), Local::now(), "none");
1128        assert!(line.left_percent.is_some());
1129    }
1130
1131    #[test]
1132    fn usage_limits_fallback_to_additional_bucket_when_primary_missing() {
1133        let window = RateLimitWindowSnapshot {
1134            used_percent: 25.0,
1135            limit_window_seconds: 900,
1136            reset_at: Local::now().timestamp(),
1137        };
1138        let payload = UsagePayload {
1139            rate_limit: None,
1140            additional_rate_limits: Some(vec![AdditionalRateLimitDetails {
1141                limit_name: Some("codex_other".to_string()),
1142                metered_feature: Some("codex_other".to_string()),
1143                rate_limit: Some(RateLimitDetails {
1144                    primary_window: Some(window),
1145                    secondary_window: None,
1146                }),
1147            }]),
1148        };
1149        let limits = build_usage_limits(&payload);
1150        assert!(limits.five_hour.is_some());
1151    }
1152
1153    #[test]
1154    fn usage_lines_include_multi_bucket_labels() {
1155        let _plain = set_plain_guard(true);
1156        let now = Local::now();
1157        let payload = UsagePayload {
1158            rate_limit: Some(RateLimitDetails {
1159                primary_window: Some(RateLimitWindowSnapshot {
1160                    used_percent: 20.0,
1161                    limit_window_seconds: 18000,
1162                    reset_at: now.timestamp() + 600,
1163                }),
1164                secondary_window: None,
1165            }),
1166            additional_rate_limits: Some(vec![AdditionalRateLimitDetails {
1167                limit_name: Some("codex_other".to_string()),
1168                metered_feature: Some("codex_other".to_string()),
1169                rate_limit: Some(RateLimitDetails {
1170                    primary_window: Some(RateLimitWindowSnapshot {
1171                        used_percent: 60.0,
1172                        limit_window_seconds: 3600,
1173                        reset_at: now.timestamp() + 900,
1174                    }),
1175                    secondary_window: None,
1176                }),
1177            }]),
1178        };
1179        let lines = usage_lines_from_payload(&payload, "unavailable", now);
1180        assert!(lines.iter().any(|line| line == "codex"));
1181        assert!(lines.iter().any(|line| line == "codex_other"));
1182        assert!(
1183            lines
1184                .iter()
1185                .any(|line| line.starts_with("  ") && line.contains("left"))
1186        );
1187    }
1188
1189    #[test]
1190    fn usage_lines_label_dual_windows_for_single_bucket() {
1191        let _plain = set_plain_guard(true);
1192        let now = Local::now();
1193        let payload = UsagePayload {
1194            rate_limit: Some(RateLimitDetails {
1195                primary_window: Some(RateLimitWindowSnapshot {
1196                    used_percent: 20.0,
1197                    limit_window_seconds: 18000,
1198                    reset_at: now.timestamp() + 600,
1199                }),
1200                secondary_window: Some(RateLimitWindowSnapshot {
1201                    used_percent: 50.0,
1202                    limit_window_seconds: 604800,
1203                    reset_at: now.timestamp() + 3600,
1204                }),
1205            }),
1206            additional_rate_limits: None,
1207        };
1208        let lines = usage_lines_from_payload(&payload, "unavailable", now);
1209        assert!(lines.iter().any(|line| line.starts_with("5 hour: ")));
1210        assert!(lines.iter().any(|line| line.starts_with("Weekly: ")));
1211    }
1212
1213    #[test]
1214    fn usage_unavailable_paths() {
1215        let _plain = set_plain_guard(true);
1216        assert_eq!(usage_unavailable(), "Data not available");
1217        let text = format_usage_unavailable("text", false);
1218        assert!(text.contains("Info"));
1219    }
1220
1221    #[test]
1222    fn format_usage_variants() {
1223        let unavailable = "unavailable";
1224        let lines = format_usage(
1225            UsageLine::unavailable(unavailable),
1226            UsageLine::unavailable(unavailable),
1227            unavailable,
1228        );
1229        assert_eq!(lines.len(), 1);
1230    }
1231
1232    #[test]
1233    fn format_usage_line_plain_and_dim() {
1234        let line = UsageLine {
1235            bar: render_bar(50.0),
1236            percent: "50%".to_string(),
1237            reset: "soon".to_string(),
1238            left_percent: Some(50),
1239        };
1240        let _plain = set_plain_guard(true);
1241        let plain = format_usage_line(&line, false, false);
1242        assert!(plain.contains("left"));
1243    }
1244
1245    #[test]
1246    fn style_bar_and_strip_ansi() {
1247        let _env = set_env_guard("NO_COLOR", Some("1"));
1248        let bar = render_bar(10.0);
1249        let styled = style_usage_bar(&bar, 10.0);
1250        assert_eq!(bar, styled);
1251        let stripped = crate::ui::strip_ansi("\x1b[31mred\x1b[0m");
1252        assert_eq!(stripped, "red");
1253    }
1254
1255    #[test]
1256    fn format_reset_timestamp_helpers() {
1257        use chrono::Timelike;
1258        let now = Local::now()
1259            .with_hour(12)
1260            .and_then(|value| value.with_minute(0))
1261            .and_then(|value| value.with_second(0))
1262            .and_then(|value| value.with_nanosecond(0))
1263            .expect("valid midday");
1264        let same_day = format_reset_timestamp(now.timestamp() + 60, now).expect("same day");
1265        let cross_day =
1266            format_reset_timestamp(now.timestamp() + 60 * 60 * 24, now).expect("cross day");
1267        assert!(same_day.contains(':'));
1268        assert!(!same_day.contains(" on "));
1269        assert!(cross_day.contains(" on "));
1270        assert!(local_from_timestamp(0).is_some());
1271        assert!(local_from_timestamp(-1).is_some());
1272    }
1273
1274    #[test]
1275    fn lock_usage_failure_paths() {
1276        let _guard = LOCK_TEST_MUTEX.lock().unwrap();
1277        let dir = tempfile::tempdir().expect("tempdir");
1278        let paths = make_paths(dir.path());
1279        fs::create_dir_all(&paths.profiles).unwrap();
1280        fs::write(&paths.profiles_lock, "").unwrap();
1281
1282        LOCK_FAILPOINT.with(|failpoint| failpoint.set(LOCK_FAIL_BUSY));
1283        let err = lock_usage(&paths).unwrap_err();
1284        assert!(err.contains("Could not acquire profiles lock"));
1285        LOCK_FAILPOINT.with(|failpoint| failpoint.set(LOCK_FAIL_ERR));
1286        let err = lock_usage(&paths).unwrap_err();
1287        assert!(err.contains("Could not lock profiles file"));
1288        LOCK_FAILPOINT.with(|failpoint| failpoint.set(0));
1289    }
1290
1291    #[test]
1292    fn lock_usage_open_error() {
1293        let _guard = LOCK_TEST_MUTEX.lock().unwrap();
1294        let dir = tempfile::tempdir().expect("tempdir");
1295        let lock_dir = dir.path().join("locked");
1296        fs::create_dir_all(&lock_dir).unwrap();
1297        #[cfg(unix)]
1298        {
1299            use std::os::unix::fs::PermissionsExt;
1300            fs::set_permissions(&lock_dir, fs::Permissions::from_mode(0o400)).unwrap();
1301        }
1302        let mut paths = make_paths(dir.path());
1303        paths.profiles_lock = lock_dir.join("profiles.lock");
1304        let err = lock_usage(&paths).unwrap_err();
1305        assert!(err.contains("Could not open profiles lock"));
1306    }
1307}