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