1use std::{ffi::OsString, path::PathBuf};
2
3use crate::arg_parser::ArgParser;
4use crate::help::{print_help_and_exit, print_version_and_exit};
5use csusage_cli::{
6 AgentCommandArgs, AgentReportKind, BlocksArgs, CliConfig, CodexSpeed, Command, CostMode,
7 CostSource, DATE_BOUND_FORMATS, DailyArgs, OPENCODE_AGENT_REPORTS, STANDARD_AGENT_REPORTS,
8 SessionArgs, SharedArgs, SortOrder, StatuslineArgs, VisualBurnRate, WeekDay, WeeklyArgs,
9 normalize_date_bound,
10};
11
12use crate::Cli;
13
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15enum ControlArg {
16 Help,
17 Version,
18}
19
20#[derive(Default)]
21struct RootAllOptions {
22 sections: Option<Vec<AgentReportKind>>,
23 by_agent: bool,
24 first_flag: Option<&'static str>,
25}
26
27impl RootAllOptions {
28 fn mark_used(&mut self, flag: &'static str) {
29 self.first_flag.get_or_insert(flag);
30 }
31
32 fn is_used(&self) -> bool {
33 self.first_flag.is_some()
34 }
35
36 fn first_flag(&self) -> &'static str {
37 self.first_flag.unwrap_or("--sections")
38 }
39
40 fn into_agent_args(self, shared: SharedArgs, kind: AgentReportKind) -> AgentCommandArgs {
41 AgentCommandArgs {
42 shared,
43 kind,
44 sections: self.sections,
45 by_agent: self.by_agent,
46 pi_path: None,
47 open_claw_path: None,
48 codex_speed: CodexSpeed::Auto,
49 }
50 }
51}
52
53impl Cli {
54 #[cfg(test)]
57 pub(crate) fn parse_from<I>(args: I) -> Result<Self, String>
58 where
59 I: IntoIterator<Item = OsString>,
60 {
61 Self::parse_from_with_config(args, &csusage_cli::NoConfig, 5.0, env!("CARGO_PKG_VERSION"))
62 }
63
64 pub fn parse_from_with_config<I>(
65 args: I,
66 config: &dyn CliConfig,
67 default_session_duration_hours: f64,
68 version: &'static str,
69 ) -> Result<Self, String>
70 where
71 I: IntoIterator<Item = OsString>,
72 {
73 let mut parser = ArgParser::new(args.into_iter().skip(1).collect())?;
74 normalize_legacy_agent_command_args(&mut parser.args);
75 match control_arg(&parser.args) {
76 Some(ControlArg::Version) => print_version_and_exit(version),
77 Some(ControlArg::Help) => print_help_and_exit(&parser.args),
78 None => {}
79 }
80 if let Some(message) = report_flag_alias_error(&parser.args) {
81 return Err(message);
82 }
83 if let Some(message) = agent_filter_option_error(&parser.args) {
84 return Err(message);
85 }
86 if let Some(message) = unsupported_agent_report_error(&parser.args) {
87 return Err(message);
88 }
89 if let Some(message) = config.config_error() {
90 return Err(message.to_string());
91 }
92 let mut shared = SharedArgs::with_defaults();
93 config.apply_shared(&mut shared);
94 let mut root_all_options = RootAllOptions::default();
95 while let Some(arg) = parser.peek() {
96 if is_command(arg) {
97 break;
98 }
99 if !arg.starts_with('-') {
100 return Err(format!("Unknown command '{arg}'"));
101 }
102 if parse_root_all_arg(&mut parser, &mut root_all_options)? {
103 continue;
104 }
105 parse_shared_arg(&mut parser, &mut shared)?;
106 }
107
108 let command = match parser.next() {
109 None if root_all_options.is_used() => Some(Command::All(
110 root_all_options.into_agent_args(shared.clone(), AgentReportKind::Daily),
111 )),
112 None => None,
113 Some(command) => Some(parse_command(
114 &command,
115 &mut parser,
116 shared.clone(),
117 config,
118 default_session_duration_hours,
119 root_all_options,
120 )?),
121 };
122 if let Some(extra) = parser.next() {
123 return Err(format!("Unexpected argument '{extra}'"));
124 }
125 if let Some(message) = last_option_error(command.as_ref(), &shared) {
126 return Err(message);
127 }
128 Ok(Self { command, shared })
129 }
130}
131
132fn control_arg(args: &[String]) -> Option<ControlArg> {
133 if args
134 .iter()
135 .any(|arg| matches!(arg.as_str(), "-v" | "-V" | "--version"))
136 {
137 return Some(ControlArg::Version);
138 }
139 if args
140 .iter()
141 .any(|arg| matches!(arg.as_str(), "-h" | "--help"))
142 {
143 return Some(ControlArg::Help);
144 }
145 None
146}
147
148fn parse_command(
149 command: &str,
150 parser: &mut ArgParser,
151 shared: SharedArgs,
152 config: &dyn CliConfig,
153 default_session_duration_hours: f64,
154 root_all_options: RootAllOptions,
155) -> Result<Command, String> {
156 if root_all_options.is_used() && !accepts_root_all_options(command) {
157 return Err(format!(
158 "Unknown option '{}'",
159 root_all_options.first_flag()
160 ));
161 }
162 match command {
163 "daily" => parse_all_command(
164 parser,
165 shared,
166 AgentReportKind::Daily,
167 config,
168 root_all_options,
169 ),
170 "monthly" => parse_all_command(
171 parser,
172 shared,
173 AgentReportKind::Monthly,
174 config,
175 root_all_options,
176 ),
177 "weekly" => parse_all_command(
178 parser,
179 shared,
180 AgentReportKind::Weekly,
181 config,
182 root_all_options,
183 ),
184 "session" => parse_top_level_session_command(parser, shared, config, root_all_options),
185 "blocks" => {
186 let mut args = BlocksArgs {
187 shared,
188 active: false,
189 recent: false,
190 token_limit: None,
191 session_length: default_session_duration_hours,
192 };
193 config.apply_blocks_args(&mut args);
194 while parser.peek().is_some() {
195 if parse_shared_arg_for_command(parser, &mut args.shared)? {
196 continue;
197 }
198 match parser.next_flag()?.as_str() {
199 "-a" | "--active" => args.active = true,
200 "-r" | "--recent" => args.recent = true,
201 "-t" | "--token-limit" => {
202 args.token_limit = Some(parser.value_for("--token-limit")?)
203 }
204 "-n" | "--session-length" => {
205 args.session_length = parser
206 .value_for("--session-length")?
207 .parse()
208 .map_err(|_| "Invalid value for --session-length".to_string())?
209 }
210 flag => return Err(format!("Unknown blocks option '{flag}'")),
211 }
212 }
213 Ok(Command::Blocks(args))
214 }
215 "statusline" => {
216 let mut args = StatuslineArgs::default();
217 config.apply_statusline_args(&mut args);
218 while parser.peek().is_some() {
219 match parser.next_flag()?.as_str() {
220 "-O" | "--offline" => args.offline = true,
221 "--no-offline" => args.no_offline = true,
222 "-B" | "--visual-burn-rate" => {
223 args.visual_burn_rate =
224 parse_visual_burn_rate(&parser.value_for("--visual-burn-rate")?)?
225 }
226 "--cost-source" => {
227 args.cost_source = parse_cost_source(&parser.value_for("--cost-source")?)?
228 }
229 "--cache" => args.cache = true,
230 "--no-cache" => args.no_cache = true,
231 "--refresh-interval" => {
232 args.refresh_interval = parser
233 .value_for("--refresh-interval")?
234 .parse()
235 .map_err(|_| "Invalid value for --refresh-interval".to_string())?
236 }
237 "--context-low-threshold" => {
238 args.context_low_threshold = parser
239 .value_for("--context-low-threshold")?
240 .parse()
241 .map_err(|_| "Invalid value for --context-low-threshold".to_string())?
242 }
243 "--context-medium-threshold" => {
244 args.context_medium_threshold = parser
245 .value_for("--context-medium-threshold")?
246 .parse()
247 .map_err(|_| {
248 "Invalid value for --context-medium-threshold".to_string()
249 })?
250 }
251 "-z" | "--timezone" => args.timezone = Some(parser.value_for("--timezone")?),
252 "--config" => args.config = Some(PathBuf::from(parser.value_for("--config")?)),
253 "--debug" => args.debug = true,
254 flag => return Err(format!("Unknown statusline option '{flag}'")),
255 }
256 }
257 Ok(Command::Statusline(args))
258 }
259 "claude" => parse_claude_command(parser, shared, config, default_session_duration_hours),
260 "codex" => parse_codex_command(parser, shared, config),
261 "opencode" => parse_basic_agent_command(
262 parser,
263 shared,
264 "opencode",
265 OPENCODE_AGENT_REPORTS,
266 Command::OpenCode,
267 ),
268 "amp" => {
269 parse_basic_agent_command(parser, shared, "amp", STANDARD_AGENT_REPORTS, Command::Amp)
270 }
271 "droid" => parse_basic_agent_command(
272 parser,
273 shared,
274 "droid",
275 STANDARD_AGENT_REPORTS,
276 Command::Droid,
277 ),
278 "codebuff" => parse_basic_agent_command(
279 parser,
280 shared,
281 "codebuff",
282 STANDARD_AGENT_REPORTS,
283 Command::Codebuff,
284 ),
285 "hermes" => parse_basic_agent_command(
286 parser,
287 shared,
288 "hermes",
289 STANDARD_AGENT_REPORTS,
290 Command::Hermes,
291 ),
292 "pi" => parse_pi_command(parser, shared, config),
293 "goose" => parse_basic_agent_command(
294 parser,
295 shared,
296 "goose",
297 STANDARD_AGENT_REPORTS,
298 Command::Goose,
299 ),
300 "kilo" => parse_basic_agent_command(
301 parser,
302 shared,
303 "kilo",
304 STANDARD_AGENT_REPORTS,
305 Command::Kilo,
306 ),
307 "copilot" => parse_basic_agent_command(
308 parser,
309 shared,
310 "copilot",
311 STANDARD_AGENT_REPORTS,
312 Command::Copilot,
313 ),
314 "gemini" => parse_basic_agent_command(
315 parser,
316 shared,
317 "gemini",
318 STANDARD_AGENT_REPORTS,
319 Command::Gemini,
320 ),
321 "antigravity" => parse_basic_agent_command(
322 parser,
323 shared,
324 "antigravity",
325 STANDARD_AGENT_REPORTS,
326 Command::Antigravity,
327 ),
328 "kimi" => parse_basic_agent_command(
329 parser,
330 shared,
331 "kimi",
332 STANDARD_AGENT_REPORTS,
333 Command::Kimi,
334 ),
335 "qwen" => parse_basic_agent_command(
336 parser,
337 shared,
338 "qwen",
339 STANDARD_AGENT_REPORTS,
340 Command::Qwen,
341 ),
342 "openclaw" => parse_openclaw_command(parser, shared, config),
343 "grok" => parse_basic_agent_command(
344 parser,
345 shared,
346 "grok",
347 STANDARD_AGENT_REPORTS,
348 Command::Grok,
349 ),
350 "zcode" => parse_basic_agent_command(
351 parser,
352 shared,
353 "zcode",
354 STANDARD_AGENT_REPORTS,
355 Command::ZCode,
356 ),
357 "claude-science" => parse_basic_agent_command(
358 parser,
359 shared,
360 "claude-science",
361 STANDARD_AGENT_REPORTS,
362 Command::ClaudeScience,
363 ),
364 _ => Err(format!("Unknown command '{command}'")),
365 }
366}
367
368fn accepts_root_all_options(command: &str) -> bool {
369 matches!(command, "daily" | "monthly" | "weekly" | "session")
370}
371
372fn parse_root_all_arg(
373 parser: &mut ArgParser,
374 options: &mut RootAllOptions,
375) -> Result<bool, String> {
376 if let Some(flag) =
377 parse_unified_report_arg(parser, &mut options.sections, &mut options.by_agent)?
378 {
379 options.mark_used(flag);
380 return Ok(true);
381 }
382 Ok(false)
383}
384
385fn parse_unified_report_arg(
386 parser: &mut ArgParser,
387 sections: &mut Option<Vec<AgentReportKind>>,
388 by_agent: &mut bool,
389) -> Result<Option<&'static str>, String> {
390 if matches!(parser.peek(), Some("--all")) {
391 parser.next();
392 return Ok(Some("--all"));
393 }
394 if matches!(parser.peek_name(), Some("--sections")) {
395 parser.next_flag()?;
396 *sections = Some(parse_report_sections(&parser.value_for("--sections")?)?);
397 return Ok(Some("--sections"));
398 }
399 if matches!(parser.peek(), Some("--by-agent")) {
400 parser.next();
401 *by_agent = true;
402 return Ok(Some("--by-agent"));
403 }
404 Ok(None)
405}
406
407fn parse_all_command(
408 parser: &mut ArgParser,
409 mut shared: SharedArgs,
410 kind: AgentReportKind,
411 _config: &dyn CliConfig,
412 initial_options: RootAllOptions,
413) -> Result<Command, String> {
414 let mut sections = initial_options.sections;
415 let mut by_agent = initial_options.by_agent;
416 while parser.peek().is_some() {
417 if parse_unified_report_arg(parser, &mut sections, &mut by_agent)?.is_some() {
418 continue;
419 }
420 parse_shared_arg(parser, &mut shared)?;
421 }
422 Ok(Command::All(AgentCommandArgs {
423 shared,
424 kind,
425 sections,
426 by_agent,
427 pi_path: None,
428 open_claw_path: None,
429 codex_speed: CodexSpeed::Auto,
430 }))
431}
432
433fn parse_top_level_session_command(
434 parser: &mut ArgParser,
435 shared: SharedArgs,
436 _config: &dyn CliConfig,
437 initial_options: RootAllOptions,
438) -> Result<Command, String> {
439 let mut args = SessionArgs { shared, id: None };
440 let mut sections = initial_options.sections;
441 let mut by_agent = initial_options.by_agent;
442 while parser.peek().is_some() {
443 if parse_unified_report_arg(parser, &mut sections, &mut by_agent)?.is_some() {
444 continue;
445 }
446 if parse_shared_arg_for_command(parser, &mut args.shared)? {
447 continue;
448 }
449 match parser.next_flag()?.as_str() {
450 "-i" | "--id" => args.id = Some(parser.value_for("--id")?),
451 flag => return Err(format!("Unknown session option '{flag}'")),
452 }
453 }
454
455 if args.id.is_some() {
456 if sections.is_some() || by_agent {
457 return Err(
458 "The --sections and --by-agent options cannot be used with session --id."
459 .to_string(),
460 );
461 }
462 return Ok(Command::Session(args));
463 }
464
465 Ok(Command::All(AgentCommandArgs {
466 shared: args.shared,
467 kind: AgentReportKind::Session,
468 sections,
469 by_agent,
470 pi_path: None,
471 open_claw_path: None,
472 codex_speed: CodexSpeed::Auto,
473 }))
474}
475
476fn parse_claude_daily_command(
477 parser: &mut ArgParser,
478 shared: SharedArgs,
479 config: &dyn CliConfig,
480) -> Result<Command, String> {
481 let mut args = DailyArgs {
482 shared,
483 instances: false,
484 project: None,
485 project_aliases: None,
486 };
487 config.apply_daily_args(&mut args);
488 while parser.peek().is_some() {
489 if parse_shared_arg_for_command(parser, &mut args.shared)? {
490 continue;
491 }
492 match parser.next_flag()?.as_str() {
493 "-i" | "--instances" => args.instances = true,
494 "-p" | "--project" => args.project = Some(parser.value_for("--project")?),
495 "--project-aliases" => {
496 args.project_aliases = Some(parser.value_for("--project-aliases")?)
497 }
498 flag => return Err(format!("Unknown daily option '{flag}'")),
499 }
500 }
501 Ok(Command::Daily(args))
502}
503
504fn parse_claude_monthly_command(
505 parser: &mut ArgParser,
506 mut shared: SharedArgs,
507 _config: &dyn CliConfig,
508) -> Result<Command, String> {
509 while parser.peek().is_some() {
510 parse_shared_arg(parser, &mut shared)?;
511 }
512 Ok(Command::Monthly(shared))
513}
514
515fn parse_claude_weekly_command(
516 parser: &mut ArgParser,
517 shared: SharedArgs,
518 config: &dyn CliConfig,
519) -> Result<Command, String> {
520 let mut args = WeeklyArgs {
521 shared,
522 start_of_week: WeekDay::Sunday,
523 };
524 config.apply_weekly_args(&mut args);
525 while parser.peek().is_some() {
526 if parse_shared_arg_for_command(parser, &mut args.shared)? {
527 continue;
528 }
529 match parser.next_flag()?.as_str() {
530 "-w" | "--start-of-week" => {
531 args.start_of_week = parse_week_day(&parser.value_for("--start-of-week")?)?
532 }
533 flag => return Err(format!("Unknown weekly option '{flag}'")),
534 }
535 }
536 Ok(Command::Weekly(args))
537}
538
539fn parse_claude_session_command(
540 parser: &mut ArgParser,
541 shared: SharedArgs,
542 _config: &dyn CliConfig,
543) -> Result<Command, String> {
544 let mut args = SessionArgs { shared, id: None };
545 while parser.peek().is_some() {
546 if parse_shared_arg_for_command(parser, &mut args.shared)? {
547 continue;
548 }
549 match parser.next_flag()?.as_str() {
550 "-i" | "--id" => args.id = Some(parser.value_for("--id")?),
551 flag => return Err(format!("Unknown session option '{flag}'")),
552 }
553 }
554 Ok(Command::Session(args))
555}
556
557fn parse_claude_command(
558 parser: &mut ArgParser,
559 shared: SharedArgs,
560 config: &dyn CliConfig,
561 default_session_duration_hours: f64,
562) -> Result<Command, String> {
563 let command = match parser.peek() {
564 Some(command @ ("daily" | "monthly" | "weekly" | "session" | "blocks" | "statusline")) => {
565 let command = command.to_string();
566 parser.next();
567 command
568 }
569 Some(command) if !command.starts_with('-') => {
570 return Err(format!("Unknown claude command '{command}'"));
571 }
572 _ => "daily".to_string(),
573 };
574 match command.as_str() {
575 "daily" => parse_claude_daily_command(parser, shared, config),
576 "monthly" => parse_claude_monthly_command(parser, shared, config),
577 "weekly" => parse_claude_weekly_command(parser, shared, config),
578 "session" => parse_claude_session_command(parser, shared, config),
579 "blocks" | "statusline" => parse_command(
580 &command,
581 parser,
582 shared,
583 config,
584 default_session_duration_hours,
585 RootAllOptions::default(),
586 ),
587 _ => unreachable!("claude command is prevalidated"),
588 }
589}
590
591fn parse_basic_agent_command(
592 parser: &mut ArgParser,
593 mut shared: SharedArgs,
594 agent: &str,
595 reports: &[(&str, AgentReportKind)],
596 command: fn(AgentCommandArgs) -> Command,
597) -> Result<Command, String> {
598 let kind = parse_agent_report_kind(parser, agent, reports)?;
599 while parser.peek().is_some() {
600 parse_shared_arg(parser, &mut shared)?;
601 }
602 Ok(command(agent_command_args(shared, kind)))
603}
604
605fn parse_codex_command(
606 parser: &mut ArgParser,
607 mut shared: SharedArgs,
608 config: &dyn CliConfig,
609) -> Result<Command, String> {
610 let kind = parse_agent_report_kind(parser, "codex", STANDARD_AGENT_REPORTS)?;
611 let mut codex_speed = CodexSpeed::Auto;
612 config.apply_agent_args(&mut codex_speed, None, None);
613 while parser.peek().is_some() {
614 if parse_shared_arg_for_command(parser, &mut shared)? {
615 continue;
616 }
617 match parser.next_flag()?.as_str() {
618 "--speed" => codex_speed = parse_codex_speed(&parser.value_for("--speed")?)?,
619 flag => return Err(format!("Unknown codex option '{flag}'")),
620 }
621 }
622 Ok(Command::Codex(AgentCommandArgs {
623 shared,
624 kind,
625 sections: None,
626 by_agent: false,
627 pi_path: None,
628 open_claw_path: None,
629 codex_speed,
630 }))
631}
632
633fn parse_pi_command(
634 parser: &mut ArgParser,
635 mut shared: SharedArgs,
636 config: &dyn CliConfig,
637) -> Result<Command, String> {
638 let kind = parse_agent_report_kind(parser, "pi", STANDARD_AGENT_REPORTS)?;
639 let mut pi_path = None;
640 let mut codex_speed = CodexSpeed::Auto;
641 config.apply_agent_args(&mut codex_speed, Some(&mut pi_path), None);
642 while parser.peek().is_some() {
643 if parse_shared_arg_for_command(parser, &mut shared)? {
644 continue;
645 }
646 match parser.next_flag()?.as_str() {
647 "--pi-path" => pi_path = Some(parser.value_for("--pi-path")?),
648 flag => return Err(format!("Unknown pi option '{flag}'")),
649 }
650 }
651 Ok(Command::Pi(AgentCommandArgs {
652 shared,
653 kind,
654 sections: None,
655 by_agent: false,
656 pi_path,
657 open_claw_path: None,
658 codex_speed,
659 }))
660}
661
662fn parse_openclaw_command(
663 parser: &mut ArgParser,
664 mut shared: SharedArgs,
665 config: &dyn CliConfig,
666) -> Result<Command, String> {
667 let kind = parse_agent_report_kind(parser, "openclaw", STANDARD_AGENT_REPORTS)?;
668 let mut open_claw_path = None;
669 let mut codex_speed = CodexSpeed::Auto;
670 config.apply_agent_args(&mut codex_speed, None, Some(&mut open_claw_path));
671 while parser.peek().is_some() {
672 if parse_shared_arg_for_command(parser, &mut shared)? {
673 continue;
674 }
675 match parser.next_flag()?.as_str() {
676 "--open-claw-path" => open_claw_path = Some(parser.value_for("--open-claw-path")?),
677 flag => return Err(format!("Unknown openclaw option '{flag}'")),
678 }
679 }
680 Ok(Command::OpenClaw(AgentCommandArgs {
681 shared,
682 kind,
683 sections: None,
684 by_agent: false,
685 pi_path: None,
686 open_claw_path,
687 codex_speed,
688 }))
689}
690
691fn parse_agent_report_kind(
692 parser: &mut ArgParser,
693 agent: &str,
694 reports: &[(&str, AgentReportKind)],
695) -> Result<AgentReportKind, String> {
696 let Some(command) = parser.peek() else {
697 return Ok(AgentReportKind::Daily);
698 };
699 if let Some((_, kind)) = reports.iter().find(|(report, _)| *report == command) {
700 parser.next();
701 return Ok(*kind);
702 }
703 if !command.starts_with('-') {
704 return Err(format!("Unknown {agent} command '{command}'"));
705 }
706 Ok(AgentReportKind::Daily)
707}
708
709fn agent_command_args(shared: SharedArgs, kind: AgentReportKind) -> AgentCommandArgs {
710 AgentCommandArgs {
711 shared,
712 kind,
713 sections: None,
714 by_agent: false,
715 pi_path: None,
716 open_claw_path: None,
717 codex_speed: CodexSpeed::Auto,
718 }
719}
720
721fn parse_shared_arg_for_command(
722 parser: &mut ArgParser,
723 shared: &mut SharedArgs,
724) -> Result<bool, String> {
725 let Some(arg) = parser.peek() else {
726 return Ok(false);
727 };
728 if is_shared_flag(arg) {
729 parse_shared_arg(parser, shared)?;
730 return Ok(true);
731 }
732 Ok(false)
733}
734
735fn parse_shared_arg(parser: &mut ArgParser, shared: &mut SharedArgs) -> Result<(), String> {
736 match parser.next_flag()?.as_str() {
737 "-s" | "--since" => {
738 shared.since = Some(parse_date_bound("--since", &parser.value_for("--since")?)?)
739 }
740 "-u" | "--until" => {
741 shared.until = Some(parse_date_bound("--until", &parser.value_for("--until")?)?)
742 }
743 "--last" => shared.last = Some(parse_last_periods(&parser.value_for("--last")?)?),
744 "-j" | "--json" => shared.json = true,
745 "-m" | "--mode" => shared.mode = parse_cost_mode(&parser.value_for("--mode")?)?,
746 "-d" | "--debug" => shared.debug = true,
747 "--debug-samples" => {
748 shared.debug_samples = parser
749 .value_for("--debug-samples")?
750 .parse()
751 .map_err(|_| "Invalid value for --debug-samples".to_string())?
752 }
753 "-o" | "--order" => shared.order = parse_sort_order(&parser.value_for("--order")?)?,
754 "-b" | "--breakdown" => shared.breakdown = true,
755 "-O" | "--offline" => shared.offline = true,
756 "--no-offline" => shared.no_offline = true,
757 "--color" => shared.color = true,
758 "--no-color" => shared.no_color = true,
759 "-z" | "--timezone" => shared.timezone = Some(parser.value_for("--timezone")?),
760 "-q" | "--jq" => shared.jq = Some(parser.value_for("--jq")?),
761 "--config" => shared.config = Some(PathBuf::from(parser.value_for("--config")?)),
762 "--compact" => shared.compact = true,
763 "--single-thread" => shared.single_thread = true,
764 "--no-cost" => shared.no_cost = true,
765 flag => return Err(format!("Unknown option '{flag}'")),
766 }
767 Ok(())
768}
769
770fn is_command(arg: &str) -> bool {
771 matches!(
772 arg,
773 "daily"
774 | "monthly"
775 | "weekly"
776 | "session"
777 | "blocks"
778 | "statusline"
779 | "claude"
780 | "codex"
781 | "opencode"
782 | "amp"
783 | "droid"
784 | "codebuff"
785 | "hermes"
786 | "pi"
787 | "goose"
788 | "openclaw"
789 | "kilo"
790 | "copilot"
791 | "gemini"
792 | "antigravity"
793 | "kimi"
794 | "qwen"
795 | "grok"
796 | "zcode"
797 | "claude-science"
798 )
799}
800
801fn normalize_legacy_agent_command_args(args: &mut Vec<String>) {
802 let Some(command) = args.first() else {
803 return;
804 };
805 let Some((agent, report)) = command.split_once(':') else {
806 return;
807 };
808 if !legacy_agent_report_supported(agent, report) {
809 return;
810 }
811 args.splice(0..1, [agent.to_string(), report.to_string()]);
812}
813
814fn legacy_agent_report_supported(agent: &str, report: &str) -> bool {
815 agent_report_supported(agent, report)
816}
817
818fn report_flag_alias_error(args: &[String]) -> Option<String> {
819 let flag = args.iter().find(|arg| {
820 matches!(
821 arg.as_str(),
822 "--daily" | "--weekly" | "--monthly" | "--session" | "--blocks" | "--statusline"
823 )
824 })?;
825 Some(format!(
826 "Report flags like {flag} are not supported. Use \"ccusage {}\" instead.",
827 flag.trim_start_matches("--")
828 ))
829}
830
831fn agent_filter_option_error(args: &[String]) -> Option<String> {
832 let allows_short_active = blocks_command_tokens(args);
833 let flag = args.iter().find_map(|arg| {
834 if arg == "--agent" || arg.starts_with("--agent=") {
835 return Some("--agent");
836 }
837 if (arg == "-a" && !allows_short_active) || arg.starts_with("-a=") {
838 return Some("-a");
839 }
840 None
841 })?;
842 Some(format!(
843 "Agent filters like {flag} are not supported. Use \"ccusage <agent> <report>\", for example \"ccusage codex daily\"."
844 ))
845}
846
847fn blocks_command_tokens(args: &[String]) -> bool {
848 let tokens = command_tokens(args);
849 matches!(
850 tokens.as_slice(),
851 [command, ..] if command == "blocks"
852 ) || matches!(
853 tokens.as_slice(),
854 [agent, command, ..] if agent == "claude" && command == "blocks"
855 )
856}
857
858fn unsupported_agent_report_error(args: &[String]) -> Option<String> {
859 let tokens = command_tokens(args);
860 let [agent, report, ..] = tokens.as_slice() else {
861 return None;
862 };
863 if !is_agent_command(agent) || agent_report_supported(agent, report) {
864 return None;
865 }
866
867 let display = agent_display_name(agent);
868 let message = if matches!(report.as_str(), "blocks" | "statusline") {
869 format!(
870 "The \"{report}\" report is only available for Claude Code usage.\nUse \"ccusage {agent} daily\" for {display} usage reports."
871 )
872 } else {
873 format!(
874 "The \"{report}\" report is not available for {display} usage.\nUse \"ccusage {agent} daily\" for {display} usage reports."
875 )
876 };
877 Some(message)
878}
879
880pub(crate) fn command_tokens(args: &[String]) -> Vec<String> {
881 let mut tokens = Vec::new();
882 let mut index = 0;
883 while let Some(arg) = args.get(index) {
884 if arg.starts_with('-') {
885 if option_takes_value(arg) && !arg.contains('=') {
886 index += 2;
887 } else {
888 index += 1;
889 }
890 continue;
891 }
892 tokens.push(arg.clone());
893 index += 1;
894 }
895 tokens
896}
897
898fn option_takes_value(arg: &str) -> bool {
899 matches!(
900 arg,
901 "-s" | "--since"
902 | "-u"
903 | "--until"
904 | "--last"
905 | "-m"
906 | "--mode"
907 | "--debug-samples"
908 | "-o"
909 | "--order"
910 | "-z"
911 | "--timezone"
912 | "-q"
913 | "--jq"
914 | "--config"
915 | "-p"
916 | "--project"
917 | "--project-aliases"
918 | "-w"
919 | "--start-of-week"
920 | "-i"
921 | "--id"
922 | "-t"
923 | "--token-limit"
924 | "-n"
925 | "--session-length"
926 | "-B"
927 | "--visual-burn-rate"
928 | "--cost-source"
929 | "--refresh-interval"
930 | "--context-low-threshold"
931 | "--context-medium-threshold"
932 | "--speed"
933 | "--pi-path"
934 | "--open-claw-path"
935 | "--sections"
936 )
937}
938
939fn is_agent_command(command: &str) -> bool {
940 matches!(
941 command,
942 "claude"
943 | "codex"
944 | "opencode"
945 | "amp"
946 | "droid"
947 | "codebuff"
948 | "hermes"
949 | "pi"
950 | "goose"
951 | "kilo"
952 | "copilot"
953 | "gemini"
954 | "antigravity"
955 | "kimi"
956 | "qwen"
957 | "openclaw"
958 | "grok"
959 | "zcode"
960 | "claude-science"
961 )
962}
963
964fn agent_report_supported(agent: &str, report: &str) -> bool {
965 match agent {
966 "claude" => matches!(
967 report,
968 "daily" | "weekly" | "monthly" | "session" | "blocks" | "statusline"
969 ),
970 "codex" => matches!(report, "daily" | "monthly" | "session"),
971 "opencode" => matches!(report, "daily" | "weekly" | "monthly" | "session"),
972 "amp" | "droid" | "codebuff" | "hermes" | "pi" | "goose" | "kilo" | "copilot"
973 | "gemini" | "antigravity" | "kimi" | "qwen" | "openclaw" | "grok" | "zcode"
974 | "claude-science" => {
975 matches!(report, "daily" | "monthly" | "session")
976 }
977 _ => false,
978 }
979}
980
981fn agent_display_name(agent: &str) -> &'static str {
982 match agent {
983 "claude" => "Claude Code",
984 "codex" => "Codex",
985 "opencode" => "OpenCode",
986 "amp" => "Amp",
987 "droid" => "Droid",
988 "codebuff" => "Codebuff",
989 "hermes" => "Hermes",
990 "pi" => "pi-agent",
991 "goose" => "Goose",
992 "kilo" => "Kilo",
993 "copilot" => "GitHub Copilot CLI",
994 "gemini" => "Gemini CLI",
995 "antigravity" => "Antigravity",
996 "kimi" => "Kimi",
997 "qwen" => "Qwen",
998 "openclaw" => "OpenClaw",
999 "grok" => "Grok",
1000 "zcode" => "ZCode",
1001 "claude-science" => "Claude Science",
1002 _ => unreachable!("agent is prevalidated"),
1003 }
1004}
1005
1006fn is_shared_flag(arg: &str) -> bool {
1007 matches!(
1008 arg.split_once('=').map_or(arg, |(name, _)| name),
1009 "-s" | "--since"
1010 | "-u"
1011 | "--until"
1012 | "--last"
1013 | "-j"
1014 | "--json"
1015 | "-m"
1016 | "--mode"
1017 | "-d"
1018 | "--debug"
1019 | "--debug-samples"
1020 | "-o"
1021 | "--order"
1022 | "-b"
1023 | "--breakdown"
1024 | "-O"
1025 | "--offline"
1026 | "--no-offline"
1027 | "--color"
1028 | "--no-color"
1029 | "-z"
1030 | "--timezone"
1031 | "-q"
1032 | "--jq"
1033 | "--config"
1034 | "--compact"
1035 | "--single-thread"
1036 | "--no-cost"
1037 )
1038}
1039
1040fn parse_date_bound(flag: &str, value: &str) -> Result<String, String> {
1041 normalize_date_bound(value).ok_or_else(|| {
1042 format!("Invalid value for {flag} '{value}'. Expected {DATE_BOUND_FORMATS}.")
1043 })
1044}
1045
1046fn parse_last_periods(value: &str) -> Result<u32, String> {
1047 match value.parse() {
1048 Ok(0) | Err(_) => Err(format!(
1049 "Invalid value for --last '{value}'. Expected a whole number of periods, 1 or greater."
1050 )),
1051 Ok(periods) => Ok(periods),
1052 }
1053}
1054
1055fn last_option_error(command: Option<&Command>, root_shared: &SharedArgs) -> Option<String> {
1058 let (shared, supported) = match command {
1059 None => (root_shared, true),
1060 Some(Command::All(args)) => (&args.shared, args.kind != AgentReportKind::Session),
1061 Some(Command::Daily(args)) => (&args.shared, true),
1062 Some(Command::Monthly(shared)) => (shared, true),
1063 Some(Command::Weekly(args)) => (&args.shared, true),
1064 Some(Command::Session(args)) => (&args.shared, false),
1065 Some(Command::Blocks(args)) => (&args.shared, false),
1066 Some(Command::Statusline(_)) => (root_shared, false),
1067 Some(
1068 Command::Codex(args)
1069 | Command::OpenCode(args)
1070 | Command::Amp(args)
1071 | Command::Droid(args)
1072 | Command::Codebuff(args)
1073 | Command::Hermes(args)
1074 | Command::Pi(args)
1075 | Command::Goose(args)
1076 | Command::Kilo(args)
1077 | Command::Copilot(args)
1078 | Command::Gemini(args)
1079 | Command::Antigravity(args)
1080 | Command::Kimi(args)
1081 | Command::Qwen(args)
1082 | Command::OpenClaw(args)
1083 | Command::Grok(args)
1084 | Command::ZCode(args)
1085 | Command::ClaudeScience(args),
1086 ) => (&args.shared, args.kind != AgentReportKind::Session),
1087 };
1088 shared.last?;
1089 if !supported {
1090 return Some(
1091 "The --last option is only available for the daily, weekly, and monthly reports."
1092 .to_string(),
1093 );
1094 }
1095 if shared.since.is_some() || shared.until.is_some() {
1096 return Some("The --last option cannot be combined with --since or --until.".to_string());
1097 }
1098 if matches!(command, Some(Command::All(args)) if args.sections.is_some()) {
1099 return Some("The --last option cannot be used with --sections.".to_string());
1100 }
1101 None
1102}
1103
1104fn parse_cost_mode(value: &str) -> Result<CostMode, String> {
1105 match value {
1106 "auto" => Ok(CostMode::Auto),
1107 "calculate" => Ok(CostMode::Calculate),
1108 "display" => Ok(CostMode::Display),
1109 _ => Err(format!("Invalid cost mode '{value}'")),
1110 }
1111}
1112
1113fn parse_sort_order(value: &str) -> Result<SortOrder, String> {
1114 match value {
1115 "asc" => Ok(SortOrder::Asc),
1116 "desc" => Ok(SortOrder::Desc),
1117 _ => Err(format!("Invalid sort order '{value}'")),
1118 }
1119}
1120
1121fn parse_report_sections(value: &str) -> Result<Vec<AgentReportKind>, String> {
1122 let mut sections = Vec::new();
1123 for token in value.split(',') {
1124 let token = token.trim();
1125 if token.is_empty() {
1126 continue;
1127 }
1128 let kind = match token {
1129 "daily" => AgentReportKind::Daily,
1130 "weekly" => AgentReportKind::Weekly,
1131 "monthly" => AgentReportKind::Monthly,
1132 "session" => AgentReportKind::Session,
1133 _ => {
1134 return Err(format!(
1135 "Invalid --sections value '{token}'. Expected one or more of: daily, weekly, monthly, session."
1136 ));
1137 }
1138 };
1139 if !sections.contains(&kind) {
1140 sections.push(kind);
1141 }
1142 }
1143 if sections.is_empty() {
1144 return Err(format!(
1145 "Invalid --sections value '{value}'. Expected one or more of: daily, weekly, monthly, session."
1146 ));
1147 }
1148 Ok(sections)
1149}
1150
1151fn parse_week_day(value: &str) -> Result<WeekDay, String> {
1152 match value {
1153 "sunday" => Ok(WeekDay::Sunday),
1154 "monday" => Ok(WeekDay::Monday),
1155 "tuesday" => Ok(WeekDay::Tuesday),
1156 "wednesday" => Ok(WeekDay::Wednesday),
1157 "thursday" => Ok(WeekDay::Thursday),
1158 "friday" => Ok(WeekDay::Friday),
1159 "saturday" => Ok(WeekDay::Saturday),
1160 _ => Err(format!("Invalid week day '{value}'")),
1161 }
1162}
1163
1164fn parse_codex_speed(value: &str) -> Result<CodexSpeed, String> {
1165 match value {
1166 "auto" => Ok(CodexSpeed::Auto),
1167 "standard" => Ok(CodexSpeed::Standard),
1168 "fast" => Ok(CodexSpeed::Fast),
1169 _ => Err(format!("Invalid speed option '{value}'")),
1170 }
1171}
1172
1173fn parse_visual_burn_rate(value: &str) -> Result<VisualBurnRate, String> {
1174 match value {
1175 "off" => Ok(VisualBurnRate::Off),
1176 "emoji" => Ok(VisualBurnRate::Emoji),
1177 "text" => Ok(VisualBurnRate::Text),
1178 "emoji-text" => Ok(VisualBurnRate::EmojiText),
1179 _ => Err(format!("Invalid visual burn rate '{value}'")),
1180 }
1181}
1182
1183fn parse_cost_source(value: &str) -> Result<CostSource, String> {
1184 match value {
1185 "auto" => Ok(CostSource::Auto),
1186 "ccusage" => Ok(CostSource::Ccusage),
1187 "cc" => Ok(CostSource::Cc),
1188 "both" => Ok(CostSource::Both),
1189 _ => Err(format!("Invalid cost source '{value}'")),
1190 }
1191}
1192
1193#[cfg(test)]
1194mod tests {
1195 use super::*;
1196
1197 fn args(values: &[&str]) -> Vec<String> {
1198 values.iter().map(|value| value.to_string()).collect()
1199 }
1200
1201 #[test]
1202 fn detects_help_before_semantic_validation() {
1203 assert_eq!(
1204 control_arg(&args(&["--help", "--daily"])),
1205 Some(ControlArg::Help)
1206 );
1207 }
1208
1209 #[test]
1210 fn version_takes_precedence_over_help() {
1211 assert_eq!(
1212 control_arg(&args(&["--help", "--version"])),
1213 Some(ControlArg::Version)
1214 );
1215 }
1216}