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