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