Skip to main content

cmd_usage/
lib.rs

1mod api;
2mod cli;
3mod config;
4mod mcp;
5mod paths;
6mod render;
7mod report_render;
8mod reports;
9mod snapshot;
10mod update_check;
11
12#[cfg(test)]
13mod cli_tests;
14
15#[cfg(test)]
16mod config_tests;
17
18#[cfg(test)]
19mod mcp_tests;
20
21#[cfg(test)]
22mod render_tests;
23
24#[cfg(test)]
25mod main_tests;
26
27use std::io::{IsTerminal, Write};
28
29use crate::render::{BOLD, DIM, RESET};
30
31pub fn run() {
32    let args = cli::parse_args();
33    if args.help {
34        cli::usage();
35        return;
36    }
37
38    if let Some(cs) = args.config_set {
39        if let Err(e) = config::set(&cs) {
40            eprintln!("error: {e}");
41            std::process::exit(1);
42        }
43        return;
44    }
45
46    if args.dismiss_update {
47        match update_check::latest_newer() {
48            Ok(Some(v)) => match config::set_dismissed(&v) {
49                Ok(()) => println!("dismissed update notice for {v}"),
50                Err(e) => {
51                    eprintln!("error: {e}");
52                    std::process::exit(1);
53                }
54            },
55            Ok(None) => println!("cmduse is up to date ({})", env!("CARGO_PKG_VERSION")),
56            Err(e) => {
57                eprintln!("error: {e}");
58                std::process::exit(1);
59            }
60        }
61        return;
62    }
63
64    if let Some(cli::SubCmd::Mcp) = args.subcmd {
65        mcp::run();
66        return;
67    }
68
69    let fmt = output_fmt(&args);
70    let colors = !args.plain && render::color_enabled();
71    let tz = args.tz.unwrap_or(0);
72
73    // offline local reports — no API, no key needed
74    match args.subcmd {
75        Some(cli::SubCmd::Daily) => {
76            match daily_output(args.last, tz, args.local) {
77                Ok(text) => print!("{text}"),
78                Err(e) => {
79                    eprintln!("error: {e}");
80                    std::process::exit(1);
81                }
82            }
83            return;
84        }
85        Some(cli::SubCmd::Hours) => {
86            match hourly_output(args.hours, tz, args.local) {
87                Ok(text) => print!("{text}"),
88                Err(e) => {
89                    eprintln!("error: {e}");
90                    std::process::exit(1);
91                }
92            }
93            return;
94        }
95        Some(cli::SubCmd::Model) => {
96            let since = since_secs_from(&args);
97            let d = reports::load_local_since(tz, since);
98            print!(
99                "{}",
100                report_render::model_table(&d.by_model, fmt, colors, args.since.as_deref())
101            );
102            return;
103        }
104        Some(cli::SubCmd::Session) => {
105            let d = reports::load_local(tz);
106            print!(
107                "{}",
108                report_render::project_table(&d.by_project, fmt, colors)
109            );
110            return;
111        }
112        Some(cli::SubCmd::Statusline) => {
113            statusline_cmd(&args);
114            return;
115        }
116        Some(cli::SubCmd::Models) => {
117            models_cmd(&args);
118            return;
119        }
120        Some(cli::SubCmd::Plans) => {
121            plans_cmd(&args);
122            return;
123        }
124        // handled (and served) before this match; unreachable
125        Some(cli::SubCmd::Mcp) => {}
126        None => {}
127    }
128
129    let cfg = config::load();
130    let interval = args.interval.unwrap_or(cfg.interval_secs).clamp(1, 86_400);
131    let bar_width = args.bar_width.unwrap_or(cfg.bar_width).clamp(5, 200);
132    // spend-burst sparkline is opt-in: -b/--bursts on the CLI turns it on for
133    // this run; otherwise it follows the config flag (default off).
134    let burst_on = args.bursts.is_some() || cfg.burst_enabled;
135    let burst_cap = if burst_on {
136        args.bursts
137            .or(Some(cfg.burst_samples))
138            .unwrap_or(40)
139            .clamp(5, 240)
140    } else {
141        0
142    };
143    let notify_on_cap = cfg.notify_on_cap;
144
145    // Update check before any output. Watch mode draws the notice inside the
146    // redrawn frame (an eprintln sits outside the in-place UI and scrolls
147    // away); one-shot has no frame, so it goes to stderr. Report subcommands
148    // returned earlier, so this only covers the dashboard.
149    let update = update_check::check_sync(cfg.check_updates, cfg.dismissed_update.as_deref());
150
151    if args.once {
152        if let Some(msg) = &update {
153            eprintln!("{msg}");
154        }
155        let s = snapshot::snapshot(false);
156        if args.json {
157            println!("{}", render::render_json(&s));
158        } else if args.plain || !render::color_enabled() {
159            print!("{}", render::render_plain(&s));
160        } else {
161            print!("{}", render::render(&s, bar_width));
162        }
163        return;
164    }
165
166    // live mode: true in-place redraw. Frame's last line = status line,
167    // drawn WITHOUT trailing newline so the cursor stays on it. Spinner
168    // and countdown rewrite that line in place. No scroll, no drift.
169    //
170    // Terminal size is re-queried every refresh: on a window too small for
171    // the full dashboard we swap to a compact single-line frame instead of
172    // exiting, so shrinking/widening mid-run follows live (term_size is NOT
173    // cached).
174    let stdout = std::io::stdout();
175    let mut out = stdout.lock();
176    let mut prev_lines = 0usize;
177    // One-shot guards ANSI with color_enabled(); the watch path must too, or a
178    // pipe (e.g. `cmduse | tee`) leaks SGR. Reuse the same decision.
179    let use_color = render::color_enabled();
180    // deltas ($ per refresh) feed the spend-burst sparkline. Session-only:
181    // no disk persistence — a fresh run shows a fresh trend, never old data.
182    let mut history: Vec<f64> = Vec::new();
183    let mut history_used: Vec<f64> = Vec::new(); // raw cumulative 5h spend
184                                                 // cap-hit notifications: fire once on the rising edge into "exceeded"
185    let mut was_exceeded_5h = false;
186    let mut was_exceeded_wk = false;
187    // Only the live dashboard wants the /dev/tty spinner; a piped run must not
188    // paint the terminal it was spawned from.
189    let spinner = !args.plain && std::io::stdout().is_terminal();
190    loop {
191        let s = snapshot::snapshot(spinner);
192        let (rows, cols) = term_size().unwrap_or((0, 0));
193        // <8x4 is not a usable dashboard even in compact form: keep drawing
194        // the compact line anyway (clip keeps it stable), no hard exit.
195        let compact = cols > 0 && (rows < 16 || cols < 40);
196        // track 5-hour spend DELTA between refreshes — cumulative spend is
197        // monotonic (sparkline of it is all-full); deltas show burst vs idle
198        let used = s
199            .credits
200            .window_limits
201            .five_hour
202            .as_ref()
203            .map(|w| w.used)
204            .unwrap_or(0.0);
205        let delta = history_used
206            .last()
207            .map(|prev| (used - prev).max(0.0))
208            .unwrap_or(0.0);
209        history_used.push(used);
210        if history_used.len() > 60 {
211            history_used.remove(0);
212        }
213        if burst_on {
214            history.push(delta);
215            if history.len() > burst_cap {
216                history.remove(0);
217            }
218        }
219        if notify_on_cap {
220            let ex5 = s
221                .credits
222                .window_limits
223                .five_hour
224                .as_ref()
225                .map(|w| w.exceeded)
226                .unwrap_or(false);
227            let exwk = s
228                .credits
229                .window_limits
230                .weekly
231                .as_ref()
232                .map(|w| w.exceeded)
233                .unwrap_or(false);
234            if ex5 && !was_exceeded_5h {
235                notify_cap("5-hour");
236            }
237            if exwk && !was_exceeded_wk {
238                notify_cap("weekly");
239            }
240            was_exceeded_5h = ex5;
241            was_exceeded_wk = exwk;
242        }
243        let text = if compact {
244            compact_dashboard(&s, if burst_on { &history } else { &[] })
245        } else if args.plain || !use_color {
246            render::render_plain(&s)
247        } else {
248            render::render(&s, bar_width)
249        };
250        let spark = if compact || !burst_on {
251            String::new()
252        } else if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
253            format!(
254                "{DIM}spend bursts ({}s samples){RESET} {}\n",
255                interval,
256                render::sparkline(&history)
257            )
258        } else {
259            String::new() // idle (all-zero deltas) → no row, no flat-line noise
260        };
261        let update_box = update
262            .as_ref()
263            .map(|m| render::update_box(m, (cols > 0).then_some(cols), use_color && !args.plain));
264        let status_line = format!("{DIM}refreshing every {interval}s · ctrl-c to quit{RESET}");
265        let frame = match &update_box {
266            Some(u) => format!("{u}{text}{spark}{status_line}"),
267            None => format!("{text}{spark}{status_line}"),
268        };
269        prev_lines = redraw_frame(&mut out, &frame, prev_lines, (cols > 0).then_some(cols));
270        out.flush().ok();
271        // countdown: rewrite just the status line each second (cursor already on it)
272        for remaining in (1..interval).rev() {
273            std::thread::sleep(std::time::Duration::from_secs(1));
274            let msg = format!(
275                "\r\x1b[2K{DIM}refreshing every {interval}s · next refresh in {remaining}s · ctrl-c to quit{RESET}"
276            );
277            write!(out, "{}", clip_to_width(&msg, (cols > 0).then_some(cols))).ok();
278            out.flush().ok();
279        }
280    }
281}
282
283/// Fire a desktop notification that a spend window hit its cap. Best-effort:
284/// runs on a detached thread and silently no-ops if the OS tool is missing.
285/// macOS uses osascript, Linux notify-send.
286fn notify_cap(window: &str) {
287    let window = window.to_string();
288    std::thread::spawn(move || {
289        let title = "cmduse: limit exceeded";
290        let body = format!("Command Code {window} window is over its cap");
291        if cfg!(target_os = "macos") {
292            let script = format!(
293                "display notification \"{}\" with title \"{}\"",
294                body.replace('\\', "\\\\").replace('"', "\\\""),
295                title
296            );
297            let _ = std::process::Command::new("osascript")
298                .arg("-e")
299                .arg(script)
300                .status();
301        } else if cfg!(target_os = "linux") {
302            let _ = std::process::Command::new("notify-send")
303                .arg(title)
304                .arg(&body)
305                .status();
306        }
307    });
308}
309
310/// Minimal one-line dashboard for windows too small for the full frame:
311/// plan + remaining monthly credits (clip handles the rest). Stays stable
312/// where the full 14-row dashboard would scroll every refresh. When the
313/// spend-burst sparkline is active it is inlined at the end of the same line
314/// (clip trims it first on very narrow windows).
315fn compact_dashboard(s: &crate::render::Snapshot, history: &[f64]) -> String {
316    use crate::render::plan_name;
317    if let Some(e) = &s.err {
318        return format!("{BOLD}error{RESET} {e}\n");
319    }
320    let cap = cmduse_core::plan_monthly_cap(&s.sub.plan_id);
321    let cap_txt = cap.map(cmduse_core::money).unwrap_or_else(|| "-".into());
322    let rem_txt = cmduse_core::money(s.credits.credits.monthly_credits);
323    let h5 = s
324        .credits
325        .window_limits
326        .five_hour
327        .as_ref()
328        .map(|w| cmduse_core::money(w.used))
329        .unwrap_or_else(|| "-".into());
330    let status = if s.sub.status.is_empty() {
331        String::new()
332    } else {
333        format!(" · {}", s.sub.status)
334    };
335    let spark = if history.len() >= 2 && history.iter().any(|v| *v > 0.0) {
336        format!(" {DIM}{}{RESET}", crate::render::sparkline(history))
337    } else {
338        String::new()
339    };
340    format!(
341        "{BOLD}{}{RESET} {rem_txt}/{cap_txt} · 5h {h5}{status}{spark}\n",
342        plan_name(&s.sub.plan_id)
343    )
344}
345
346/// Terminal (rows, cols) from `stty size`. None when not a tty or the probe
347/// fails — redraw then skips clipping (safe: non-tty can't wrap).
348/// Deliberately UNcached: the watch loop re-queries every refresh so resizing
349/// mid-run switches between the full and compact dashboards live.
350fn term_size() -> Option<(usize, usize)> {
351    let out = std::process::Command::new("sh")
352        .arg("-c")
353        .arg("stty size < /dev/tty")
354        .output()
355        .ok()?;
356    if !out.status.success() {
357        return None;
358    }
359    let text = String::from_utf8_lossy(&out.stdout);
360    let mut it = text.split_whitespace();
361    let rows = it.next()?.parse().ok()?;
362    let cols = it.next()?.parse().ok()?;
363    Some((rows, cols))
364}
365
366/// Keep a frame line from auto-wrapping: count visible columns ignoring SGR
367/// escapes and control chars, and drop trailing visible chars that would
368/// push past `cols`. Appends a reset so a truncated color run can't leak.
369pub fn clip_to_width(line: &str, cols: Option<usize>) -> String {
370    let Some(cols) = cols else { return line.into() };
371    if cols == 0 {
372        return String::new();
373    }
374    let mut vis = 0usize;
375    let mut cs = line.chars().peekable();
376    while let Some(&c) = cs.peek() {
377        match c {
378            '\x1b' => {
379                cs.next();
380                for e in cs.by_ref() {
381                    if e.is_ascii_alphabetic() {
382                        break;
383                    }
384                }
385            }
386            c if (c as u32) < 0x20 => {
387                cs.next(); // \r etc: zero width
388            }
389            _ => {
390                vis += 1;
391                cs.next();
392            }
393        }
394    }
395    if vis <= cols {
396        return line.into();
397    }
398    let mut out = String::new();
399    let mut kept = 0usize;
400    let mut cs = line.chars();
401    while let Some(c) = cs.next() {
402        match c {
403            '\x1b' => {
404                out.push(c);
405                for e in cs.by_ref() {
406                    out.push(e);
407                    if e.is_ascii_alphabetic() {
408                        break;
409                    }
410                }
411            }
412            '\r' => out.push(c), // carriage return is meaningful: keep it
413            c if (c as u32) < 0x20 => {}
414            _ => {
415                if kept < cols {
416                    out.push(c);
417                    kept += 1;
418                }
419            }
420        }
421    }
422    if out.ends_with("\x1b[0m") {
423        out
424    } else {
425        out + "\x1b[0m"
426    }
427}
428
429/// In-place terminal redraw of one frame. Assumes the cursor parks on the
430/// previous frame's last line (no trailing newline). Returns the new frame's
431/// line count so the caller can pass it back as `prev_lines`.
432fn redraw_frame(
433    out: &mut impl Write,
434    frame: &str,
435    prev_lines: usize,
436    cols: Option<usize>,
437) -> usize {
438    if prev_lines > 0 {
439        // move cursor up to top of previous frame (we're ON its last line)
440        write!(out, "\x1b[{}F", prev_lines - 1).ok();
441    }
442    let lines: Vec<&str> = frame.lines().collect();
443    let n = lines.len();
444    for (i, line) in lines.iter().enumerate() {
445        let line = clip_to_width(line, cols);
446        if i + 1 < n {
447            write!(out, "\x1b[2K\r{line}\n").ok();
448        } else {
449            // last line: clear and write, NO newline — cursor stays here
450            write!(out, "\x1b[2K\r{line}").ok();
451        }
452    }
453    if n < prev_lines {
454        // new frame shorter than old (error frame): clear the stale rows
455        // below the new frame WITHOUT touching the frame we just wrote.
456        // First move down one row (no clear) so the frame's last line
457        // survives, then clear-and-advance each remaining stale row,
458        // clear the old bottom row, and step back up to the new bottom.
459        // Writing \n at the very bottom row would scroll and desync —
460        // clear in place with \x1b[2K\x1b[1B instead.
461        write!(out, "\x1b[1B").ok();
462        for _ in (n + 1)..prev_lines {
463            write!(out, "\x1b[2K\x1b[1B").ok();
464        }
465        write!(out, "\x1b[2K").ok();
466        write!(out, "\x1b[{}F", prev_lines - n).ok();
467    }
468    n
469}
470
471/// `--since` (ISO, wins) or `--days N` as epoch seconds, for local reports.
472fn since_secs_from(args: &cli::Args) -> Option<f64> {
473    if let Some(iso) = &args.since {
474        return cmduse_core::dates::parse_iso_utc(iso);
475    }
476    let days = args.last?;
477    Some(cmduse_core::dates::now_secs() as f64 - (days as f64) * 86_400.0)
478}
479
480fn models_cmd(args: &cli::Args) {
481    if args.gated {
482        if let Some(days) = cmduse_core::gate_age_days(cmduse_core::dates::now_secs()) {
483            if days > 30 {
484                eprintln!(
485                    "warning: gating snapshot is {days}d old (CLI {}) — run `bun run extract` from the repo root",
486                    cmduse_core::GATE_CLI_VERSION
487                );
488            }
489        }
490    }
491    let key = match api::api_key() {
492        Ok(k) => k,
493        Err(e) => {
494            eprintln!("error: {e}");
495            std::process::exit(1);
496        }
497    };
498    // --gated needs the plan + credits to know what the account may use.
499    let (plan_id, unlocked) = if args.gated {
500        match (api::subscriptions(&key), api::credits(&key)) {
501            (Ok(s), Ok(c)) => (
502                s.plan_id,
503                c.credits.purchased_credits > 0.0 || c.credits.free_credits > 0.0,
504            ),
505            _ => (String::new(), true), // unknown → show everything
506        }
507    } else {
508        (String::new(), true)
509    };
510    match api::models(&key) {
511        Ok(list) => {
512            if args.json {
513                for m in &list {
514                    if args.gated {
515                        // annotate every model with the gating verdict
516                        let (allowed, reason) = cmduse_core::gate(&m.id, &plan_id, unlocked);
517                        let line = serde_json::json!({
518                            "id": m.id,
519                            "name": m.name,
520                            "context_length": m.context_length,
521                            "owned_by": m.owned_by,
522                            "allowed": allowed,
523                            "reason": reason,
524                        });
525                        println!("{line}");
526                    } else {
527                        let line = serde_json::to_string(m).unwrap_or_else(|_| "{}".into());
528                        println!("{line}");
529                    }
530                }
531                return;
532            }
533            let list: Vec<_> = if args.gated {
534                list.into_iter()
535                    .filter(|m| cmduse_core::gate_allowed(&m.id, &plan_id, unlocked))
536                    .collect()
537            } else {
538                list
539            };
540            if list.is_empty() {
541                println!("no models returned");
542                return;
543            }
544            let mut rows: Vec<_> = list
545                .iter()
546                .map(|m| {
547                    let ctx = m
548                        .context_length
549                        .map(|n| format!("{} ctx", render::compact(n)))
550                        .unwrap_or_else(|| "-".into());
551                    let name = m.name.as_deref().unwrap_or(&m.id);
552                    (m.id.as_str(), name, ctx)
553                })
554                .collect();
555            rows.sort_by(|a, b| a.1.cmp(b.1));
556            for (id, name, ctx) in rows {
557                println!("{name:<36} {ctx:<10} {id}");
558            }
559        }
560        Err(e) => {
561            eprintln!("error: {e}");
562            std::process::exit(1);
563        }
564    }
565}
566
567fn plans_cmd(args: &cli::Args) {
568    // current plan id is best-effort: without a key/network, show no mark.
569    let current = api::api_key()
570        .ok()
571        .and_then(|k| api::subscriptions(&k).ok())
572        .map(|s| s.plan_id)
573        .unwrap_or_default();
574    if args.json {
575        print!("{}", render::plans_json(&current));
576    } else {
577        print!("{}", render::plans_table(&current, render::color_enabled()));
578    }
579}
580
581// --- shared tool/report bodies (CLI subcommands AND `cmduse mcp` tools) ---
582// One implementation each; the MCP layer is a thin adapter over these.
583
584/// One-shot dashboard, plain text (no SGR) — the MCP `usage` tool body.
585pub(crate) fn usage_output() -> String {
586    render::render_plain(&snapshot::snapshot(false))
587}
588
589/// Plan comparison table. `colors` off for MCP, `render::color_enabled()` for
590/// the CLI. Current-plan mark is best-effort (no key/network → no mark).
591pub(crate) fn plans_output(colors: bool) -> String {
592    let current = api::api_key()
593        .ok()
594        .and_then(|k| api::subscriptions(&k).ok())
595        .map(|s| s.plan_id)
596        .unwrap_or_default();
597    render::plans_table(&current, colors)
598}
599
600/// Gated live model list as aligned text. `Err` = API/network failure.
601pub(crate) fn models_output(gated: bool) -> Result<String, String> {
602    let key = api::api_key().map_err(|e| e.to_string())?;
603    let (plan_id, unlocked) = if gated {
604        match (api::subscriptions(&key), api::credits(&key)) {
605            (Ok(s), Ok(c)) => (
606                s.plan_id,
607                c.credits.purchased_credits > 0.0 || c.credits.free_credits > 0.0,
608            ),
609            _ => (String::new(), true), // unknown → show everything
610        }
611    } else {
612        (String::new(), true)
613    };
614    let list = api::models(&key).map_err(|e| e.to_string())?;
615    let list: Vec<_> = if gated {
616        list.into_iter()
617            .filter(|m| cmduse_core::gate_allowed(&m.id, &plan_id, unlocked))
618            .collect()
619    } else {
620        list
621    };
622    if list.is_empty() {
623        return Ok("no models returned\n".into());
624    }
625    let mut rows: Vec<_> = list
626        .iter()
627        .map(|m| {
628            let ctx = m
629                .context_length
630                .map(|n| format!("{} ctx", render::compact(n)))
631                .unwrap_or_else(|| "-".into());
632            let name = m.name.as_deref().unwrap_or(&m.id);
633            (m.id.as_str(), name, ctx)
634        })
635        .collect();
636    rows.sort_by(|a, b| a.1.cmp(b.1));
637    let mut out = String::new();
638    for (id, name, ctx) in rows {
639        out.push_str(&format!("{name:<36} {ctx:<10} {id}\n"));
640    }
641    Ok(out)
642}
643
644/// Account-wide daily report; local CLI-log fallback when no key/--local.
645pub(crate) fn daily_output(days: Option<usize>, tz: i64, local: bool) -> Result<String, String> {
646    let fmt = report_render::Fmt::Table;
647    let tz_suffix = |t: i64| cmduse_core::dates::tz_offset_suffix(t);
648    // account-wide (all harnesses) via API when key available; local fallback
649    let data_source = if local {
650        None
651    } else {
652        api::api_key()
653            .ok()
654            .map(|k| reports::load_account_daily(days.unwrap_or(7), &k, tz))
655    };
656    match data_source {
657        Some(Ok(by_day)) => {
658            let total = reports::sum_days(&by_day);
659            Ok(report_render::table(
660                "account",
661                "all harnesses",
662                &by_day,
663                &total,
664                None,
665                fmt,
666                false,
667            ))
668        }
669        Some(Err(e)) => Err(e.to_string()),
670        None => {
671            let d = reports::load_local(tz);
672            let subtitle = if tz != 0 {
673                format!("offline, ~/.commandcode/projects, UTC{}", tz_suffix(tz))
674            } else {
675                "offline, ~/.commandcode/projects".to_string()
676            };
677            Ok(report_render::table(
678                "local", &subtitle, &d.by_day, &d.total, days, fmt, false,
679            ))
680        }
681    }
682}
683
684/// Hourly report; local CLI sessions with --local, else all harnesses.
685pub(crate) fn hourly_output(hours: Option<usize>, tz: i64, local: bool) -> Result<String, String> {
686    let fmt = report_render::Fmt::Table;
687    let tz_suffix = |t: i64| cmduse_core::dates::tz_offset_suffix(t);
688    let rows = if local {
689        reports::load_local_hourly(hours.unwrap_or(24), tz)
690    } else {
691        let key = api::api_key().map_err(|e| e.to_string())?;
692        reports::load_account_hourly(hours.unwrap_or(24), &key, tz).map_err(|e| e.to_string())?
693    };
694    let subtitle = if local {
695        if tz != 0 {
696            format!("local, CLI sessions, UTC{}", tz_suffix(tz))
697        } else {
698            "local, CLI sessions".to_string()
699        }
700    } else if tz != 0 {
701        format!("all harnesses, UTC{}", tz_suffix(tz))
702    } else {
703        "all harnesses, UTC".to_string()
704    };
705    Ok(report_render::hourly_table(&rows, fmt, &subtitle, false))
706}
707
708/// Report output format. `--json`/`--csv` are mutually exclusive (parse-guarded).
709fn output_fmt(args: &cli::Args) -> report_render::Fmt {
710    if args.json {
711        report_render::Fmt::Json
712    } else if args.csv {
713        report_render::Fmt::Csv
714    } else {
715        report_render::Fmt::Table
716    }
717}
718
719fn statusline_cmd(args: &cli::Args) {
720    let cfg = config::load();
721    let s = snapshot::snapshot(false);
722    let plan = render::plan_name(&s.sub.plan_id);
723    let cap = render::plan_monthly_cap(&s.sub.plan_id).unwrap_or(0.0);
724    if let Some(e) = &s.err {
725        println!("cmduse: {e}");
726        std::process::exit(1);
727    }
728    if args.json {
729        let w = |o: &Option<api::Window>| -> (f64, f64) {
730            (
731                o.as_ref().map(|w| w.used).unwrap_or(0.0),
732                o.as_ref().map(|w| w.cap).unwrap_or(0.0),
733            )
734        };
735        let (h5u, h5c) = w(&s.credits.window_limits.five_hour);
736        let (wku, wkc) = w(&s.credits.window_limits.weekly);
737        let out = serde_json::json!({
738            "plan": plan,
739            "monthlyRemaining": s.credits.credits.monthly_credits,
740            "monthlyCap": cap,
741            "fiveHourUsed": h5u,
742            "fiveHourCap": h5c,
743            "weeklyUsed": wku,
744            "weeklyCap": wkc,
745        });
746        print!("{out}");
747        return;
748    }
749    let h5 = s
750        .credits
751        .window_limits
752        .five_hour
753        .as_ref()
754        .map(|w| (w.used, w.cap));
755    let wk = s
756        .credits
757        .window_limits
758        .weekly
759        .as_ref()
760        .map(|w| (w.used, w.cap));
761    // pace ETAs for the {5h_eta}/{wk_eta} placeholders
762    let now = s.now;
763    let eta_of = |w: &Option<api::Window>, dur: u64| -> Option<String> {
764        let w = w.as_ref()?;
765        let secs = cmduse_core::pace_eta(w.reset_at, dur, w.used, w.cap, now)?;
766        Some(format!(
767            "on pace to hit cap in {}",
768            cmduse_core::duration(secs as u64)
769        ))
770    };
771    let h5_eta = eta_of(
772        &s.credits.window_limits.five_hour,
773        cmduse_core::FIVE_HOUR_SECS,
774    );
775    let wk_eta = eta_of(&s.credits.window_limits.weekly, cmduse_core::WEEKLY_SECS);
776    let d = report_render::StatusData {
777        plan,
778        monthly_remaining: s.credits.credits.monthly_credits,
779        monthly_cap: cap,
780        five_hour: &h5,
781        weekly: &wk,
782        five_hour_eta: h5_eta,
783        weekly_eta: wk_eta,
784        bar_width: cfg.bar_width.min(30),
785        colors: cfg.statusline_colors,
786        ascii: cfg.statusline_ascii,
787    };
788    print!(
789        "{}",
790        report_render::render_statusline(&cfg.statusline_template, &d)
791    );
792}