1use chrono::{DateTime, Local};
2use colored::Colorize;
3use fslock::LockFile;
4use serde::Deserialize;
5use std::collections::{BTreeMap, HashSet};
6use std::fs;
7use std::io::Write;
8use std::sync::Arc;
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::thread::{self, JoinHandle};
11use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
12
13use crate::{Paths, command_name};
14use crate::{is_plain, style_text, use_color_stdout, use_tty_stderr};
15
16const DEFAULT_BASE_URL: &str = "https://chatgpt.com/backend-api";
17const USER_AGENT: &str = "codex-profiles";
18#[cfg(not(test))]
19const LOCK_TIMEOUT: Duration = Duration::from_secs(10);
20const LOCK_RETRY_DELAY: Duration = Duration::from_secs(1);
21
22#[cfg(test)]
23use std::sync::atomic::AtomicUsize;
24
25#[cfg(test)]
26const LOCK_FAIL_ERR: usize = 1;
27#[cfg(test)]
28const LOCK_FAIL_BUSY: usize = 2;
29#[cfg(test)]
30static LOCK_FAILPOINT: AtomicUsize = AtomicUsize::new(0);
31
32#[derive(Clone, Default)]
33pub(crate) struct UsageLimits {
34 pub(crate) five_hour: Option<UsageWindow>,
35 pub(crate) weekly: Option<UsageWindow>,
36}
37
38#[derive(Clone, Debug)]
39pub(crate) struct UsageWindow {
40 pub(crate) left_percent: f64,
41 pub(crate) reset_at: i64,
42 pub(crate) reset_at_relative: Option<String>,
43}
44
45#[derive(Debug)]
46pub enum UsageFetchError {
47 Status(u16),
48 Transport(String),
49 Parse(String),
50}
51
52impl UsageFetchError {
53 pub fn status_code(&self) -> Option<u16> {
54 match self {
55 UsageFetchError::Status(code) => Some(*code),
56 _ => None,
57 }
58 }
59
60 pub fn message(&self) -> String {
61 match self {
62 UsageFetchError::Status(code) => {
63 format!("Error: failed to fetch usage: http status: {code}")
64 }
65 UsageFetchError::Transport(err) => {
66 format!("Error: failed to fetch usage: {err}")
67 }
68 UsageFetchError::Parse(err) => format!("Error: failed to parse usage: {err}"),
69 }
70 }
71}
72
73impl std::fmt::Display for UsageFetchError {
74 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75 f.write_str(&self.message())
76 }
77}
78
79#[derive(Debug, Deserialize)]
80struct UsagePayload {
81 #[serde(default)]
82 rate_limit: Option<RateLimitDetails>,
83}
84
85#[derive(Clone, Debug, Deserialize)]
86struct RateLimitDetails {
87 #[serde(default)]
88 primary_window: Option<RateLimitWindowSnapshot>,
89 #[serde(default)]
90 secondary_window: Option<RateLimitWindowSnapshot>,
91}
92
93#[derive(Clone, Debug, Deserialize)]
94struct RateLimitWindowSnapshot {
95 used_percent: f64,
96 limit_window_seconds: i64,
97 reset_at: i64,
98}
99
100pub fn read_base_url(paths: &Paths) -> String {
101 let config_path = paths.codex.join("config.toml");
102 if let Ok(contents) = fs::read_to_string(config_path) {
103 for line in contents.lines() {
104 if let Some(value) = parse_config_value(line, "chatgpt_base_url") {
105 return normalize_base_url(&value);
106 }
107 }
108 }
109 DEFAULT_BASE_URL.to_string()
110}
111
112#[doc(hidden)]
113pub fn parse_config_value(line: &str, key: &str) -> Option<String> {
114 let line = line.trim();
115 if line.is_empty() || line.starts_with('#') {
116 return None;
117 }
118 let (config_key, raw_value) = line.split_once('=')?;
119 if config_key.trim() != key {
120 return None;
121 }
122 let value = strip_inline_comment(raw_value).trim();
123 if value.is_empty() {
124 return None;
125 }
126 let value = value.trim_matches('"').trim_matches('\'').trim();
127 if value.is_empty() {
128 return None;
129 }
130 Some(value.to_string())
131}
132
133fn strip_inline_comment(value: &str) -> &str {
134 let mut in_single = false;
135 let mut in_double = false;
136 let mut escape = false;
137 for (idx, ch) in value.char_indices() {
138 match ch {
139 '"' if !in_single && !escape => in_double = !in_double,
140 '\'' if !in_double => in_single = !in_single,
141 '#' if !in_single && !in_double => return value[..idx].trim_end(),
142 _ => {}
143 }
144 escape = in_double && ch == '\\' && !escape;
145 if ch != '\\' {
146 escape = false;
147 }
148 }
149 value.trim_end()
150}
151
152fn normalize_base_url(value: &str) -> String {
153 let mut base = value.trim_end_matches('/').to_string();
154 if (base.starts_with("https://chatgpt.com") || base.starts_with("https://chat.openai.com"))
155 && !base.contains("/backend-api")
156 {
157 base = format!("{base}/backend-api");
158 }
159 base
160}
161
162fn usage_endpoint(base_url: &str) -> String {
163 if base_url.contains("/backend-api") {
164 format!("{base_url}/wham/usage")
165 } else {
166 format!("{base_url}/api/codex/usage")
167 }
168}
169
170fn fetch_usage_payload(
171 base_url: &str,
172 access_token: &str,
173 account_id: &str,
174) -> Result<UsagePayload, UsageFetchError> {
175 let endpoint = usage_endpoint(base_url);
176 let config = ureq::Agent::config_builder()
177 .timeout_global(Some(Duration::from_secs(5)))
178 .build();
179 let agent: ureq::Agent = config.into();
180 let response = match agent
181 .get(&endpoint)
182 .header("Authorization", &format!("Bearer {access_token}"))
183 .header("ChatGPT-Account-Id", account_id)
184 .header("User-Agent", USER_AGENT)
185 .call()
186 {
187 Ok(response) => response,
188 Err(ureq::Error::StatusCode(code)) => return Err(UsageFetchError::Status(code)),
189 Err(err) => return Err(UsageFetchError::Transport(err.to_string())),
190 };
191 response
192 .into_body()
193 .read_json::<UsagePayload>()
194 .map_err(|err| UsageFetchError::Parse(err.to_string()))
195}
196
197pub fn fetch_usage_details(
198 base_url: &str,
199 access_token: &str,
200 account_id: &str,
201 unavailable_text: &str,
202 now: DateTime<Local>,
203 show_spinner: bool,
204) -> Result<Vec<String>, UsageFetchError> {
205 let spinner = show_spinner.then(|| start_spinner("Fetching profile..."));
206 let payload = fetch_usage_payload(base_url, access_token, account_id);
207 if let Some(spinner) = spinner {
208 stop_spinner(spinner);
209 }
210 let payload = payload?;
211 let limits = build_usage_limits(&payload, now);
212 Ok(format_usage(
213 format_limit(limits.five_hour.as_ref(), now, unavailable_text),
214 format_limit(limits.weekly.as_ref(), now, unavailable_text),
215 unavailable_text,
216 ))
217}
218
219fn build_usage_limits(payload: &UsagePayload, now: DateTime<Local>) -> UsageLimits {
220 let mut limits = UsageLimits::default();
221 let Some(rate_limit) = payload.rate_limit.as_ref() else {
222 return limits;
223 };
224 let mut windows: Vec<(i64, UsageWindow)> = [
225 rate_limit.primary_window.as_ref(),
226 rate_limit.secondary_window.as_ref(),
227 ]
228 .into_iter()
229 .flatten()
230 .map(|window| {
231 (
232 window.limit_window_seconds,
233 usage_window_output(window, now),
234 )
235 })
236 .collect();
237 if windows.is_empty() {
238 return limits;
239 }
240 windows.sort_by_key(|(secs, _)| *secs);
241 if let Some((_, first)) = windows.first() {
242 limits.five_hour = Some(first.clone());
243 }
244 if let Some((_, second)) = windows.get(1) {
245 limits.weekly = Some(second.clone());
246 }
247 limits
248}
249
250fn usage_window_output(window: &RateLimitWindowSnapshot, now: DateTime<Local>) -> UsageWindow {
251 let left_percent = (100.0 - window.used_percent).clamp(0.0, 100.0);
252 let reset_at = window.reset_at;
253 let reset_at_relative = format_reset_relative(reset_at, now);
254 UsageWindow {
255 left_percent,
256 reset_at,
257 reset_at_relative,
258 }
259}
260
261struct SpinnerHandle {
262 stop: Arc<AtomicBool>,
263 handle: Option<JoinHandle<()>>,
264}
265
266fn start_spinner(message: &str) -> SpinnerHandle {
267 if !use_tty_stderr() || is_plain() {
268 return SpinnerHandle {
269 stop: Arc::new(AtomicBool::new(true)),
270 handle: None,
271 };
272 }
273 let stop = Arc::new(AtomicBool::new(false));
274 let stop_thread = Arc::clone(&stop);
275 let message = message.to_string();
276 let handle = thread::spawn(move || {
277 let frames = [". ", ".. ", "...", " ..", " .", " "];
278 let mut idx = 0usize;
279 while !stop_thread.load(Ordering::Relaxed) {
280 let frame = frames[idx % frames.len()];
281 eprint!("\r{message} {frame}");
282 let _ = std::io::stderr().flush();
283 idx += 1;
284 thread::sleep(Duration::from_millis(80));
285 }
286 });
287 SpinnerHandle {
288 stop,
289 handle: Some(handle),
290 }
291}
292
293fn stop_spinner(mut spinner: SpinnerHandle) {
294 spinner.stop.store(true, Ordering::Relaxed);
295 if let Some(handle) = spinner.handle.take() {
296 let _ = handle.join();
297 }
298 eprint!("\r\x1b[2K");
299 let _ = std::io::stderr().flush();
300}
301
302pub(crate) struct UsageLine {
303 pub(crate) bar: String,
304 pub(crate) percent: String,
305 pub(crate) reset: String,
306 pub(crate) left_percent: Option<i64>,
307}
308
309impl UsageLine {
310 fn unavailable(text: &str) -> Self {
311 UsageLine {
312 bar: text.to_string(),
313 percent: String::new(),
314 reset: String::new(),
315 left_percent: None,
316 }
317 }
318}
319
320pub(crate) fn format_limit(
321 window: Option<&UsageWindow>,
322 now: DateTime<Local>,
323 unavailable_text: &str,
324) -> UsageLine {
325 let Some(window) = window else {
326 return UsageLine::unavailable(unavailable_text);
327 };
328 let left_percent = window.left_percent;
329 let left_percent_rounded = left_percent.round() as i64;
330 let bar = render_bar(left_percent);
331 let bar = style_usage_bar(&bar, left_percent);
332 let percent = format!("{left_percent_rounded}%");
333 let reset = window.reset_at_relative.clone().unwrap_or_else(|| {
334 let local = local_from_timestamp(window.reset_at).unwrap_or(now);
335 local.format("%H:%M on %d %b").to_string()
336 });
337 UsageLine {
338 bar,
339 percent,
340 reset,
341 left_percent: Some(left_percent_rounded),
342 }
343}
344
345pub fn usage_unavailable(plan_is_free: bool) -> &'static str {
346 if plan_is_free {
347 "You need a ChatGPT subscription to use Codex CLI"
348 } else {
349 "Data not available"
350 }
351}
352
353pub fn format_usage_unavailable(text: &str, use_color: bool) -> String {
354 if is_plain() {
355 format!("INFO: {text}")
356 } else if use_color {
357 text.red().bold().to_string()
358 } else {
359 text.to_string()
360 }
361}
362
363pub(crate) fn format_usage(
364 five: UsageLine,
365 weekly: UsageLine,
366 unavailable_text: &str,
367) -> Vec<String> {
368 let use_color = use_color_stdout();
369 let available: Vec<UsageLine> = [five, weekly]
370 .into_iter()
371 .filter(|line| line.left_percent.is_some())
372 .collect();
373 if available.is_empty() {
374 return vec![format_usage_unavailable(unavailable_text, use_color)];
375 }
376 let has_zero = available.iter().any(|line| line.left_percent == Some(0));
377 let multiple = available.len() > 1;
378 available
379 .into_iter()
380 .map(|line| {
381 let dim = use_color && multiple && has_zero && line.left_percent != Some(0);
382 format_usage_line(&line, dim, use_color)
383 })
384 .collect()
385}
386
387pub fn format_last_used(ts: u64) -> String {
388 if ts == 0 {
389 return "unknown".to_string();
390 }
391 let timestamp = UNIX_EPOCH + Duration::from_secs(ts);
392 match SystemTime::now().duration_since(timestamp) {
393 Ok(duration) => format_relative_duration(duration, true),
394 Err(err) => format_relative_duration(err.duration(), false),
395 }
396}
397
398pub(crate) fn format_reset_relative(reset_at: i64, now: DateTime<Local>) -> Option<String> {
399 let reset_at = local_from_timestamp(reset_at)?;
400 let duration = reset_at.signed_duration_since(now);
401 if duration.num_seconds() <= 0 {
402 return Some("now".to_string());
403 }
404 let duration = duration.to_std().ok()?;
405 Some(format_duration(duration, DurationStyle::ResetTimer))
406}
407
408fn format_usage_line(line: &UsageLine, dim: bool, use_color: bool) -> String {
409 let reset = reset_label(&line.reset);
410 let reset = reset.to_string();
411 let percent = if line.percent.is_empty() {
412 String::new()
413 } else {
414 format!("{} left", line.percent)
415 };
416 let resets = format_resets_suffix(&reset, use_color);
417 if is_plain() {
418 let mut out = String::new();
419 if !percent.is_empty() {
420 out.push_str(&percent);
421 }
422 if !resets.is_empty() {
423 if !out.is_empty() {
424 out.push(' ');
425 }
426 out.push_str(&resets);
427 }
428 return out;
429 }
430 let resets = if resets.is_empty() {
431 resets
432 } else {
433 format!(" {resets}")
434 };
435 let bar = if dim {
436 strip_ansi(&line.bar)
437 } else {
438 line.bar.clone()
439 };
440 let formatted = if percent.is_empty() {
441 format!("{bar}{resets}")
442 } else {
443 format!("{bar} {percent}{resets}")
444 };
445 if dim && use_color {
446 formatted.dimmed().to_string()
447 } else {
448 formatted
449 }
450}
451
452fn reset_label(reset: &str) -> &str {
453 if reset.is_empty() { "unknown" } else { reset }
454}
455
456fn format_resets_suffix(reset: &str, use_color: bool) -> String {
457 let text = format!("(resets {reset})");
458 style_text(&text, use_color, |text| text.dimmed().italic())
459}
460
461fn render_bar(left_percent: f64) -> String {
462 let total = 20;
463 let filled = ((left_percent / 100.0) * total as f64).round() as usize;
464 let filled = filled.min(total);
465 let empty = total.saturating_sub(filled);
466 format!(
467 "{}{}",
468 "▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮▮"
469 .chars()
470 .take(filled)
471 .collect::<String>(),
472 "▯▯▯▯▯▯▯▯▯▯▯▯▯▯▯▯▯▯▯▯"
473 .chars()
474 .take(empty)
475 .collect::<String>()
476 )
477}
478
479fn style_usage_bar(bar: &str, left_percent: f64) -> String {
480 if !use_color_stdout() {
481 return bar.to_string();
482 }
483 if left_percent >= 66.0 {
484 bar.green().to_string()
485 } else if left_percent >= 33.0 {
486 bar.yellow().to_string()
487 } else {
488 bar.red().to_string()
489 }
490}
491
492fn strip_ansi(input: &str) -> String {
493 let mut out = String::with_capacity(input.len());
494 let mut chars = input.chars().peekable();
495 loop {
496 let Some(ch) = chars.next() else {
497 break;
498 };
499 if ch == '\x1b' && consume_ansi_escape(&mut chars) {
500 continue;
501 }
502 out.push(ch);
503 }
504 out
505}
506
507fn consume_ansi_escape<I>(chars: &mut std::iter::Peekable<I>) -> bool
508where
509 I: Iterator<Item = char>,
510{
511 if chars.peek() != Some(&'[') {
512 return false;
513 }
514 chars.next();
515 for c in chars.by_ref() {
516 if c == 'm' {
517 break;
518 }
519 }
520 true
521}
522
523fn format_relative_duration(duration: Duration, past: bool) -> String {
524 let text = format_duration(duration, DurationStyle::LastUsed);
525 if past {
526 format!("{text} ago")
527 } else {
528 format!("in {text}")
529 }
530}
531
532enum DurationStyle {
533 LastUsed,
534 ResetTimer,
535}
536
537fn format_duration(duration: Duration, style: DurationStyle) -> String {
538 let secs = duration.as_secs();
539 let (value, unit) = if secs < 60 {
540 (secs, "s")
541 } else if secs < 60 * 60 {
542 (secs / 60, "m")
543 } else if secs < 60 * 60 * 24 {
544 (secs / (60 * 60), "h")
545 } else {
546 (secs / (60 * 60 * 24), "d")
547 };
548 match style {
549 DurationStyle::LastUsed => format!("{value}{unit}"),
550 DurationStyle::ResetTimer => format!("in {value}{unit}"),
551 }
552}
553
554fn local_from_timestamp(ts: i64) -> Option<DateTime<Local>> {
555 let dt = chrono::DateTime::<chrono::Utc>::from_timestamp(ts, 0)?;
556 Some(dt.with_timezone(&Local))
557}
558
559#[derive(Debug)]
560pub struct UsageLock {
561 _lock: LockFile,
562}
563
564pub fn lock_usage(paths: &Paths) -> Result<UsageLock, String> {
565 let start = Instant::now();
566 let mut lock = LockFile::open(&paths.profiles_lock)
567 .map_err(|err| format!("Error: failed to open profiles lock: {err}"))?;
568 loop {
569 match try_lock(&mut lock) {
570 Ok(true) => break,
571 Ok(false) => {
572 if start.elapsed() > lock_timeout() {
573 return Err(format!(
574 "Error: could not acquire profiles lock. Ensure no other {} is running and retry.",
575 command_name()
576 ));
577 }
578 thread::sleep(LOCK_RETRY_DELAY);
579 }
580 Err(err) => {
581 return Err(format!("Error: failed to lock profiles file: {err}"));
582 }
583 }
584 }
585 Ok(UsageLock { _lock: lock })
586}
587
588#[cfg(not(test))]
589fn lock_timeout() -> Duration {
590 LOCK_TIMEOUT
591}
592
593#[cfg(not(test))]
594fn try_lock(lock: &mut LockFile) -> Result<bool, fslock::Error> {
595 lock.try_lock()
596}
597
598#[cfg(test)]
599fn lock_timeout() -> Duration {
600 Duration::from_millis(50)
601}
602
603#[cfg(test)]
604fn try_lock(lock: &mut LockFile) -> Result<bool, fslock::Error> {
605 match LOCK_FAILPOINT.load(Ordering::Relaxed) {
606 LOCK_FAIL_ERR => Err(std::io::Error::other("fail")),
607 LOCK_FAIL_BUSY => Ok(false),
608 _ => lock.try_lock(),
609 }
610}
611
612pub fn normalize_usage(entries: &[(String, u64)], ids: &HashSet<String>) -> BTreeMap<String, u64> {
613 let mut map = BTreeMap::new();
614 for id in ids {
615 map.insert(id.clone(), 0);
616 }
617 for (id, ts) in entries {
618 if !ids.contains(id) {
619 continue;
620 }
621 let entry = map.entry(id.clone()).or_insert(0);
622 if *ts > *entry {
623 *entry = *ts;
624 }
625 }
626 map
627}
628
629pub fn ordered_profiles(map: &BTreeMap<String, u64>) -> Vec<(String, u64)> {
630 let mut ordered = map
631 .iter()
632 .map(|(id, ts)| (id.clone(), *ts))
633 .collect::<Vec<_>>();
634 ordered.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
635 ordered
636}
637
638pub fn now_seconds() -> u64 {
639 SystemTime::now()
640 .duration_since(UNIX_EPOCH)
641 .map(|duration| duration.as_secs())
642 .unwrap_or(0)
643}
644
645#[cfg(test)]
646mod tests {
647 use super::*;
648 use crate::test_utils::{
649 http_ok_response, make_paths, set_env_guard, set_plain_guard, spawn_server,
650 };
651 use std::fs;
652 use std::sync::Mutex;
653
654 static LOCK_TEST_MUTEX: Mutex<()> = Mutex::new(());
655
656 #[test]
657 fn config_parsing_paths() {
658 assert!(parse_config_value("", "key").is_none());
659 assert!(parse_config_value("# comment", "key").is_none());
660 assert!(parse_config_value("other = 1", "key").is_none());
661 assert!(parse_config_value("key =", "key").is_none());
662 assert_eq!(
663 parse_config_value("key = 'value'", "key"),
664 Some("value".to_string())
665 );
666 assert_eq!(strip_inline_comment("value # comment"), "value");
667 }
668
669 #[test]
670 fn normalize_base_url_and_endpoint() {
671 let url = normalize_base_url("https://chatgpt.com");
672 assert!(url.ends_with("/backend-api"));
673 assert!(usage_endpoint(&url).contains("wham/usage"));
674 assert!(usage_endpoint("http://example.com").contains("api/codex/usage"));
675 }
676
677 #[test]
678 fn fetch_usage_payload_paths() {
679 let payload = r#"{"rate_limit":{"primary_window":{"used_percent":50.0,"limit_window_seconds":3600,"reset_at":1}}}"#;
680 let resp = http_ok_response(payload, "application/json");
681 let url = spawn_server(resp);
682 let base_url = format!("{url}/backend-api");
683 fetch_usage_payload(&base_url, "token", "acct").unwrap();
684
685 let err_resp =
686 "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\n\r\n".to_string();
687 let err_url = spawn_server(err_resp);
688 let base_url = format!("{err_url}/backend-api");
689 let err = fetch_usage_payload(&base_url, "token", "acct").unwrap_err();
690 assert!(matches!(err, UsageFetchError::Status(_)));
691
692 let bad_resp =
693 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 1\r\n\r\n{"
694 .to_string();
695 let bad_url = spawn_server(bad_resp);
696 let base_url = format!("{bad_url}/backend-api");
697 let err = fetch_usage_payload(&base_url, "token", "acct").unwrap_err();
698 assert!(matches!(err, UsageFetchError::Parse(_)));
699 }
700
701 #[test]
702 fn fetch_usage_details_with_spinner() {
703 let payload = r#"{"rate_limit":{"primary_window":{"used_percent":10.0,"limit_window_seconds":3600,"reset_at":1}}}"#;
704 let resp = http_ok_response(payload, "application/json");
705 let url = spawn_server(resp);
706 let base_url = format!("{url}/backend-api");
707 let lines = fetch_usage_details(
708 &base_url,
709 "token",
710 "acct",
711 "unavailable",
712 Local::now(),
713 true,
714 )
715 .unwrap();
716 assert!(!lines.is_empty());
717 }
718
719 #[test]
720 fn usage_limits_and_formatting() {
721 let payload = UsagePayload { rate_limit: None };
722 let limits = build_usage_limits(&payload, Local::now());
723 assert!(limits.five_hour.is_none());
724
725 let window = RateLimitWindowSnapshot {
726 used_percent: 50.0,
727 limit_window_seconds: 10,
728 reset_at: Local::now().timestamp(),
729 };
730 let rate_limit = RateLimitDetails {
731 primary_window: Some(window.clone()),
732 secondary_window: Some(window.clone()),
733 };
734 let payload = UsagePayload {
735 rate_limit: Some(rate_limit),
736 };
737 let limits = build_usage_limits(&payload, Local::now());
738 assert!(limits.five_hour.is_some());
739 let line = format_limit(limits.five_hour.as_ref(), Local::now(), "none");
740 assert!(line.left_percent.is_some());
741 }
742
743 #[test]
744 fn usage_unavailable_paths() {
745 let _plain = set_plain_guard(true);
746 assert_eq!(
747 usage_unavailable(true),
748 "You need a ChatGPT subscription to use Codex CLI"
749 );
750 let text = format_usage_unavailable("text", false);
751 assert!(text.contains("INFO"));
752 }
753
754 #[test]
755 fn format_usage_variants() {
756 let unavailable = "unavailable";
757 let lines = format_usage(
758 UsageLine::unavailable(unavailable),
759 UsageLine::unavailable(unavailable),
760 unavailable,
761 );
762 assert_eq!(lines.len(), 1);
763 }
764
765 #[test]
766 fn last_used_formatting() {
767 assert_eq!(format_last_used(0), "unknown");
768 let future = SystemTime::now()
769 .duration_since(UNIX_EPOCH)
770 .unwrap()
771 .as_secs()
772 + 60;
773 assert!(format_last_used(future).contains("in"));
774 }
775
776 #[test]
777 fn format_usage_line_plain_and_dim() {
778 let line = UsageLine {
779 bar: render_bar(50.0),
780 percent: "50%".to_string(),
781 reset: "soon".to_string(),
782 left_percent: Some(50),
783 };
784 let _plain = set_plain_guard(true);
785 let plain = format_usage_line(&line, false, false);
786 assert!(plain.contains("left"));
787 }
788
789 #[test]
790 fn style_bar_and_strip_ansi() {
791 let _env = set_env_guard("NO_COLOR", Some("1"));
792 let bar = render_bar(10.0);
793 let styled = style_usage_bar(&bar, 10.0);
794 assert_eq!(bar, styled);
795 let stripped = strip_ansi("\x1b[31mred\x1b[0m");
796 assert_eq!(stripped, "red");
797 }
798
799 #[test]
800 fn format_duration_helpers() {
801 let text = format_relative_duration(Duration::from_secs(30), true);
802 assert!(text.contains("ago"));
803 assert_eq!(
804 format_duration(Duration::from_secs(60), DurationStyle::LastUsed),
805 "1m"
806 );
807 assert!(local_from_timestamp(0).is_some());
808 assert!(local_from_timestamp(-1).is_some());
809 }
810
811 #[test]
812 fn lock_usage_failure_paths() {
813 let _guard = LOCK_TEST_MUTEX.lock().unwrap();
814 let dir = tempfile::tempdir().expect("tempdir");
815 let paths = make_paths(dir.path());
816 fs::create_dir_all(&paths.profiles).unwrap();
817 fs::write(&paths.profiles_lock, "").unwrap();
818
819 LOCK_FAILPOINT.store(LOCK_FAIL_BUSY, Ordering::Relaxed);
820 let err = lock_usage(&paths).unwrap_err();
821 assert!(err.contains("could not acquire profiles lock"));
822 LOCK_FAILPOINT.store(LOCK_FAIL_ERR, Ordering::Relaxed);
823 let err = lock_usage(&paths).unwrap_err();
824 assert!(err.contains("failed to lock profiles file"));
825 LOCK_FAILPOINT.store(0, Ordering::Relaxed);
826 }
827
828 #[test]
829 fn lock_usage_open_error() {
830 let _guard = LOCK_TEST_MUTEX.lock().unwrap();
831 let dir = tempfile::tempdir().expect("tempdir");
832 let lock_dir = dir.path().join("locked");
833 fs::create_dir_all(&lock_dir).unwrap();
834 #[cfg(unix)]
835 {
836 use std::os::unix::fs::PermissionsExt;
837 fs::set_permissions(&lock_dir, fs::Permissions::from_mode(0o400)).unwrap();
838 }
839 let mut paths = make_paths(dir.path());
840 paths.profiles_lock = lock_dir.join("profiles.lock");
841 let err = lock_usage(&paths).unwrap_err();
842 assert!(err.contains("failed to open profiles lock"));
843 }
844}