Skip to main content

cmd_usage/ocuse/
cli.rs

1// ocuse — OpenCode Go/Zen usage, same CLI shape as cmduse.
2//
3// Data: opencode's own store (read-only), OpenCode Go's own usage endpoint when
4// the account carries a Go key — the meters opencode.ai/console shows, see
5// `ocuse::api` — and the generated docs catalogue.
6//
7// The front-end is the shared one: `cmduse/src/cli.rs` parses argv for both
8// binaries, `config.rs` owns both config files, `notify.rs` the desktop
9// notification, `update_check.rs` the crates.io notice. ocuse only supplies its
10// own source (the store) and its own renderers.
11//
12// Lives in the library (not the bin) so `ocuse` and its `ocusedev` twin share
13// one entry point: both bins are `fn main() { cmd_usage::ocuse::run(); }`.
14use crate::cli::{self, Binary, SubCmd};
15use crate::ocuse::window;
16use crate::ocuse::{api, build, db, mcp, render, zen};
17use crate::report_render::Fmt;
18use std::collections::HashSet;
19use std::ffi::OsString;
20use std::io::Write;
21
22/// `YYYY-MM-DD` -> epoch ms at 00:00 UTC, via the same civil helpers the window
23/// module uses (no date dependency).
24fn parse_day(value: &str) -> Option<i64> {
25    let mut parts = value.split('-');
26    let y: i64 = parts.next()?.parse().ok()?;
27    let m: i64 = parts.next()?.parse().ok()?;
28    let d: i64 = parts.next()?.parse().ok()?;
29    (1..=12).contains(&m).then_some(())?;
30    (1..=31).contains(&d).then_some(())?;
31    Some(window::days_from_civil(y, m, d) * 86_400_000)
32}
33
34/// Flags whose value is the next argv token (mirrors the shared parser). Used
35/// only to locate ocuse's `model <id>` positional before it parses.
36const VALUE_FLAGS: &[&str] = &[
37    "-i",
38    "--interval",
39    "-w",
40    "--bar-width",
41    "-b",
42    "--bursts",
43    "--days",
44    "--last",
45    "-l",
46    "--since",
47    "--hours",
48    "--tz",
49    "--db",
50    "--plan",
51    "--window",
52    "--period-start",
53    "--rows",
54];
55
56/// ocuse's `model` command takes an optional model id, which is a plain
57/// positional; the shared parser (written for cmduse, whose `model` takes none)
58/// rejects a stray positional. Pull the id out first so `ocuse model <id>`
59/// keeps working — the rest of argv parses exactly as the shared parser does.
60fn split_model_id(argv: Vec<OsString>) -> (Vec<OsString>, Option<String>) {
61    let strs: Vec<String> = argv
62        .iter()
63        .map(|a| a.to_string_lossy().into_owned())
64        .collect();
65    let mut drop_at = None;
66    let mut skip_value = false;
67    let mut after_model = false;
68    for (i, tok) in strs.iter().enumerate() {
69        if skip_value {
70            skip_value = false;
71            continue;
72        }
73        if tok.starts_with('-') {
74            // `--flag=value` carries its own value; `-w40` is attached, not a
75            // separate token. Only the bare value flags consume the next token.
76            if VALUE_FLAGS.contains(&tok.as_str()) {
77                skip_value = true;
78            }
79            continue;
80        }
81        if after_model {
82            drop_at = Some(i);
83            break;
84        }
85        after_model = tok == "model";
86    }
87    let id = drop_at.map(|i| strs[i].clone());
88    let argv = argv
89        .into_iter()
90        .enumerate()
91        .filter(|(i, _)| Some(*i) != drop_at)
92        .map(|(_, a)| a)
93        .collect();
94    (argv, id)
95}
96
97/// The live watch loop is the default command, but `-1`/`--once` and `--json`
98/// both want a single frame printed and an exit. A bare `--json` used to fall
99/// into the loop and print no JSON at all, so it is one-shot too.
100fn wants_watch(args: &cli::Args) -> bool {
101    !args.once && !args.json
102}
103
104/// cmduse's small-terminal rule: one clipped line instead of the full frame.
105fn compact_frame(rows: usize, cols: usize) -> bool {
106    cols > 0 && (rows < 16 || cols < 40)
107}
108
109/// Spend-burst sparkline derivation, identical to cmduse's `lib.rs`: a CLI
110/// `-b/--bursts` turns it on for the run; otherwise it follows the config flag.
111/// The cap is the CLI value, else the config sample count, clamped 5–240.
112fn burst_config(bursts: Option<usize>, enabled: bool, samples: usize) -> (bool, usize) {
113    let on = bursts.is_some() || enabled;
114    let cap = if on {
115        bursts.or(Some(samples)).unwrap_or(40).clamp(5, 240)
116    } else {
117        0
118    };
119    (on, cap)
120}
121
122/// Report output format, mirroring cmduse's (`--json` / `--csv` / table).
123fn output_fmt(args: &cli::Args) -> Fmt {
124    if args.json {
125        Fmt::Json
126    } else if args.csv {
127        Fmt::Csv
128    } else {
129        Fmt::Table
130    }
131}
132
133/// The period's end (epoch ms), resolved from the report's own resolved start so
134/// an inferred anniversary gets one too. `--window all` has no boundary (`None`);
135/// an `<n>d` window ends n days after it started; every other mode — the calendar
136/// month, `--period-start`, an inferred anniversary — runs to the same
137/// day-of-month next month. The rolling 5h/weekly windows never carry an end:
138/// they have no real reset (see `render_text`).
139fn period_end(report: &window::Report, window_mode: &str, inferred: bool) -> Option<i64> {
140    // Inference pins the start itself, so it outranks the window mode.
141    if inferred {
142        return Some(window::next_month(report.since_ms));
143    }
144    if window_mode == "all" {
145        None
146    } else if let Some(d) = window_mode
147        .strip_suffix('d')
148        .and_then(|v| v.parse::<i64>().ok())
149    {
150        Some(report.since_ms + d * 86_400_000)
151    } else {
152        Some(window::next_month(report.since_ms))
153    }
154}
155
156pub fn run() {
157    let (argv, model_id) = split_model_id(std::env::args_os().collect());
158    let args = match cli::parse_args_from(argv, Binary::Ocuse) {
159        Ok(a) => a,
160        Err(e) => {
161            eprintln!("{e}");
162            std::process::exit(2);
163        }
164    };
165    if args.help {
166        cli::usage(Binary::Ocuse);
167        return;
168    }
169    if let Some(cs) = args.config_set {
170        if let Err(e) = crate::config::set(Binary::Ocuse, &cs) {
171            eprintln!("error: {e}");
172            std::process::exit(1);
173        }
174        return;
175    }
176    if args.dismiss_update {
177        match crate::update_check::latest_newer() {
178            Ok(Some(v)) => match crate::config::set_dismissed(Binary::Ocuse, &v) {
179                Ok(()) => println!("dismissed update notice for {v}"),
180                Err(e) => {
181                    eprintln!("error: {e}");
182                    std::process::exit(1);
183                }
184            },
185            Ok(None) => println!("ocuse is up to date ({})", env!("CARGO_PKG_VERSION")),
186            Err(e) => {
187                eprintln!("error: {e}");
188                std::process::exit(1);
189            }
190        }
191        return;
192    }
193
194    let cfg = crate::config::load(Binary::Ocuse);
195    let colours = !args.plain && crate::render::color_enabled();
196    let fmt = output_fmt(&args);
197    let db_path = args.db.clone().unwrap_or_else(db::default_path);
198    let days: i64 = args.last.unwrap_or(120) as i64;
199    let hours: i64 = args.hours.unwrap_or(24) as i64;
200    let rows: usize = args.rows_cap(15);
201    let now = window::now_ms();
202    let tz_secs = args.tz.unwrap_or(0);
203
204    // `plans` needs neither the store nor a plan id: the catalogue is static.
205    if let Some(SubCmd::Plans) = args.subcmd {
206        if args.json {
207            print!("{}", render::plans_json());
208        } else {
209            print!("{}", render::plans_text(colours));
210        }
211        return;
212    }
213
214    // Which Go plan the allowances are measured against: explicit, because the
215    // store records no subscription and the usage endpoint reports a percentage
216    // rather than a plan id.
217    let catalog = zen::catalog();
218    let plan_id = args
219        .plan
220        .clone()
221        .unwrap_or_else(|| zen::DEFAULT_PLAN.to_string());
222    if catalog.plan(&plan_id).is_none() {
223        eprintln!(
224            "ocuse: --plan expects one of {} (got {plan_id})",
225            catalog.plan_ids().join(", ")
226        );
227        std::process::exit(2);
228    }
229
230    // `models` is the catalogue joined with one plan's allowances: no store read.
231    if let Some(SubCmd::Models) = args.subcmd {
232        if args.json {
233            print!("{}", render::models_json(&plan_id, args.gated));
234        } else {
235            print!("{}", render::models_text(&plan_id, args.gated, colours));
236        }
237        return;
238    }
239
240    // `--since` (an ISO-8601 instant) pins the report window for model/session,
241    // as in cmduse; it outranks --period-start and --window. `parse_iso_utc`
242    // yields epoch milliseconds (the same units as the store's timestamps).
243    let since_override = args
244        .since
245        .as_deref()
246        .and_then(cmduse_core::dates::parse_iso_utc)
247        .map(|ms| ms as i64);
248
249    // The period: explicit start > window mode > calendar month. Inference from
250    // the first Go request is opt-in because it guesses badly on sparse history
251    // (an account whose usage starts mid-cycle infers the wrong anniversary).
252    let window_mode = args.window.clone().unwrap_or_else(|| "month".into());
253    let period_start = if let Some(ms) = since_override {
254        Some(ms)
255    } else if let Some(day) = args.period_start.as_deref().and_then(parse_day) {
256        Some(day)
257    } else if window_mode == "all" {
258        Some(0)
259    } else if window_mode == "month" {
260        Some(window::calendar_month_start(now))
261    } else if let Some(d) = window_mode
262        .strip_suffix('d')
263        .and_then(|v| v.parse::<i64>().ok())
264    {
265        Some(now - d * 86_400_000)
266    } else {
267        eprintln!("ocuse: --window expects month, all, or <n>d (got {window_mode})");
268        std::process::exit(2);
269    };
270    let period_label = if let Some(iso) = &args.since {
271        format!("since {iso}")
272    } else if args.period_start.is_some() {
273        "period start (pinned)".to_string()
274    } else {
275        match window_mode.as_str() {
276            "all" => "all time".to_string(),
277            "month" => "calendar month".to_string(),
278            other => other.to_string(),
279        }
280    };
281    let period_start =
282        if args.infer_anniversary && args.period_start.is_none() && args.since.is_none() {
283            None
284        } else {
285            period_start
286        };
287    // Only the inference branch leaves `period_start` unset; the report then
288    // resolves the anniversary itself, so remember which mode produced it.
289    let period_inferred = period_start.is_none();
290
291    // OpenCode Go's own meters: the console's numbers, straight from the account
292    // (see `ocuse::api`). A missing key, a refused key, or no network leaves
293    // `None` and a named reason, and every window row falls back to the local
294    // derivation.
295    let fetch_account = || -> Result<api::Usage, api::Unavailable> {
296        if args.no_api {
297            return Err(api::Unavailable::Disabled);
298        }
299        match api::key() {
300            Some(key) => api::fetch(&key),
301            None => Err(api::Unavailable::NoKey),
302        }
303    };
304    let (mut account, mut account_error) = match fetch_account() {
305        Ok(usage) => (Some(usage), None),
306        Err(why) => (None, why.reason()),
307    };
308    // The endpoint knows the billing anniversary; the calendar month is a guess,
309    // and slicing the totals by it would disagree with the monthly meter printed
310    // in the same frame. So when the endpoint answered and nothing pinned the
311    // period, take the account's own month: `(start, reset)`.
312    let account_month = account
313        .as_ref()
314        .and_then(|a| a.monthly.reset_ms())
315        .map(|reset| (window::prev_month(reset), reset));
316    let account_month = if window_mode == "month"
317        && !period_inferred
318        && since_override.is_none()
319        && args.period_start.is_none()
320    {
321        account_month
322    } else {
323        None
324    };
325    let period_start = account_month.map(|(start, _)| start).or(period_start);
326    let period_label = match account_month {
327        Some(_) => "Go billing month".to_string(),
328        None => period_label,
329    };
330
331    let read_since = |since_ms: i64| match db::read_rows(&db_path, since_ms) {
332        Ok(rows) => rows,
333        Err(error) => {
334            eprintln!("ocuse: {error}");
335            eprintln!("hint: --db <path>, or OPENCODE_DB; the store is created by opencode itself");
336            std::process::exit(1);
337        }
338    };
339    // The report's own read window: an explicit --since pins it, else --days.
340    let report_window = since_override.unwrap_or(now - days * 86_400_000);
341    let build_at = |t: i64, account: Option<&api::Usage>, account_error: Option<&str>| {
342        let mut report = build(
343            &read_since(report_window),
344            &catalog,
345            &plan_id,
346            t,
347            period_start,
348            account,
349        );
350        report.period_label = period_label.clone();
351        report.account = account.cloned();
352        report.account_error = account_error.map(str::to_string);
353        report.period_end_ms = account_month
354            .map(|(_, reset)| reset)
355            .or_else(|| period_end(&report, &window_mode, period_inferred));
356        report
357    };
358
359    match args.subcmd {
360        Some(SubCmd::Statusline) => {
361            let report = build_at(now, account.as_ref(), account_error.as_deref());
362            if args.json {
363                println!("{}", render::statusline_json(&report, &plan_id));
364            } else {
365                print!(
366                    "{}",
367                    render::statusline_template(
368                        &report,
369                        &plan_id,
370                        &cfg.statusline_template,
371                        cfg.statusline_colors,
372                        cfg.statusline_ascii,
373                        cfg.bar_width.min(30),
374                    )
375                );
376            }
377            return;
378        }
379        Some(SubCmd::Daily) => {
380            print!(
381                "{}",
382                render::bucket_text(
383                    &read_since(now - days * 86_400_000),
384                    now,
385                    days * 86_400_000,
386                    86_400_000,
387                    tz_secs,
388                    colours,
389                    fmt,
390                )
391            );
392            return;
393        }
394        Some(SubCmd::Hours) => {
395            print!(
396                "{}",
397                render::bucket_text(
398                    &read_since(now - hours * 3_600_000),
399                    now,
400                    hours * 3_600_000,
401                    3_600_000,
402                    tz_secs,
403                    colours,
404                    fmt,
405                )
406            );
407            return;
408        }
409        Some(SubCmd::Session) => {
410            print!(
411                "{}",
412                render::session_text(&read_since(report_window), args.rows_cap(20), colours, fmt)
413            );
414            return;
415        }
416        Some(SubCmd::Mcp) => {
417            // stdio JSON-RPC; stdout carries protocol only, so nothing else may print.
418            mcp::run(mcp::Settings {
419                db_path: &db_path,
420                days,
421                tz_secs,
422                colour: false,
423                plan: &plan_id,
424                period_start,
425                account: account.as_ref(),
426                account_error: account_error.as_deref(),
427            });
428            return;
429        }
430        Some(SubCmd::Model) => {
431            let report = build_at(now, account.as_ref(), account_error.as_deref());
432            if fmt == Fmt::Table {
433                match &model_id {
434                    Some(id) => print!("{}", render::model_text(&report, id, colours)),
435                    None => print!(
436                        "{}",
437                        render::render_text(
438                            &report,
439                            &db_path,
440                            args.rows_cap(usize::MAX),
441                            colours,
442                            args.bar_width.unwrap_or(cfg.bar_width).clamp(5, 200),
443                        )
444                    ),
445                }
446            } else {
447                print!(
448                    "{}",
449                    render::model_report(&report, model_id.as_deref(), fmt)
450                );
451            }
452            return;
453        }
454        // served above: plans and models need no store read
455        Some(SubCmd::Plans) | Some(SubCmd::Models) => return,
456        None => {}
457    }
458
459    // Dashboard (the default command): one-shot or live watch.
460    let interval = args.interval.unwrap_or(cfg.interval_secs).clamp(1, 86_400);
461    let bar_width = args.bar_width.unwrap_or(cfg.bar_width).clamp(5, 200);
462    let (burst_on, burst_cap) = burst_config(args.bursts, cfg.burst_enabled, cfg.burst_samples);
463    let notify_on_cap = cfg.notify_on_cap;
464
465    // Update check before any output. Watch mode draws the notice inside the
466    // redrawn frame (an eprintln sits outside the in-place UI and scrolls away);
467    // one-shot has no frame, so it goes to stderr.
468    let update =
469        crate::update_check::check_sync(cfg.check_updates, cfg.dismissed_update.as_deref());
470
471    if !wants_watch(&args) {
472        if let Some(msg) = &update {
473            eprintln!("{msg}");
474        }
475        let report = build_at(now, account.as_ref(), account_error.as_deref());
476        if args.json {
477            println!("{}", render::render_json(&report));
478        } else {
479            print!(
480                "{}",
481                render::render_text(&report, &db_path, rows, colours, bar_width)
482            );
483        }
484        return;
485    }
486
487    // Live frame, cmduse's redraw contract: the frame's last line carries no
488    // newline, so the cursor parks there and the countdown can rewrite that one
489    // line each second instead of repainting the whole frame.
490    let ink = render::Ink::new(colours);
491    let mut out = std::io::stdout();
492    let mut prev_lines = 0usize;
493    let mut history: Vec<f64> = Vec::new();
494    let mut last_five: Option<f64> = None;
495    // Per-model cap-hit notifications: fire once on the rising edge into "over".
496    let mut over: HashSet<String> = HashSet::new();
497    let use_color = crate::render::color_enabled();
498    loop {
499        // Re-ask each tick, keeping the last answer through a blip so the frame
500        // cannot flip back and forth between the account's meters and the local
501        // estimate. A blip's *reason* is dropped with it, for the same reason: the
502        // rows on screen are still the account's, so nothing was unavailable.
503        match fetch_account() {
504            Ok(fresh) => {
505                account = Some(fresh);
506                account_error = None;
507            }
508            Err(why) => {
509                if account.is_none() {
510                    account_error = why.reason();
511                }
512            }
513        }
514        let report = build_at(window::now_ms(), account.as_ref(), account_error.as_deref());
515        let (term_rows, cols) = crate::term_size().unwrap_or((0, 0));
516        let cols_opt = (cols > 0).then_some(cols);
517        let compact = compact_frame(term_rows, cols);
518        // 5-hour spend only rises between refreshes, so the delta is the burst;
519        // a sparkline of the cumulative figure would just stay full.
520        let delta = last_five
521            .map(|prev| (report.five_hour.cost - prev).max(0.0))
522            .unwrap_or(0.0);
523        last_five = Some(report.five_hour.cost);
524        if burst_on {
525            history.push(delta);
526            if history.len() > burst_cap {
527                history.remove(0);
528            }
529        }
530        if notify_on_cap {
531            let mut over_now: HashSet<String> = HashSet::new();
532            // The account's own verdicts first: they are what the console shows,
533            // and they cover the windows no local per-model figure can.
534            if let Some(a) = &account {
535                for (key, label, meter) in [
536                    ("window:5h", "5-hour window", &a.rolling),
537                    ("window:wk", "weekly window", &a.weekly),
538                    ("window:mo", "monthly limit", &a.monthly),
539                ] {
540                    if meter.exceeded() {
541                        over_now.insert(key.to_string());
542                        if !over.contains(key) {
543                            crate::notify::cap(
544                                "ocuse: limit exceeded",
545                                &format!("OpenCode Go {label} is over its limit"),
546                            );
547                        }
548                    }
549                }
550            }
551            for model in &report.models {
552                if let Some(window) = window::over_window(model) {
553                    over_now.insert(model.id.clone());
554                    if !over.contains(&model.id) {
555                        crate::notify::cap(
556                            "ocuse: limit exceeded",
557                            &format!("OpenCode Go {} is over its {window} limit", model.id),
558                        );
559                    }
560                }
561            }
562            over = over_now;
563        }
564        let burst_slice: &[f64] = if burst_on { &history } else { &[] };
565        let text = if compact {
566            render::compact_line(&report, burst_slice, colours)
567        } else {
568            render::watch_frame(
569                &report,
570                &db_path,
571                rows,
572                colours,
573                burst_slice,
574                interval,
575                bar_width,
576            )
577        };
578        let update_box = update
579            .as_ref()
580            .map(|m| crate::render::update_box(m, cols_opt, use_color && !args.plain));
581        let status = format!(
582            "{}refreshing every {interval}s · ctrl-c to quit{}",
583            ink.dim, ink.reset
584        );
585        let frame = match &update_box {
586            Some(u) => format!("{u}{text}\n{status}"),
587            None => format!("{text}\n{status}"),
588        };
589        prev_lines = crate::redraw_frame(&mut out, &frame, prev_lines, cols_opt);
590        let _ = out.flush();
591        for remaining in (1..interval).rev() {
592            std::thread::sleep(std::time::Duration::from_secs(1));
593            let msg = format!(
594                "\r\x1b[2K{}refreshing every {interval}s · next refresh in {remaining}s · ctrl-c to quit{}",
595                ink.dim, ink.reset
596            );
597            let _ = write!(out, "{}", crate::clip_to_width(&msg, cols_opt));
598            let _ = out.flush();
599        }
600    }
601}
602
603#[cfg(test)]
604mod tests {
605    use super::*;
606
607    fn argv(args: &[&str]) -> Vec<OsString> {
608        std::iter::once(OsString::from("ocuse"))
609            .chain(args.iter().map(OsString::from))
610            .collect()
611    }
612
613    fn parse(args: &[&str]) -> cli::Args {
614        cli::parse_args_from(argv(args), Binary::Ocuse).expect("parses")
615    }
616
617    #[test]
618    fn json_and_one_shot_leave_the_watch_loop() {
619        assert!(wants_watch(&parse(&[]))); // default: live frame
620        assert!(wants_watch(&parse(&["-W"])));
621        assert!(!wants_watch(&parse(&["-1"]))); // -1
622        assert!(!wants_watch(&parse(&["--once"])));
623        assert!(!wants_watch(&parse(&["--json"]))); // bare --json too
624    }
625
626    #[test]
627    fn compact_frame_switches_on_small_terminals() {
628        assert!(!compact_frame(40, 120)); // roomy
629        assert!(compact_frame(10, 120)); // too few rows
630        assert!(compact_frame(40, 30)); // too few columns
631        assert!(!compact_frame(0, 0)); // unknown size: never compact
632        assert!(compact_frame(0, 20)); // cols known, tiny
633    }
634
635    #[test]
636    fn burst_state_derives_from_args_and_config() {
637        // config off, no CLI flag → off
638        assert_eq!(burst_config(None, false, 40), (false, 0));
639        // CLI flag turns it on for the run, using its own sample count
640        assert_eq!(burst_config(Some(30), false, 40), (true, 30));
641        // config on → on with the configured samples
642        assert_eq!(burst_config(None, true, 25), (true, 25));
643        // CLI value wins over config's
644        assert_eq!(burst_config(Some(60), true, 25), (true, 60));
645        // a hand-edited config below the floor is clamped, like cmduse's
646        assert_eq!(burst_config(None, true, 3), (true, 5));
647    }
648
649    #[test]
650    fn gated_is_a_shared_flag() {
651        // `--gated` filters `models` on both binaries, so it goes through the
652        // shared parser instead of being lifted out of argv first.
653        let a = cli::parse_args_from(argv(&["models", "--gated", "--plan", "go"]), Binary::Ocuse)
654            .expect("parses");
655        assert!(a.gated);
656        assert_eq!(a.subcmd, Some(SubCmd::Models));
657        assert_eq!(a.plan.as_deref(), Some("go"));
658        assert!(
659            !cli::parse_args_from(argv(&["models"]), Binary::Ocuse)
660                .expect("parses")
661                .gated
662        );
663    }
664
665    #[test]
666    fn no_api_is_an_ocuse_flag() {
667        // The account's meters are only reachable from ocuse, so the escape
668        // hatch belongs to it; cmduse's account API is its own source.
669        assert!(parse(&["-1", "--no-api"]).no_api);
670        assert!(!parse(&["-1"]).no_api);
671        assert!(
672            cli::parse_args_from(argv(&["--no-api"]), Binary::Cmduse).is_err(),
673            "cmduse rejects --no-api"
674        );
675    }
676
677    #[test]
678    fn output_fmt_maps_the_flags() {
679        let of = |args: &[&str]| output_fmt(&parse(args));
680        assert!(matches!(of(&[]), Fmt::Table));
681        assert!(matches!(of(&["--json"]), Fmt::Json));
682        assert!(matches!(of(&["--csv"]), Fmt::Csv));
683    }
684
685    #[test]
686    fn period_end_resolves_from_the_reports_own_start() {
687        let since = window::days_from_civil(2026, 9, 15) * 86_400_000;
688        let report = window::Report {
689            period_label: "calendar month".into(),
690            now_ms: since,
691            since_ms: since,
692            period_end_ms: None,
693            month_inferred: false,
694            account: None,
695            account_error: None,
696            five_hour: Default::default(),
697            weekly: Default::default(),
698            month: Default::default(),
699            models: Vec::new(),
700        };
701        // Month / --period-start: same day-of-month next month.
702        assert_eq!(
703            period_end(&report, "month", false),
704            Some(window::next_month(since))
705        );
706        // An <n>d window ends n days after it started.
707        assert_eq!(
708            period_end(&report, "30d", false),
709            Some(since + 30 * 86_400_000)
710        );
711        // All time has no boundary.
712        assert_eq!(period_end(&report, "all", false), None);
713        // Inference pins the start itself, so it outranks the window mode.
714        assert_eq!(
715            period_end(&report, "30d", true),
716            Some(window::next_month(since))
717        );
718    }
719
720    #[test]
721    fn model_id_is_pulled_out_before_parsing() {
722        let (rest, id) = split_model_id(argv(&["model", "glm-5.3-flash"]));
723        assert_eq!(id.as_deref(), Some("glm-5.3-flash"));
724        // the id is gone; the rest still parses as the shared parser expects
725        assert_eq!(
726            cli::parse_args_from(rest, Binary::Ocuse).unwrap().subcmd,
727            Some(SubCmd::Model)
728        );
729
730        // no id → the parser sees a bare `model`
731        let (_, id) = split_model_id(argv(&["model"]));
732        assert!(id.is_none());
733
734        // a flag value that happens to be a word is not mistaken for the id
735        let (rest, id) = split_model_id(argv(&["--plan", "go", "model", "glm-5.3-flash"]));
736        assert_eq!(id.as_deref(), Some("glm-5.3-flash"));
737        assert!(cli::parse_args_from(rest, Binary::Ocuse).is_ok());
738
739        // value flags between `model` and the id are skipped, not consumed
740        let (_, id) = split_model_id(argv(&["model", "--days", "7", "glm-5.3-flash"]));
741        assert_eq!(id.as_deref(), Some("glm-5.3-flash"));
742
743        // other commands pass through untouched
744        let (_, id) = split_model_id(argv(&["daily", "--days", "7"]));
745        assert!(id.is_none());
746    }
747
748    #[test]
749    fn ocuse_reaches_shared_flag_parity() {
750        // every shared flag cmduse's help prints is accepted by ocuse's parser
751        for args in [
752            vec!["-1"],
753            vec!["-W"],
754            vec!["-p"],
755            vec!["-i", "30"],
756            vec!["-w", "20"],
757            vec!["-b", "40"],
758            vec!["--json"],
759            vec!["--csv"],
760            vec!["--tz", "+05:30"],
761            vec!["--days", "7"],
762            vec!["--hours", "6"],
763            vec!["--since", "2026-01-01T00:00:00Z"],
764            vec!["--dismiss-update"],
765            vec!["--gated"],
766            vec!["config", "set", "interval=10"],
767        ] {
768            assert!(
769                cli::parse_args_from(argv(&args), Binary::Ocuse).is_ok(),
770                "ocuse must accept {args:?}"
771            );
772        }
773        // `--local` stays cmduse-only (ocuse is always local); `--gated` is a
774        // shared flag now, so it must parse for ocuse too.
775        assert!(cli::parse_args_from(argv(&["--local"]), Binary::Ocuse).is_err());
776        assert!(cli::parse_args_from(argv(&["--gated"]), Binary::Ocuse).is_ok());
777    }
778
779    #[test]
780    fn ocuse_parses_every_shared_subcommand() {
781        // The shared parser hands both binaries the same command surface,
782        // including `models` (implemented for ocuse now).
783        for (word, want) in [
784            ("daily", SubCmd::Daily),
785            ("days", SubCmd::Daily),
786            ("hourly", SubCmd::Hours),
787            ("hours", SubCmd::Hours),
788            ("model", SubCmd::Model),
789            ("session", SubCmd::Session),
790            ("sessions", SubCmd::Session),
791            ("project", SubCmd::Session),
792            ("statusline", SubCmd::Statusline),
793            ("models", SubCmd::Models),
794            ("plans", SubCmd::Plans),
795            ("mcp", SubCmd::Mcp),
796        ] {
797            for binary in [Binary::Cmduse, Binary::Ocuse] {
798                let a = cli::parse_args_from(
799                    std::iter::once(OsString::from("x"))
800                        .chain(std::iter::once(OsString::from(word))),
801                    binary,
802                )
803                .unwrap_or_else(|e| panic!("{binary:?} must parse {word}: {e}"));
804                assert_eq!(a.subcmd.as_ref(), Some(&want), "{binary:?} {word}");
805            }
806        }
807    }
808
809    #[test]
810    fn ocuse_help_carries_the_shared_tokens() {
811        let c = cli::usage_text(Binary::Cmduse);
812        let o = cli::usage_text(Binary::Ocuse);
813        // every shared flag token cmduse prints appears in ocuse's help too
814        for tok in [
815            "-1",
816            "--once",
817            "-W",
818            "--watch",
819            "-p",
820            "--plain",
821            "-h",
822            "--help",
823            "-V",
824            "--version",
825            "--json",
826            "--csv",
827            "-i",
828            "--interval",
829            "-w",
830            "--bar-width",
831            "-b",
832            "--bursts",
833            "--days",
834            "--since",
835            "--hours",
836            "--tz",
837            "--dismiss-update",
838            "NO_COLOR",
839            "config set",
840        ] {
841            if c.contains(tok) {
842                assert!(o.contains(tok), "ocuse help missing shared token {tok}");
843            }
844        }
845    }
846}