1use crate::cli::{self, Binary, SubCmd};
16use crate::ocuse::window;
17use crate::ocuse::{build, db, mcp, render, zen};
18use crate::report_render::Fmt;
19use std::collections::HashSet;
20use std::ffi::OsString;
21use std::io::Write;
22
23fn parse_day(value: &str) -> Option<i64> {
26 let mut parts = value.split('-');
27 let y: i64 = parts.next()?.parse().ok()?;
28 let m: i64 = parts.next()?.parse().ok()?;
29 let d: i64 = parts.next()?.parse().ok()?;
30 (1..=12).contains(&m).then_some(())?;
31 (1..=31).contains(&d).then_some(())?;
32 Some(window::days_from_civil(y, m, d) * 86_400_000)
33}
34
35const VALUE_FLAGS: &[&str] = &[
38 "-i",
39 "--interval",
40 "-w",
41 "--bar-width",
42 "-b",
43 "--bursts",
44 "--days",
45 "--last",
46 "-l",
47 "--since",
48 "--hours",
49 "--tz",
50 "--db",
51 "--plan",
52 "--window",
53 "--period-start",
54 "--rows",
55];
56
57fn split_model_id(argv: Vec<OsString>) -> (Vec<OsString>, Option<String>) {
62 let strs: Vec<String> = argv
63 .iter()
64 .map(|a| a.to_string_lossy().into_owned())
65 .collect();
66 let mut drop_at = None;
67 let mut skip_value = false;
68 let mut after_model = false;
69 for (i, tok) in strs.iter().enumerate() {
70 if skip_value {
71 skip_value = false;
72 continue;
73 }
74 if tok.starts_with('-') {
75 if VALUE_FLAGS.contains(&tok.as_str()) {
78 skip_value = true;
79 }
80 continue;
81 }
82 if after_model {
83 drop_at = Some(i);
84 break;
85 }
86 after_model = tok == "model";
87 }
88 let id = drop_at.map(|i| strs[i].clone());
89 let argv = argv
90 .into_iter()
91 .enumerate()
92 .filter(|(i, _)| Some(*i) != drop_at)
93 .map(|(_, a)| a)
94 .collect();
95 (argv, id)
96}
97
98fn wants_watch(args: &cli::Args) -> bool {
102 !args.once && !args.json
103}
104
105fn compact_frame(rows: usize, cols: usize) -> bool {
107 cols > 0 && (rows < 16 || cols < 40)
108}
109
110fn burst_config(bursts: Option<usize>, enabled: bool, samples: usize) -> (bool, usize) {
114 let on = bursts.is_some() || enabled;
115 let cap = if on {
116 bursts.or(Some(samples)).unwrap_or(40).clamp(5, 240)
117 } else {
118 0
119 };
120 (on, cap)
121}
122
123fn output_fmt(args: &cli::Args) -> Fmt {
125 if args.json {
126 Fmt::Json
127 } else if args.csv {
128 Fmt::Csv
129 } else {
130 Fmt::Table
131 }
132}
133
134fn period_end(report: &window::Report, window_mode: &str, inferred: bool) -> Option<i64> {
141 if inferred {
143 return Some(window::next_month(report.since_ms));
144 }
145 if window_mode == "all" {
146 None
147 } else if let Some(d) = window_mode
148 .strip_suffix('d')
149 .and_then(|v| v.parse::<i64>().ok())
150 {
151 Some(report.since_ms + d * 86_400_000)
152 } else {
153 Some(window::next_month(report.since_ms))
154 }
155}
156
157pub fn run() {
158 let (argv, model_id) = split_model_id(std::env::args_os().collect());
159 let args = match cli::parse_args_from(argv, Binary::Ocuse) {
160 Ok(a) => a,
161 Err(e) => {
162 eprintln!("{e}");
163 std::process::exit(2);
164 }
165 };
166 if args.help {
167 cli::usage(Binary::Ocuse);
168 return;
169 }
170 if let Some(cs) = args.config_set {
171 if let Err(e) = crate::config::set(Binary::Ocuse, &cs) {
172 eprintln!("error: {e}");
173 std::process::exit(1);
174 }
175 return;
176 }
177 if args.dismiss_update {
178 match crate::update_check::latest_newer() {
179 Ok(Some(v)) => match crate::config::set_dismissed(Binary::Ocuse, &v) {
180 Ok(()) => println!("dismissed update notice for {v}"),
181 Err(e) => {
182 eprintln!("error: {e}");
183 std::process::exit(1);
184 }
185 },
186 Ok(None) => println!("ocuse is up to date ({})", env!("CARGO_PKG_VERSION")),
187 Err(e) => {
188 eprintln!("error: {e}");
189 std::process::exit(1);
190 }
191 }
192 return;
193 }
194
195 let cfg = crate::config::load(Binary::Ocuse);
196 let colours = !args.plain && crate::render::color_enabled();
197 let fmt = output_fmt(&args);
198 let db_path = args.db.clone().unwrap_or_else(db::default_path);
199 let days: i64 = args.last.unwrap_or(120) as i64;
200 let hours: i64 = args.hours.unwrap_or(24) as i64;
201 let rows: usize = args.rows.unwrap_or(15);
202 let now = window::now_ms();
203 let tz_secs = args.tz.unwrap_or(0);
204
205 if let Some(SubCmd::Plans) = args.subcmd {
207 if args.json {
208 print!("{}", render::plans_json());
209 } else {
210 print!("{}", render::plans_text(colours));
211 }
212 return;
213 }
214
215 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 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 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 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 let period_inferred = period_start.is_none();
290
291 let read_since = |since_ms: i64| match db::read_rows(&db_path, since_ms) {
292 Ok(rows) => rows,
293 Err(error) => {
294 eprintln!("ocuse: {error}");
295 eprintln!("hint: --db <path>, or OPENCODE_DB; the store is created by opencode itself");
296 std::process::exit(1);
297 }
298 };
299 let report_window = since_override.unwrap_or(now - days * 86_400_000);
301 let build_at = |t: i64| {
302 let mut report = build(
303 &read_since(report_window),
304 &catalog,
305 &plan_id,
306 t,
307 period_start,
308 );
309 report.period_label = period_label.clone();
310 report.period_end_ms = period_end(&report, &window_mode, period_inferred);
311 report
312 };
313
314 match args.subcmd {
315 Some(SubCmd::Statusline) => {
316 let report = build_at(now);
317 if args.json {
318 println!("{}", render::statusline_json(&report, &plan_id));
319 } else {
320 print!(
321 "{}",
322 render::statusline_template(
323 &report,
324 &plan_id,
325 &cfg.statusline_template,
326 cfg.statusline_colors,
327 cfg.statusline_ascii,
328 cfg.bar_width.min(30),
329 )
330 );
331 }
332 return;
333 }
334 Some(SubCmd::Daily) => {
335 print!(
336 "{}",
337 render::bucket_text(
338 &read_since(now - days * 86_400_000),
339 now,
340 days * 86_400_000,
341 86_400_000,
342 tz_secs,
343 colours,
344 fmt,
345 )
346 );
347 return;
348 }
349 Some(SubCmd::Hours) => {
350 print!(
351 "{}",
352 render::bucket_text(
353 &read_since(now - hours * 3_600_000),
354 now,
355 hours * 3_600_000,
356 3_600_000,
357 tz_secs,
358 colours,
359 fmt,
360 )
361 );
362 return;
363 }
364 Some(SubCmd::Session) => {
365 print!(
366 "{}",
367 render::session_text(&read_since(report_window), 20, colours, fmt)
368 );
369 return;
370 }
371 Some(SubCmd::Mcp) => {
372 mcp::run(&db_path, days, tz_secs, false, &plan_id, period_start);
374 return;
375 }
376 Some(SubCmd::Model) => {
377 let report = build_at(now);
378 if fmt == Fmt::Table {
379 match &model_id {
380 Some(id) => print!("{}", render::model_text(&report, id, colours)),
381 None => print!(
382 "{}",
383 render::render_text(
384 &report,
385 &db_path,
386 usize::MAX,
387 colours,
388 args.bar_width.unwrap_or(cfg.bar_width).clamp(5, 200),
389 )
390 ),
391 }
392 } else {
393 print!(
394 "{}",
395 render::model_report(&report, model_id.as_deref(), fmt)
396 );
397 }
398 return;
399 }
400 Some(SubCmd::Plans) | Some(SubCmd::Models) => return,
402 None => {}
403 }
404
405 let interval = args.interval.unwrap_or(cfg.interval_secs).clamp(1, 86_400);
407 let bar_width = args.bar_width.unwrap_or(cfg.bar_width).clamp(5, 200);
408 let (burst_on, burst_cap) = burst_config(args.bursts, cfg.burst_enabled, cfg.burst_samples);
409 let notify_on_cap = cfg.notify_on_cap;
410
411 let update =
415 crate::update_check::check_sync(cfg.check_updates, cfg.dismissed_update.as_deref());
416
417 if !wants_watch(&args) {
418 if let Some(msg) = &update {
419 eprintln!("{msg}");
420 }
421 let report = build_at(now);
422 if args.json {
423 println!("{}", render::render_json(&report));
424 } else {
425 print!(
426 "{}",
427 render::render_text(&report, &db_path, rows, colours, bar_width)
428 );
429 }
430 return;
431 }
432
433 let ink = render::Ink::new(colours);
437 let mut out = std::io::stdout();
438 let mut prev_lines = 0usize;
439 let mut history: Vec<f64> = Vec::new();
440 let mut last_five: Option<f64> = None;
441 let mut over: HashSet<String> = HashSet::new();
443 let use_color = crate::render::color_enabled();
444 loop {
445 let report = build_at(window::now_ms());
446 let (term_rows, cols) = crate::term_size().unwrap_or((0, 0));
447 let cols_opt = (cols > 0).then_some(cols);
448 let compact = compact_frame(term_rows, cols);
449 let delta = last_five
452 .map(|prev| (report.five_hour.cost - prev).max(0.0))
453 .unwrap_or(0.0);
454 last_five = Some(report.five_hour.cost);
455 if burst_on {
456 history.push(delta);
457 if history.len() > burst_cap {
458 history.remove(0);
459 }
460 }
461 if notify_on_cap {
462 let mut over_now: HashSet<String> = HashSet::new();
463 for model in &report.models {
464 if let Some(window) = window::over_window(model) {
465 over_now.insert(model.id.clone());
466 if !over.contains(&model.id) {
467 crate::notify::cap(
468 "ocuse: limit exceeded",
469 &format!("OpenCode Go {} is over its {window} limit", model.id),
470 );
471 }
472 }
473 }
474 over = over_now;
475 }
476 let burst_slice: &[f64] = if burst_on { &history } else { &[] };
477 let text = if compact {
478 render::compact_line(&report, burst_slice, colours)
479 } else {
480 render::watch_frame(
481 &report,
482 &db_path,
483 rows,
484 colours,
485 burst_slice,
486 interval,
487 bar_width,
488 )
489 };
490 let update_box = update
491 .as_ref()
492 .map(|m| crate::render::update_box(m, cols_opt, use_color && !args.plain));
493 let status = format!(
494 "{}refreshing every {interval}s · ctrl-c to quit{}",
495 ink.dim, ink.reset
496 );
497 let frame = match &update_box {
498 Some(u) => format!("{u}{text}\n{status}"),
499 None => format!("{text}\n{status}"),
500 };
501 prev_lines = crate::redraw_frame(&mut out, &frame, prev_lines, cols_opt);
502 let _ = out.flush();
503 for remaining in (1..interval).rev() {
504 std::thread::sleep(std::time::Duration::from_secs(1));
505 let msg = format!(
506 "\r\x1b[2K{}refreshing every {interval}s · next refresh in {remaining}s · ctrl-c to quit{}",
507 ink.dim, ink.reset
508 );
509 let _ = write!(out, "{}", crate::clip_to_width(&msg, cols_opt));
510 let _ = out.flush();
511 }
512 }
513}
514
515#[cfg(test)]
516mod tests {
517 use super::*;
518
519 fn argv(args: &[&str]) -> Vec<OsString> {
520 std::iter::once(OsString::from("ocuse"))
521 .chain(args.iter().map(OsString::from))
522 .collect()
523 }
524
525 fn parse(args: &[&str]) -> cli::Args {
526 cli::parse_args_from(argv(args), Binary::Ocuse).expect("parses")
527 }
528
529 #[test]
530 fn json_and_one_shot_leave_the_watch_loop() {
531 assert!(wants_watch(&parse(&[]))); assert!(wants_watch(&parse(&["-W"])));
533 assert!(!wants_watch(&parse(&["-1"]))); assert!(!wants_watch(&parse(&["--once"])));
535 assert!(!wants_watch(&parse(&["--json"]))); }
537
538 #[test]
539 fn compact_frame_switches_on_small_terminals() {
540 assert!(!compact_frame(40, 120)); assert!(compact_frame(10, 120)); assert!(compact_frame(40, 30)); assert!(!compact_frame(0, 0)); assert!(compact_frame(0, 20)); }
546
547 #[test]
548 fn burst_state_derives_from_args_and_config() {
549 assert_eq!(burst_config(None, false, 40), (false, 0));
551 assert_eq!(burst_config(Some(30), false, 40), (true, 30));
553 assert_eq!(burst_config(None, true, 25), (true, 25));
555 assert_eq!(burst_config(Some(60), true, 25), (true, 60));
557 assert_eq!(burst_config(None, true, 3), (true, 5));
559 }
560
561 #[test]
562 fn gated_is_a_shared_flag() {
563 let a = cli::parse_args_from(argv(&["models", "--gated", "--plan", "go"]), Binary::Ocuse)
566 .expect("parses");
567 assert!(a.gated);
568 assert_eq!(a.subcmd, Some(SubCmd::Models));
569 assert_eq!(a.plan.as_deref(), Some("go"));
570 assert!(
571 !cli::parse_args_from(argv(&["models"]), Binary::Ocuse)
572 .expect("parses")
573 .gated
574 );
575 }
576
577 #[test]
578 fn output_fmt_maps_the_flags() {
579 let of = |args: &[&str]| output_fmt(&parse(args));
580 assert!(matches!(of(&[]), Fmt::Table));
581 assert!(matches!(of(&["--json"]), Fmt::Json));
582 assert!(matches!(of(&["--csv"]), Fmt::Csv));
583 }
584
585 #[test]
586 fn period_end_resolves_from_the_reports_own_start() {
587 let since = window::days_from_civil(2026, 9, 15) * 86_400_000;
588 let report = window::Report {
589 period_label: "calendar month".into(),
590 now_ms: since,
591 since_ms: since,
592 period_end_ms: None,
593 month_inferred: false,
594 five_hour: Default::default(),
595 weekly: Default::default(),
596 month: Default::default(),
597 models: Vec::new(),
598 };
599 assert_eq!(
601 period_end(&report, "month", false),
602 Some(window::next_month(since))
603 );
604 assert_eq!(
606 period_end(&report, "30d", false),
607 Some(since + 30 * 86_400_000)
608 );
609 assert_eq!(period_end(&report, "all", false), None);
611 assert_eq!(
613 period_end(&report, "30d", true),
614 Some(window::next_month(since))
615 );
616 }
617
618 #[test]
619 fn model_id_is_pulled_out_before_parsing() {
620 let (rest, id) = split_model_id(argv(&["model", "glm-5.3-flash"]));
621 assert_eq!(id.as_deref(), Some("glm-5.3-flash"));
622 assert_eq!(
624 cli::parse_args_from(rest, Binary::Ocuse).unwrap().subcmd,
625 Some(SubCmd::Model)
626 );
627
628 let (_, id) = split_model_id(argv(&["model"]));
630 assert!(id.is_none());
631
632 let (rest, id) = split_model_id(argv(&["--plan", "go", "model", "glm-5.3-flash"]));
634 assert_eq!(id.as_deref(), Some("glm-5.3-flash"));
635 assert!(cli::parse_args_from(rest, Binary::Ocuse).is_ok());
636
637 let (_, id) = split_model_id(argv(&["model", "--days", "7", "glm-5.3-flash"]));
639 assert_eq!(id.as_deref(), Some("glm-5.3-flash"));
640
641 let (_, id) = split_model_id(argv(&["daily", "--days", "7"]));
643 assert!(id.is_none());
644 }
645
646 #[test]
647 fn ocuse_reaches_shared_flag_parity() {
648 for args in [
650 vec!["-1"],
651 vec!["-W"],
652 vec!["-p"],
653 vec!["-i", "30"],
654 vec!["-w", "20"],
655 vec!["-b", "40"],
656 vec!["--json"],
657 vec!["--csv"],
658 vec!["--tz", "+05:30"],
659 vec!["--days", "7"],
660 vec!["--hours", "6"],
661 vec!["--since", "2026-01-01T00:00:00Z"],
662 vec!["--dismiss-update"],
663 vec!["--gated"],
664 vec!["config", "set", "interval=10"],
665 ] {
666 assert!(
667 cli::parse_args_from(argv(&args), Binary::Ocuse).is_ok(),
668 "ocuse must accept {args:?}"
669 );
670 }
671 assert!(cli::parse_args_from(argv(&["--local"]), Binary::Ocuse).is_err());
674 assert!(cli::parse_args_from(argv(&["--gated"]), Binary::Ocuse).is_ok());
675 }
676
677 #[test]
678 fn ocuse_parses_every_shared_subcommand() {
679 for (word, want) in [
682 ("daily", SubCmd::Daily),
683 ("days", SubCmd::Daily),
684 ("hourly", SubCmd::Hours),
685 ("hours", SubCmd::Hours),
686 ("model", SubCmd::Model),
687 ("session", SubCmd::Session),
688 ("sessions", SubCmd::Session),
689 ("project", SubCmd::Session),
690 ("statusline", SubCmd::Statusline),
691 ("models", SubCmd::Models),
692 ("plans", SubCmd::Plans),
693 ("mcp", SubCmd::Mcp),
694 ] {
695 for binary in [Binary::Cmduse, Binary::Ocuse] {
696 let a = cli::parse_args_from(
697 std::iter::once(OsString::from("x"))
698 .chain(std::iter::once(OsString::from(word))),
699 binary,
700 )
701 .unwrap_or_else(|e| panic!("{binary:?} must parse {word}: {e}"));
702 assert_eq!(a.subcmd.as_ref(), Some(&want), "{binary:?} {word}");
703 }
704 }
705 }
706
707 #[test]
708 fn ocuse_help_carries_the_shared_tokens() {
709 let c = cli::usage_text(Binary::Cmduse);
710 let o = cli::usage_text(Binary::Ocuse);
711 for tok in [
713 "-1",
714 "--once",
715 "-W",
716 "--watch",
717 "-p",
718 "--plain",
719 "-h",
720 "--help",
721 "-V",
722 "--version",
723 "--json",
724 "--csv",
725 "-i",
726 "--interval",
727 "-w",
728 "--bar-width",
729 "-b",
730 "--bursts",
731 "--days",
732 "--since",
733 "--hours",
734 "--tz",
735 "--dismiss-update",
736 "NO_COLOR",
737 "config set",
738 ] {
739 if c.contains(tok) {
740 assert!(o.contains(tok), "ocuse help missing shared token {tok}");
741 }
742 }
743 }
744}