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