Skip to main content

csusage_cli_parser/
parser.rs

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