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