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}