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