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}