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