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.unwrap_or(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        );
349        report.period_label = period_label.clone();
350        report.account = account.cloned();
351        report.account_error = account_error.map(str::to_string);
352        report.period_end_ms = account_month
353            .map(|(_, reset)| reset)
354            .or_else(|| period_end(&report, &window_mode, period_inferred));
355        report
356    };
357
358    match args.subcmd {
359        Some(SubCmd::Statusline) => {
360            let report = build_at(now, account.as_ref(), account_error.as_deref());
361            if args.json {
362                println!("{}", render::statusline_json(&report, &plan_id));
363            } else {
364                print!(
365                    "{}",
366                    render::statusline_template(
367                        &report,
368                        &plan_id,
369                        &cfg.statusline_template,
370                        cfg.statusline_colors,
371                        cfg.statusline_ascii,
372                        cfg.bar_width.min(30),
373                    )
374                );
375            }
376            return;
377        }
378        Some(SubCmd::Daily) => {
379            print!(
380                "{}",
381                render::bucket_text(
382                    &read_since(now - days * 86_400_000),
383                    now,
384                    days * 86_400_000,
385                    86_400_000,
386                    tz_secs,
387                    colours,
388                    fmt,
389                )
390            );
391            return;
392        }
393        Some(SubCmd::Hours) => {
394            print!(
395                "{}",
396                render::bucket_text(
397                    &read_since(now - hours * 3_600_000),
398                    now,
399                    hours * 3_600_000,
400                    3_600_000,
401                    tz_secs,
402                    colours,
403                    fmt,
404                )
405            );
406            return;
407        }
408        Some(SubCmd::Session) => {
409            print!(
410                "{}",
411                render::session_text(&read_since(report_window), 20, colours, fmt)
412            );
413            return;
414        }
415        Some(SubCmd::Mcp) => {
416            // stdio JSON-RPC; stdout carries protocol only, so nothing else may print.
417            mcp::run(mcp::Settings {
418                db_path: &db_path,
419                days,
420                tz_secs,
421                colour: false,
422                plan: &plan_id,
423                period_start,
424                account: account.as_ref(),
425                account_error: account_error.as_deref(),
426            });
427            return;
428        }
429        Some(SubCmd::Model) => {
430            let report = build_at(now, account.as_ref(), account_error.as_deref());
431            if fmt == Fmt::Table {
432                match &model_id {
433                    Some(id) => print!("{}", render::model_text(&report, id, colours)),
434                    None => print!(
435                        "{}",
436                        render::render_text(
437                            &report,
438                            &db_path,
439                            usize::MAX,
440                            colours,
441                            args.bar_width.unwrap_or(cfg.bar_width).clamp(5, 200),
442                        )
443                    ),
444                }
445            } else {
446                print!(
447                    "{}",
448                    render::model_report(&report, model_id.as_deref(), fmt)
449                );
450            }
451            return;
452        }
453        // served above: plans and models need no store read
454        Some(SubCmd::Plans) | Some(SubCmd::Models) => return,
455        None => {}
456    }
457
458    // Dashboard (the default command): one-shot or live watch.
459    let interval = args.interval.unwrap_or(cfg.interval_secs).clamp(1, 86_400);
460    let bar_width = args.bar_width.unwrap_or(cfg.bar_width).clamp(5, 200);
461    let (burst_on, burst_cap) = burst_config(args.bursts, cfg.burst_enabled, cfg.burst_samples);
462    let notify_on_cap = cfg.notify_on_cap;
463
464    // Update check before any output. Watch mode draws the notice inside the
465    // redrawn frame (an eprintln sits outside the in-place UI and scrolls away);
466    // one-shot has no frame, so it goes to stderr.
467    let update =
468        crate::update_check::check_sync(cfg.check_updates, cfg.dismissed_update.as_deref());
469
470    if !wants_watch(&args) {
471        if let Some(msg) = &update {
472            eprintln!("{msg}");
473        }
474        let report = build_at(now, account.as_ref(), account_error.as_deref());
475        if args.json {
476            println!("{}", render::render_json(&report));
477        } else {
478            print!(
479                "{}",
480                render::render_text(&report, &db_path, rows, colours, bar_width)
481            );
482        }
483        return;
484    }
485
486    // Live frame, cmduse's redraw contract: the frame's last line carries no
487    // newline, so the cursor parks there and the countdown can rewrite that one
488    // line each second instead of repainting the whole frame.
489    let ink = render::Ink::new(colours);
490    let mut out = std::io::stdout();
491    let mut prev_lines = 0usize;
492    let mut history: Vec<f64> = Vec::new();
493    let mut last_five: Option<f64> = None;
494    // Per-model cap-hit notifications: fire once on the rising edge into "over".
495    let mut over: HashSet<String> = HashSet::new();
496    let use_color = crate::render::color_enabled();
497    loop {
498        // Re-ask each tick, keeping the last answer through a blip so the frame
499        // cannot flip back and forth between the account's meters and the local
500        // estimate. A blip's *reason* is dropped with it, for the same reason: the
501        // rows on screen are still the account's, so nothing was unavailable.
502        match fetch_account() {
503            Ok(fresh) => {
504                account = Some(fresh);
505                account_error = None;
506            }
507            Err(why) => {
508                if account.is_none() {
509                    account_error = why.reason();
510                }
511            }
512        }
513        let report = build_at(window::now_ms(), account.as_ref(), account_error.as_deref());
514        let (term_rows, cols) = crate::term_size().unwrap_or((0, 0));
515        let cols_opt = (cols > 0).then_some(cols);
516        let compact = compact_frame(term_rows, cols);
517        // 5-hour spend only rises between refreshes, so the delta is the burst;
518        // a sparkline of the cumulative figure would just stay full.
519        let delta = last_five
520            .map(|prev| (report.five_hour.cost - prev).max(0.0))
521            .unwrap_or(0.0);
522        last_five = Some(report.five_hour.cost);
523        if burst_on {
524            history.push(delta);
525            if history.len() > burst_cap {
526                history.remove(0);
527            }
528        }
529        if notify_on_cap {
530            let mut over_now: HashSet<String> = HashSet::new();
531            // The account's own verdicts first: they are what the console shows,
532            // and they cover the windows no local per-model figure can.
533            if let Some(a) = &account {
534                for (key, label, meter) in [
535                    ("window:5h", "5-hour window", &a.rolling),
536                    ("window:wk", "weekly window", &a.weekly),
537                    ("window:mo", "monthly limit", &a.monthly),
538                ] {
539                    if meter.exceeded() {
540                        over_now.insert(key.to_string());
541                        if !over.contains(key) {
542                            crate::notify::cap(
543                                "ocuse: limit exceeded",
544                                &format!("OpenCode Go {label} is over its limit"),
545                            );
546                        }
547                    }
548                }
549            }
550            for model in &report.models {
551                if let Some(window) = window::over_window(model) {
552                    over_now.insert(model.id.clone());
553                    if !over.contains(&model.id) {
554                        crate::notify::cap(
555                            "ocuse: limit exceeded",
556                            &format!("OpenCode Go {} is over its {window} limit", model.id),
557                        );
558                    }
559                }
560            }
561            over = over_now;
562        }
563        let burst_slice: &[f64] = if burst_on { &history } else { &[] };
564        let text = if compact {
565            render::compact_line(&report, burst_slice, colours)
566        } else {
567            render::watch_frame(
568                &report,
569                &db_path,
570                rows,
571                colours,
572                burst_slice,
573                interval,
574                bar_width,
575            )
576        };
577        let update_box = update
578            .as_ref()
579            .map(|m| crate::render::update_box(m, cols_opt, use_color && !args.plain));
580        let status = format!(
581            "{}refreshing every {interval}s · ctrl-c to quit{}",
582            ink.dim, ink.reset
583        );
584        let frame = match &update_box {
585            Some(u) => format!("{u}{text}\n{status}"),
586            None => format!("{text}\n{status}"),
587        };
588        prev_lines = crate::redraw_frame(&mut out, &frame, prev_lines, cols_opt);
589        let _ = out.flush();
590        for remaining in (1..interval).rev() {
591            std::thread::sleep(std::time::Duration::from_secs(1));
592            let msg = format!(
593                "\r\x1b[2K{}refreshing every {interval}s · next refresh in {remaining}s · ctrl-c to quit{}",
594                ink.dim, ink.reset
595            );
596            let _ = write!(out, "{}", crate::clip_to_width(&msg, cols_opt));
597            let _ = out.flush();
598        }
599    }
600}
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605
606    fn argv(args: &[&str]) -> Vec<OsString> {
607        std::iter::once(OsString::from("ocuse"))
608            .chain(args.iter().map(OsString::from))
609            .collect()
610    }
611
612    fn parse(args: &[&str]) -> cli::Args {
613        cli::parse_args_from(argv(args), Binary::Ocuse).expect("parses")
614    }
615
616    #[test]
617    fn json_and_one_shot_leave_the_watch_loop() {
618        assert!(wants_watch(&parse(&[]))); // default: live frame
619        assert!(wants_watch(&parse(&["-W"])));
620        assert!(!wants_watch(&parse(&["-1"]))); // -1
621        assert!(!wants_watch(&parse(&["--once"])));
622        assert!(!wants_watch(&parse(&["--json"]))); // bare --json too
623    }
624
625    #[test]
626    fn compact_frame_switches_on_small_terminals() {
627        assert!(!compact_frame(40, 120)); // roomy
628        assert!(compact_frame(10, 120)); // too few rows
629        assert!(compact_frame(40, 30)); // too few columns
630        assert!(!compact_frame(0, 0)); // unknown size: never compact
631        assert!(compact_frame(0, 20)); // cols known, tiny
632    }
633
634    #[test]
635    fn burst_state_derives_from_args_and_config() {
636        // config off, no CLI flag → off
637        assert_eq!(burst_config(None, false, 40), (false, 0));
638        // CLI flag turns it on for the run, using its own sample count
639        assert_eq!(burst_config(Some(30), false, 40), (true, 30));
640        // config on → on with the configured samples
641        assert_eq!(burst_config(None, true, 25), (true, 25));
642        // CLI value wins over config's
643        assert_eq!(burst_config(Some(60), true, 25), (true, 60));
644        // a hand-edited config below the floor is clamped, like cmduse's
645        assert_eq!(burst_config(None, true, 3), (true, 5));
646    }
647
648    #[test]
649    fn gated_is_a_shared_flag() {
650        // `--gated` filters `models` on both binaries, so it goes through the
651        // shared parser instead of being lifted out of argv first.
652        let a = cli::parse_args_from(argv(&["models", "--gated", "--plan", "go"]), Binary::Ocuse)
653            .expect("parses");
654        assert!(a.gated);
655        assert_eq!(a.subcmd, Some(SubCmd::Models));
656        assert_eq!(a.plan.as_deref(), Some("go"));
657        assert!(
658            !cli::parse_args_from(argv(&["models"]), Binary::Ocuse)
659                .expect("parses")
660                .gated
661        );
662    }
663
664    #[test]
665    fn no_api_is_an_ocuse_flag() {
666        // The account's meters are only reachable from ocuse, so the escape
667        // hatch belongs to it; cmduse's account API is its own source.
668        assert!(parse(&["-1", "--no-api"]).no_api);
669        assert!(!parse(&["-1"]).no_api);
670        assert!(
671            cli::parse_args_from(argv(&["--no-api"]), Binary::Cmduse).is_err(),
672            "cmduse rejects --no-api"
673        );
674    }
675
676    #[test]
677    fn output_fmt_maps_the_flags() {
678        let of = |args: &[&str]| output_fmt(&parse(args));
679        assert!(matches!(of(&[]), Fmt::Table));
680        assert!(matches!(of(&["--json"]), Fmt::Json));
681        assert!(matches!(of(&["--csv"]), Fmt::Csv));
682    }
683
684    #[test]
685    fn period_end_resolves_from_the_reports_own_start() {
686        let since = window::days_from_civil(2026, 9, 15) * 86_400_000;
687        let report = window::Report {
688            period_label: "calendar month".into(),
689            now_ms: since,
690            since_ms: since,
691            period_end_ms: None,
692            month_inferred: false,
693            account: None,
694            account_error: None,
695            five_hour: Default::default(),
696            weekly: Default::default(),
697            month: Default::default(),
698            models: Vec::new(),
699        };
700        // Month / --period-start: same day-of-month next month.
701        assert_eq!(
702            period_end(&report, "month", false),
703            Some(window::next_month(since))
704        );
705        // An <n>d window ends n days after it started.
706        assert_eq!(
707            period_end(&report, "30d", false),
708            Some(since + 30 * 86_400_000)
709        );
710        // All time has no boundary.
711        assert_eq!(period_end(&report, "all", false), None);
712        // Inference pins the start itself, so it outranks the window mode.
713        assert_eq!(
714            period_end(&report, "30d", true),
715            Some(window::next_month(since))
716        );
717    }
718
719    #[test]
720    fn model_id_is_pulled_out_before_parsing() {
721        let (rest, id) = split_model_id(argv(&["model", "glm-5.3-flash"]));
722        assert_eq!(id.as_deref(), Some("glm-5.3-flash"));
723        // the id is gone; the rest still parses as the shared parser expects
724        assert_eq!(
725            cli::parse_args_from(rest, Binary::Ocuse).unwrap().subcmd,
726            Some(SubCmd::Model)
727        );
728
729        // no id → the parser sees a bare `model`
730        let (_, id) = split_model_id(argv(&["model"]));
731        assert!(id.is_none());
732
733        // a flag value that happens to be a word is not mistaken for the id
734        let (rest, id) = split_model_id(argv(&["--plan", "go", "model", "glm-5.3-flash"]));
735        assert_eq!(id.as_deref(), Some("glm-5.3-flash"));
736        assert!(cli::parse_args_from(rest, Binary::Ocuse).is_ok());
737
738        // value flags between `model` and the id are skipped, not consumed
739        let (_, id) = split_model_id(argv(&["model", "--days", "7", "glm-5.3-flash"]));
740        assert_eq!(id.as_deref(), Some("glm-5.3-flash"));
741
742        // other commands pass through untouched
743        let (_, id) = split_model_id(argv(&["daily", "--days", "7"]));
744        assert!(id.is_none());
745    }
746
747    #[test]
748    fn ocuse_reaches_shared_flag_parity() {
749        // every shared flag cmduse's help prints is accepted by ocuse's parser
750        for args in [
751            vec!["-1"],
752            vec!["-W"],
753            vec!["-p"],
754            vec!["-i", "30"],
755            vec!["-w", "20"],
756            vec!["-b", "40"],
757            vec!["--json"],
758            vec!["--csv"],
759            vec!["--tz", "+05:30"],
760            vec!["--days", "7"],
761            vec!["--hours", "6"],
762            vec!["--since", "2026-01-01T00:00:00Z"],
763            vec!["--dismiss-update"],
764            vec!["--gated"],
765            vec!["config", "set", "interval=10"],
766        ] {
767            assert!(
768                cli::parse_args_from(argv(&args), Binary::Ocuse).is_ok(),
769                "ocuse must accept {args:?}"
770            );
771        }
772        // `--local` stays cmduse-only (ocuse is always local); `--gated` is a
773        // shared flag now, so it must parse for ocuse too.
774        assert!(cli::parse_args_from(argv(&["--local"]), Binary::Ocuse).is_err());
775        assert!(cli::parse_args_from(argv(&["--gated"]), Binary::Ocuse).is_ok());
776    }
777
778    #[test]
779    fn ocuse_parses_every_shared_subcommand() {
780        // The shared parser hands both binaries the same command surface,
781        // including `models` (implemented for ocuse now).
782        for (word, want) in [
783            ("daily", SubCmd::Daily),
784            ("days", SubCmd::Daily),
785            ("hourly", SubCmd::Hours),
786            ("hours", SubCmd::Hours),
787            ("model", SubCmd::Model),
788            ("session", SubCmd::Session),
789            ("sessions", SubCmd::Session),
790            ("project", SubCmd::Session),
791            ("statusline", SubCmd::Statusline),
792            ("models", SubCmd::Models),
793            ("plans", SubCmd::Plans),
794            ("mcp", SubCmd::Mcp),
795        ] {
796            for binary in [Binary::Cmduse, Binary::Ocuse] {
797                let a = cli::parse_args_from(
798                    std::iter::once(OsString::from("x"))
799                        .chain(std::iter::once(OsString::from(word))),
800                    binary,
801                )
802                .unwrap_or_else(|e| panic!("{binary:?} must parse {word}: {e}"));
803                assert_eq!(a.subcmd.as_ref(), Some(&want), "{binary:?} {word}");
804            }
805        }
806    }
807
808    #[test]
809    fn ocuse_help_carries_the_shared_tokens() {
810        let c = cli::usage_text(Binary::Cmduse);
811        let o = cli::usage_text(Binary::Ocuse);
812        // every shared flag token cmduse prints appears in ocuse's help too
813        for tok in [
814            "-1",
815            "--once",
816            "-W",
817            "--watch",
818            "-p",
819            "--plain",
820            "-h",
821            "--help",
822            "-V",
823            "--version",
824            "--json",
825            "--csv",
826            "-i",
827            "--interval",
828            "-w",
829            "--bar-width",
830            "-b",
831            "--bursts",
832            "--days",
833            "--since",
834            "--hours",
835            "--tz",
836            "--dismiss-update",
837            "NO_COLOR",
838            "config set",
839        ] {
840            if c.contains(tok) {
841                assert!(o.contains(tok), "ocuse help missing shared token {tok}");
842            }
843        }
844    }
845}