Skip to main content

cmd_usage/
lib.rs

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