Skip to main content

claude_cli/prompt_segment/
usage.rs

1use nils_common::cli_contract::exit;
2use nils_common::diag_output;
3use nils_common::env as shared_env;
4use nils_common::process;
5use nils_common::shell::{AnsiStripMode, quote_posix_single, strip_ansi};
6use nils_common::usage_time::reset_epoch_seconds_from_str;
7use serde::Serialize;
8use serde_json::{Map, Value, json};
9use std::ffi::OsString;
10use std::io::Write;
11use std::path::{Path, PathBuf};
12use std::process::{Command, Stdio};
13use std::thread;
14use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
15
16use super::{auth, cache, client, render};
17
18const USAGE_SCHEMA_VERSION: &str = "claude-cli.usage.v1";
19const USAGE_COMMAND: &str = "usage";
20const DEFAULT_CLAUDE_BIN: &str = "claude";
21const DEFAULT_CLAUDE_TIMEOUT_SECONDS: u64 = 15;
22const DEFAULT_PTY_STARTUP_DELAY_MS: u64 = 4_000;
23const DEFAULT_PTY_USAGE_DELAY_MS: u64 = 3_000;
24const CLI_USAGE_STDIN: &[u8] = b"/usage\n/exit\n";
25const CLI_USAGE_PTY_USAGE_STDIN: &[u8] = b"/usage\r";
26const CLI_USAGE_PTY_EXIT_STDIN: &[u8] = b"/exit\r";
27
28#[derive(Clone, Debug)]
29pub struct UsageOptions {
30    pub source: UsageSource,
31    pub output_json: bool,
32}
33
34#[derive(Copy, Clone, Debug, PartialEq, Eq)]
35pub enum UsageSource {
36    Auto,
37    Oauth,
38    Cli,
39    Cache,
40}
41
42#[derive(Debug, Clone, Serialize)]
43struct UsageResult {
44    provider: String,
45    source: String,
46    stale: bool,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    cache_file: Option<String>,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    updated_at: Option<i64>,
51    windows: Vec<UsageWindow>,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    plan: Option<String>,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    note: Option<String>,
56}
57
58#[derive(Debug, Clone, Serialize)]
59struct UsageWindow {
60    key: String,
61    label: String,
62    window_minutes: i64,
63    used_percent: f64,
64    remaining_percent: f64,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    resets_at: Option<String>,
67    #[serde(skip_serializing_if = "Option::is_none")]
68    resets_at_epoch: Option<i64>,
69}
70
71#[derive(Copy, Clone, Debug, PartialEq, Eq)]
72enum WindowKind {
73    FiveHour,
74    SevenDay,
75}
76
77#[derive(Default)]
78struct ParsedWindow {
79    used_percent: Option<f64>,
80    resets_at: Option<String>,
81}
82
83pub fn run(options: &UsageOptions) -> i32 {
84    let result = resolve_usage(options.source);
85
86    if options.output_json {
87        if diag_output::emit_success_result(USAGE_SCHEMA_VERSION, USAGE_COMMAND, &result).is_err() {
88            return exit::RUNTIME;
89        }
90    } else {
91        println!("{}", render_text_result(&result));
92    }
93
94    exit::SUCCESS
95}
96
97fn resolve_usage(source: UsageSource) -> UsageResult {
98    let cache_file = cache::cache_file();
99    match source {
100        UsageSource::Auto => {
101            if let Some(result) = try_oauth(cache_file.as_ref()) {
102                return result;
103            }
104            if let Some(result) = try_claude_cli(cache_file.as_ref()) {
105                return result;
106            }
107            read_cache(cache_file.as_ref())
108                .unwrap_or_else(|| empty_result(cache_file, "usage unavailable"))
109        }
110        UsageSource::Oauth => try_oauth(cache_file.as_ref())
111            .unwrap_or_else(|| empty_result(cache_file, "oauth usage unavailable")),
112        UsageSource::Cli => try_claude_cli(cache_file.as_ref())
113            .unwrap_or_else(|| empty_result(cache_file, "claude cli usage unavailable")),
114        UsageSource::Cache => read_cache(cache_file.as_ref())
115            .unwrap_or_else(|| empty_result(cache_file, "cache missing")),
116    }
117}
118
119fn try_oauth(cache_file: Option<&PathBuf>) -> Option<UsageResult> {
120    let token = auth::resolve_access_token()?;
121    let body = client::fetch_usage(&token.value).ok()?;
122    let value: Value = serde_json::from_str(&body).ok()?;
123    let usage = render::parse_usage_value(&value)?;
124    if let Some(cache_file) = cache_file {
125        let _ = cache::write_cache_file(cache_file, &body);
126    }
127    Some(result_from_usage(
128        "oauth",
129        false,
130        cache_file,
131        Some(now_epoch_seconds()),
132        &usage,
133        None,
134    ))
135}
136
137fn try_claude_cli(cache_file: Option<&PathBuf>) -> Option<UsageResult> {
138    let body = probe_claude_cli_usage().ok()?;
139    let usage = parse_cli_usage_output(&body)?;
140    let cache_body = usage_cache_body(&usage).ok()?;
141    if let Some(cache_file) = cache_file {
142        let _ = cache::write_cache_file(cache_file, &cache_body);
143    }
144    Some(result_from_usage(
145        "cli",
146        false,
147        cache_file,
148        Some(now_epoch_seconds()),
149        &usage,
150        None,
151    ))
152}
153
154fn read_cache(cache_file: Option<&PathBuf>) -> Option<UsageResult> {
155    let cache_file = cache_file?;
156    let raw = cache::read_cache_file(cache_file)?;
157    let value: Value = serde_json::from_str(&raw).ok()?;
158    let usage = render::parse_usage_value(&value)?;
159    Some(result_from_usage(
160        "cache",
161        true,
162        Some(cache_file),
163        modified_epoch_seconds(cache_file),
164        &usage,
165        Some("serving last cached usage".to_string()),
166    ))
167}
168
169fn empty_result(cache_file: Option<PathBuf>, note: &str) -> UsageResult {
170    UsageResult {
171        provider: "claude".to_string(),
172        source: "none".to_string(),
173        stale: true,
174        cache_file: cache_file.as_ref().map(|path| display_path(path)),
175        updated_at: None,
176        windows: Vec::new(),
177        plan: None,
178        note: Some(note.to_string()),
179    }
180}
181
182fn result_from_usage(
183    source: &str,
184    stale: bool,
185    cache_file: Option<&PathBuf>,
186    updated_at: Option<i64>,
187    usage: &render::Usage,
188    note: Option<String>,
189) -> UsageResult {
190    let mut windows = Vec::new();
191    if let Some(window) = &usage.five_hour {
192        windows.push(usage_window("5h", "5h", 300, window, updated_at));
193    }
194    if let Some(window) = &usage.seven_day {
195        windows.push(usage_window("weekly", "Weekly", 10_080, window, updated_at));
196    }
197
198    UsageResult {
199        provider: "claude".to_string(),
200        source: source.to_string(),
201        stale,
202        cache_file: cache_file.map(|path| display_path(path)),
203        updated_at,
204        windows,
205        plan: None,
206        note,
207    }
208}
209
210fn usage_window(
211    key: &str,
212    label: &str,
213    window_minutes: i64,
214    window: &render::Window,
215    reference_epoch: Option<i64>,
216) -> UsageWindow {
217    let resets_at = window.resets_at.clone();
218    let resets_at_epoch = resets_at
219        .as_deref()
220        .and_then(|raw| reset_epoch_seconds(raw, reference_epoch));
221    UsageWindow {
222        key: key.to_string(),
223        label: label.to_string(),
224        window_minutes,
225        used_percent: round_percent(window.used_percent),
226        remaining_percent: round_percent(f64::from(window.remaining_percent as i32)),
227        resets_at,
228        resets_at_epoch,
229    }
230}
231
232fn render_text_result(result: &UsageResult) -> String {
233    if result.windows.is_empty() {
234        return result
235            .note
236            .clone()
237            .unwrap_or_else(|| "usage unavailable".to_string());
238    }
239
240    let mut parts = vec![format!("source={}", result.source)];
241    for window in &result.windows {
242        parts.push(format!(
243            "{}:{}%",
244            window.label,
245            round_percent(window.remaining_percent)
246        ));
247    }
248    if result.stale {
249        parts.push("(stale)".to_string());
250    }
251    parts.join(" ")
252}
253
254fn probe_claude_cli_usage() -> anyhow::Result<String> {
255    let claude_bin = shared_env::env_non_empty("CLAUDE_PROMPT_SEGMENT_CLAUDE_BIN")
256        .unwrap_or_else(|| DEFAULT_CLAUDE_BIN.to_string());
257    let program = process::find_in_path(&claude_bin).unwrap_or_else(|| PathBuf::from(&claude_bin));
258    let timeout_seconds = shared_env::env_non_empty("CLAUDE_PROMPT_SEGMENT_CLAUDE_TIMEOUT_SECONDS")
259        .and_then(|raw| raw.parse::<u64>().ok())
260        .filter(|value| *value > 0)
261        .unwrap_or(DEFAULT_CLAUDE_TIMEOUT_SECONDS);
262
263    let mut last_error: Option<anyhow::Error> = None;
264    for mode in select_probe_modes(&program) {
265        match probe_claude_cli_usage_with_mode(&program, timeout_seconds, &mode) {
266            Ok(text)
267                if parse_cli_usage_output(&text).is_some() || matches!(mode, ProbeMode::Pipe) =>
268            {
269                return Ok(text);
270            }
271            Ok(_) => {
272                last_error = Some(anyhow::anyhow!(
273                    "claude usage probe output was not parseable"
274                ));
275            }
276            Err(error) => {
277                last_error = Some(error);
278            }
279        }
280    }
281
282    Err(last_error.unwrap_or_else(|| anyhow::anyhow!("claude usage probe unavailable")))
283}
284
285fn probe_claude_cli_usage_with_mode(
286    program: &Path,
287    timeout_seconds: u64,
288    mode: &ProbeMode,
289) -> anyhow::Result<String> {
290    let mut command = mode.command(program);
291    let mut child = command
292        .stdin(Stdio::piped())
293        .stdout(Stdio::piped())
294        .stderr(Stdio::piped())
295        .spawn()?;
296    if let Some(mut stdin) = child.stdin.take() {
297        mode.write_usage_input(&mut stdin)?;
298    }
299
300    let deadline = Instant::now() + Duration::from_secs(timeout_seconds);
301    loop {
302        if child.try_wait()?.is_some() {
303            let output = child.wait_with_output()?;
304            let text = output_text(&output.stdout, &output.stderr);
305            if output.status.success() || !text.trim().is_empty() {
306                return Ok(text);
307            }
308            anyhow::bail!("claude exited nonzero");
309        }
310
311        if Instant::now() >= deadline {
312            let _ = child.kill();
313            let output = child.wait_with_output()?;
314            let text = output_text(&output.stdout, &output.stderr);
315            if !text.trim().is_empty() {
316                return Ok(text);
317            }
318            anyhow::bail!("claude usage probe timed out");
319        }
320
321        thread::sleep(Duration::from_millis(25));
322    }
323}
324
325#[derive(Clone, Debug, PartialEq, Eq)]
326enum ProbeMode {
327    Pty(ScriptLauncher),
328    Pipe,
329}
330
331#[derive(Clone, Debug, PartialEq, Eq)]
332struct ScriptLauncher {
333    program: PathBuf,
334    flavor: ScriptFlavor,
335}
336
337#[derive(Copy, Clone, Debug, PartialEq, Eq)]
338enum ScriptFlavor {
339    UtilLinux,
340    Bsd,
341}
342
343impl ProbeMode {
344    fn command(&self, program: &Path) -> Command {
345        match self {
346            Self::Pty(launcher) => script_command(program, launcher),
347            Self::Pipe => Command::new(program),
348        }
349    }
350
351    fn write_usage_input(&self, stdin: &mut dyn Write) -> anyhow::Result<()> {
352        match self {
353            Self::Pty(_) => {
354                thread::sleep(Duration::from_millis(env_u64(
355                    "CLAUDE_PROMPT_SEGMENT_CLAUDE_PTY_STARTUP_DELAY_MS",
356                    DEFAULT_PTY_STARTUP_DELAY_MS,
357                )));
358                stdin.write_all(CLI_USAGE_PTY_USAGE_STDIN)?;
359                stdin.flush()?;
360                thread::sleep(Duration::from_millis(env_u64(
361                    "CLAUDE_PROMPT_SEGMENT_CLAUDE_PTY_USAGE_DELAY_MS",
362                    DEFAULT_PTY_USAGE_DELAY_MS,
363                )));
364                stdin.write_all(CLI_USAGE_PTY_EXIT_STDIN)?;
365                stdin.flush()?;
366            }
367            Self::Pipe => {
368                stdin.write_all(CLI_USAGE_STDIN)?;
369            }
370        }
371        Ok(())
372    }
373}
374
375fn select_probe_modes(program: &Path) -> Vec<ProbeMode> {
376    let script_program = if cfg!(unix) {
377        process::find_in_path("script")
378    } else {
379        None
380    };
381    select_probe_modes_for(
382        shared_env::env_truthy("CLAUDE_PROMPT_SEGMENT_CLAUDE_PTY_DISABLED"),
383        program.is_file(),
384        script_program,
385    )
386}
387
388fn select_probe_modes_for(
389    pty_disabled: bool,
390    program_is_file: bool,
391    script_program: Option<PathBuf>,
392) -> Vec<ProbeMode> {
393    let mut modes = Vec::new();
394    if !pty_disabled
395        && program_is_file
396        && let Some(program) = script_program
397    {
398        modes.push(ProbeMode::Pty(ScriptLauncher {
399            program,
400            flavor: platform_script_flavor(),
401        }));
402    }
403    modes.push(ProbeMode::Pipe);
404    modes
405}
406
407fn script_command(program: &Path, launcher: &ScriptLauncher) -> Command {
408    let mut command = Command::new(&launcher.program);
409    command.args(script_command_args(program, launcher.flavor));
410    command
411}
412
413fn script_command_args(program: &Path, flavor: ScriptFlavor) -> Vec<OsString> {
414    match flavor {
415        ScriptFlavor::UtilLinux => vec![
416            OsString::from("-q"),
417            OsString::from("/dev/null"),
418            OsString::from("-c"),
419            OsString::from(quote_posix_single(&program.to_string_lossy())),
420        ],
421        ScriptFlavor::Bsd => vec![
422            OsString::from("-q"),
423            OsString::from("/dev/null"),
424            program.as_os_str().to_os_string(),
425        ],
426    }
427}
428
429fn platform_script_flavor() -> ScriptFlavor {
430    if cfg!(any(
431        target_os = "macos",
432        target_os = "freebsd",
433        target_os = "netbsd",
434        target_os = "openbsd",
435        target_os = "dragonfly"
436    )) {
437        ScriptFlavor::Bsd
438    } else {
439        ScriptFlavor::UtilLinux
440    }
441}
442
443fn output_text(stdout: &[u8], stderr: &[u8]) -> String {
444    let mut text = String::from_utf8_lossy(stdout).to_string();
445    if !stderr.is_empty() {
446        text.push('\n');
447        text.push_str(&String::from_utf8_lossy(stderr));
448    }
449    text
450}
451
452fn parse_cli_usage_output(raw: &str) -> Option<render::Usage> {
453    let stripped = strip_ansi(raw, AnsiStripMode::CsiAnyTerminator);
454    let normalized = stripped.replace('\r', "\n");
455    let mut current = None;
456    let mut five_hour = ParsedWindow::default();
457    let mut seven_day = ParsedWindow::default();
458
459    for line in normalized.lines() {
460        let trimmed = line.trim();
461        if trimmed.is_empty() {
462            continue;
463        }
464        let lower = trimmed.to_ascii_lowercase();
465        if lower.contains("fable") {
466            current = None;
467            continue;
468        }
469        if let Some(kind) = classify_window_line(&lower) {
470            current = Some(kind);
471        }
472
473        let kind = classify_window_line(&lower).or(current);
474        if let Some(kind) = kind {
475            if let Some(used_percent) = parse_used_percent(trimmed) {
476                target_window(kind, &mut five_hour, &mut seven_day).used_percent =
477                    Some(used_percent);
478            }
479            if lower.contains("reset")
480                && let Some(resets_at) = parse_reset_value(trimmed)
481            {
482                target_window(kind, &mut five_hour, &mut seven_day).resets_at = Some(resets_at);
483            }
484        }
485    }
486
487    let five_hour = build_window(five_hour);
488    let seven_day = build_window(seven_day);
489    if five_hour.is_none() && seven_day.is_none() {
490        return None;
491    }
492    Some(render::Usage {
493        five_hour,
494        seven_day,
495    })
496}
497
498fn classify_window_line(lower: &str) -> Option<WindowKind> {
499    if lower.contains("week") || lower.contains("7-day") || lower.contains("7 day") {
500        return Some(WindowKind::SevenDay);
501    }
502    if lower.contains("5-hour")
503        || lower.contains("5 hour")
504        || lower.contains("5h")
505        || lower.contains("session")
506    {
507        return Some(WindowKind::FiveHour);
508    }
509    None
510}
511
512fn target_window<'a>(
513    kind: WindowKind,
514    five_hour: &'a mut ParsedWindow,
515    seven_day: &'a mut ParsedWindow,
516) -> &'a mut ParsedWindow {
517    match kind {
518        WindowKind::FiveHour => five_hour,
519        WindowKind::SevenDay => seven_day,
520    }
521}
522
523fn build_window(parsed: ParsedWindow) -> Option<render::Window> {
524    let used_percent = parsed.used_percent?;
525    Some(render::Window {
526        used_percent,
527        remaining_percent: remaining_percent(used_percent),
528        resets_at: parsed.resets_at,
529    })
530}
531
532fn parse_used_percent(line: &str) -> Option<f64> {
533    let lower = line.to_ascii_lowercase();
534    let percentages = percent_values(&lower);
535    if percentages.is_empty() {
536        return None;
537    }
538
539    for word in ["used", "usage", "utilized"] {
540        if let Some(value) = percent_before_word(&lower, &percentages, word) {
541            return Some(value.clamp(0.0, 100.0));
542        }
543    }
544    for word in ["remaining", "left", "available"] {
545        if let Some(value) = percent_before_word(&lower, &percentages, word) {
546            return Some((100.0 - value).clamp(0.0, 100.0));
547        }
548    }
549
550    let first = percentages.first()?.1;
551    if lower.contains("remaining") || lower.contains("left") {
552        Some((100.0 - first).clamp(0.0, 100.0))
553    } else {
554        Some(first.clamp(0.0, 100.0))
555    }
556}
557
558fn percent_values(line: &str) -> Vec<(usize, f64)> {
559    let mut values = Vec::new();
560    for (percent_index, ch) in line.char_indices() {
561        if ch != '%' {
562            continue;
563        }
564        let prefix = &line[..percent_index];
565        let start = prefix
566            .char_indices()
567            .rev()
568            .find(|(_, ch)| !ch.is_ascii_digit() && *ch != '.')
569            .map(|(idx, ch)| idx + ch.len_utf8())
570            .unwrap_or(0);
571        if start >= percent_index {
572            continue;
573        }
574        if let Ok(value) = line[start..percent_index].trim().parse::<f64>() {
575            values.push((percent_index + 1, value));
576        }
577    }
578    values
579}
580
581fn percent_before_word(line: &str, percentages: &[(usize, f64)], word: &str) -> Option<f64> {
582    let word_index = line.find(word)?;
583    percentages
584        .iter()
585        .take_while(|(end_index, _)| *end_index <= word_index)
586        .last()
587        .map(|(_, value)| *value)
588}
589
590fn parse_reset_value(line: &str) -> Option<String> {
591    let lower = line.to_ascii_lowercase();
592    for marker in ["resets at", "reset at", "resets:", "reset:"] {
593        if let Some(index) = lower.find(marker) {
594            let raw = line[index + marker.len()..]
595                .trim()
596                .trim_start_matches(':')
597                .trim();
598            if !raw.is_empty() {
599                return Some(raw.to_string());
600            }
601        }
602    }
603    for marker in ["resets", "reset"] {
604        if lower.starts_with(marker) {
605            let raw = line[marker.len()..].trim();
606            if !raw.is_empty() {
607                return Some(raw.to_string());
608            }
609        }
610    }
611    line.split_once(':')
612        .map(|(_, raw)| raw.trim())
613        .filter(|raw| !raw.is_empty())
614        .map(ToOwned::to_owned)
615}
616
617fn env_u64(key: &str, default: u64) -> u64 {
618    shared_env::env_non_empty(key)
619        .and_then(|raw| raw.parse::<u64>().ok())
620        .unwrap_or(default)
621}
622
623fn usage_cache_body(usage: &render::Usage) -> serde_json::Result<String> {
624    let mut usage_map = Map::new();
625    if let Some(window) = &usage.five_hour {
626        usage_map.insert("five_hour".to_string(), cache_window(window));
627    }
628    if let Some(window) = &usage.seven_day {
629        usage_map.insert("seven_day".to_string(), cache_window(window));
630    }
631    serde_json::to_string(&json!({ "usage": Value::Object(usage_map) }))
632}
633
634fn cache_window(window: &render::Window) -> Value {
635    let mut map = Map::new();
636    map.insert(
637        "utilization".to_string(),
638        json!(round_percent(window.used_percent)),
639    );
640    if let Some(resets_at) = &window.resets_at {
641        map.insert("resets_at".to_string(), json!(resets_at));
642    }
643    Value::Object(map)
644}
645
646fn remaining_percent(used_percent: f64) -> i64 {
647    (100.0 - used_percent.round()).clamp(0.0, 100.0) as i64
648}
649
650fn round_percent(value: f64) -> f64 {
651    (value * 10.0).round() / 10.0
652}
653
654fn reset_epoch_seconds(raw: &str, reference_epoch: Option<i64>) -> Option<i64> {
655    reset_epoch_seconds_from_str(raw, reference_epoch)
656}
657
658fn now_epoch_seconds() -> i64 {
659    epoch_seconds(SystemTime::now())
660}
661
662fn modified_epoch_seconds(path: &Path) -> Option<i64> {
663    let modified = std::fs::metadata(path).ok()?.modified().ok()?;
664    Some(epoch_seconds(modified))
665}
666
667fn epoch_seconds(time: SystemTime) -> i64 {
668    time.duration_since(UNIX_EPOCH)
669        .ok()
670        .and_then(|duration| i64::try_from(duration.as_secs()).ok())
671        .unwrap_or(0)
672}
673
674fn display_path(path: &Path) -> String {
675    path.to_string_lossy().to_string()
676}
677
678#[cfg(test)]
679mod tests {
680    use super::*;
681
682    #[test]
683    fn cli_usage_parser_reads_used_and_remaining_lines() {
684        let usage = parse_cli_usage_output(
685            "\x1b[32mCurrent session\x1b[0m\n\
686             5-hour limit: 25% used, 75% remaining\n\
687             Resets at 2026-01-01T00:00:00+00:00\n\
688             Current week\n\
689             Weekly limit: 50% left\n",
690        )
691        .expect("usage");
692
693        let five = usage.five_hour.expect("five hour");
694        assert_eq!(five.used_percent, 25.0);
695        assert_eq!(five.remaining_percent, 75);
696        assert_eq!(five.resets_at.as_deref(), Some("2026-01-01T00:00:00+00:00"));
697
698        let weekly = usage.seven_day.expect("weekly");
699        assert_eq!(weekly.used_percent, 50.0);
700        assert_eq!(weekly.remaining_percent, 50);
701    }
702
703    #[test]
704    fn usage_cache_body_uses_prompt_segment_cache_shape() {
705        let body = usage_cache_body(&render::Usage {
706            five_hour: Some(render::Window {
707                used_percent: 25.0,
708                remaining_percent: 75,
709                resets_at: None,
710            }),
711            seven_day: None,
712        })
713        .expect("cache body");
714
715        let value: Value = serde_json::from_str(&body).expect("json");
716        assert_eq!(value["usage"]["five_hour"]["utilization"], 25.0);
717    }
718
719    #[test]
720    fn script_command_args_use_util_linux_c_shape() {
721        let args = script_command_args(Path::new("/opt/bin/claude"), ScriptFlavor::UtilLinux);
722
723        assert_eq!(
724            args,
725            vec!["-q", "/dev/null", "-c", "'/opt/bin/claude'"]
726                .into_iter()
727                .map(std::ffi::OsString::from)
728                .collect::<Vec<_>>()
729        );
730    }
731
732    #[test]
733    fn script_command_args_use_bsd_file_command_shape() {
734        let args = script_command_args(Path::new("/opt/bin/claude"), ScriptFlavor::Bsd);
735
736        assert_eq!(
737            args,
738            vec!["-q", "/dev/null", "/opt/bin/claude"]
739                .into_iter()
740                .map(std::ffi::OsString::from)
741                .collect::<Vec<_>>()
742        );
743    }
744
745    #[test]
746    fn pty_command_uses_resolved_script_path_and_flavor() {
747        let mode = ProbeMode::Pty(ScriptLauncher {
748            program: PathBuf::from("/custom/bin/script"),
749            flavor: ScriptFlavor::Bsd,
750        });
751        let command = mode.command(Path::new("/opt/bin/claude"));
752
753        assert_eq!(command.get_program(), Path::new("/custom/bin/script"));
754        assert_eq!(
755            command.get_args().collect::<Vec<_>>(),
756            vec![
757                std::ffi::OsStr::new("-q"),
758                std::ffi::OsStr::new("/dev/null"),
759                std::ffi::OsStr::new("/opt/bin/claude"),
760            ]
761        );
762    }
763
764    #[test]
765    fn selected_probe_modes_keep_pipe_fallback_after_pty() {
766        let modes = select_probe_modes_for(false, true, Some(PathBuf::from("/usr/bin/script")));
767
768        assert_eq!(
769            modes,
770            vec![
771                ProbeMode::Pty(ScriptLauncher {
772                    program: PathBuf::from("/usr/bin/script"),
773                    flavor: platform_script_flavor(),
774                }),
775                ProbeMode::Pipe,
776            ]
777        );
778    }
779
780    #[cfg(any(
781        target_os = "macos",
782        target_os = "freebsd",
783        target_os = "netbsd",
784        target_os = "openbsd",
785        target_os = "dragonfly"
786    ))]
787    #[test]
788    fn platform_script_flavor_is_bsd_on_bsd_targets() {
789        assert_eq!(platform_script_flavor(), ScriptFlavor::Bsd);
790    }
791
792    #[cfg(not(any(
793        target_os = "macos",
794        target_os = "freebsd",
795        target_os = "netbsd",
796        target_os = "openbsd",
797        target_os = "dragonfly"
798    )))]
799    #[test]
800    fn platform_script_flavor_is_util_linux_on_other_targets() {
801        assert_eq!(platform_script_flavor(), ScriptFlavor::UtilLinux);
802    }
803
804    #[test]
805    fn cli_usage_parser_ignores_fable_weekly_subwindow() {
806        let usage = parse_cli_usage_output(
807            "Current session\n\
808             98%used\n\
809             Resets6:20am(Asia/Taipei)\n\
810             Current week (all models)\n\
811             67% used\n\
812             Resets Jul 12, 9pm (Asia/Taipei)\n\
813             Current week (Fable)\n\
814             12% used\n\
815             Resets Jul 12, 8:59pm (Asia/Taipei)\n",
816        )
817        .expect("usage");
818
819        let five = usage.five_hour.expect("five hour");
820        assert_eq!(five.used_percent, 98.0);
821        assert_eq!(five.resets_at.as_deref(), Some("6:20am(Asia/Taipei)"));
822
823        let weekly = usage.seven_day.expect("weekly");
824        assert_eq!(weekly.used_percent, 67.0);
825        assert_eq!(
826            weekly.resets_at.as_deref(),
827            Some("Jul 12, 9pm (Asia/Taipei)")
828        );
829    }
830}