Skip to main content

codex_profiles/
usage.rs

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}