Skip to main content

bijux_cli/routing/
parser.rs

1//! Clap-based parser and normalized command intent model.
2
3use clap::{Arg, ArgAction, ArgMatches, Command};
4
5use super::catalog::normalize_command_path;
6use crate::contracts::{
7    known_bijux_tool_namespaces, ColorMode, LogLevel, OutputFormat, PrettyMode,
8};
9
10/// Parsed and normalized global options.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct ParsedGlobalFlags {
13    /// Optional output format override.
14    pub output_format: Option<OutputFormat>,
15    /// Optional pretty mode override.
16    pub pretty_mode: Option<PrettyMode>,
17    /// Optional color mode override.
18    pub color_mode: Option<ColorMode>,
19    /// Optional log-level override.
20    pub log_level: Option<LogLevel>,
21    /// Quiet mode.
22    pub quiet: bool,
23    /// Optional explicit config file path override.
24    pub config_path: Option<String>,
25}
26
27/// Intent model normalized from clap matches.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct ParsedIntent {
30    /// Original path extracted from command tokens.
31    pub command_path: Vec<String>,
32    /// Normalized path after alias rewriting.
33    pub normalized_path: Vec<String>,
34    /// Parsed global flags regardless of placement.
35    pub global_flags: ParsedGlobalFlags,
36}
37
38/// Errors emitted by parser normalization.
39#[derive(Debug, thiserror::Error, PartialEq, Eq)]
40pub enum ParseError {
41    /// Unknown format value.
42    #[error("invalid format: {0}")]
43    InvalidFormat(String),
44    /// Unknown color mode value.
45    #[error("invalid color mode: {0}")]
46    InvalidColor(String),
47    /// Unknown log level value.
48    #[error("invalid log level: {0}")]
49    InvalidLogLevel(String),
50}
51
52fn parse_output_format(raw: Option<&String>) -> Result<Option<OutputFormat>, ParseError> {
53    raw.map(|v| match v.as_str() {
54        "json" => Ok(OutputFormat::Json),
55        "yaml" => Ok(OutputFormat::Yaml),
56        "text" => Ok(OutputFormat::Text),
57        other => Err(ParseError::InvalidFormat(other.to_string())),
58    })
59    .transpose()
60}
61
62fn parse_color(raw: Option<&String>) -> Result<Option<ColorMode>, ParseError> {
63    raw.map(|v| match v.as_str() {
64        "auto" => Ok(ColorMode::Auto),
65        "always" => Ok(ColorMode::Always),
66        "never" => Ok(ColorMode::Never),
67        other => Err(ParseError::InvalidColor(other.to_string())),
68    })
69    .transpose()
70}
71
72fn parse_log_level(raw: Option<&String>) -> Result<Option<LogLevel>, ParseError> {
73    raw.map(|v| match v.as_str() {
74        "trace" => Ok(LogLevel::Trace),
75        "debug" => Ok(LogLevel::Debug),
76        "info" => Ok(LogLevel::Info),
77        "warning" => Ok(LogLevel::Warning),
78        "error" => Ok(LogLevel::Error),
79        "critical" => Ok(LogLevel::Critical),
80        other => Err(ParseError::InvalidLogLevel(other.to_string())),
81    })
82    .transpose()
83}
84
85fn is_global_flag_without_value(token: &str) -> bool {
86    matches!(token, "--quiet" | "-q" | "--pretty" | "--no-pretty" | "--json" | "--text")
87}
88
89fn is_global_flag_with_value(token: &str) -> bool {
90    matches!(token, "--format" | "-f" | "--log-level" | "--color" | "--config-path")
91}
92
93fn is_global_flag_with_equals(token: &str) -> bool {
94    token.starts_with("--format=")
95        || token.starts_with("--log-level=")
96        || token.starts_with("--color=")
97        || token.starts_with("--config-path=")
98}
99
100fn parse_argv_with_global_flags_front(argv: &[String]) -> Vec<String> {
101    if argv.is_empty() {
102        return Vec::new();
103    }
104
105    let mut globals = Vec::new();
106    let mut command_tail = Vec::new();
107    let mut idx = 1;
108
109    while idx < argv.len() {
110        let token = argv[idx].as_str();
111        if token == "--" {
112            command_tail.extend(argv.iter().skip(idx).cloned());
113            break;
114        }
115        if is_global_flag_without_value(token) || is_global_flag_with_equals(token) {
116            globals.push(argv[idx].clone());
117            idx += 1;
118            continue;
119        }
120        if is_global_flag_with_value(token) {
121            globals.push(argv[idx].clone());
122            if let Some(value) = argv.get(idx + 1) {
123                globals.push(value.clone());
124                idx += 2;
125            } else {
126                idx += 1;
127            }
128            continue;
129        }
130
131        command_tail.push(argv[idx].clone());
132        idx += 1;
133    }
134
135    let mut normalized = Vec::with_capacity(1 + globals.len() + command_tail.len());
136    normalized.push(argv[0].clone());
137    normalized.extend(globals);
138    normalized.extend(command_tail);
139    normalized
140}
141
142fn global_flags_from_matches(matches: &ArgMatches) -> Result<ParsedGlobalFlags, ParseError> {
143    let output_format = if matches.get_flag("json") {
144        Some(OutputFormat::Json)
145    } else if matches.get_flag("text") {
146        Some(OutputFormat::Text)
147    } else {
148        parse_output_format(matches.get_one::<String>("format"))?
149    };
150    let color_mode = parse_color(matches.get_one::<String>("color"))?;
151    let log_level = parse_log_level(matches.get_one::<String>("log-level"))?;
152
153    let pretty_mode = if matches.get_flag("pretty") {
154        Some(PrettyMode::Pretty)
155    } else if matches.get_flag("no-pretty") {
156        Some(PrettyMode::Compact)
157    } else {
158        None
159    };
160
161    Ok(ParsedGlobalFlags {
162        output_format,
163        pretty_mode,
164        color_mode,
165        log_level,
166        quiet: matches.get_flag("quiet"),
167        config_path: matches.get_one::<String>("config-path").cloned(),
168    })
169}
170
171/// Build the root clap command for `bijux`.
172#[must_use]
173#[allow(clippy::too_many_lines)]
174pub fn root_command() -> Command {
175    let format_arg = Arg::new("format")
176        .long("format")
177        .short('f')
178        .num_args(1)
179        .global(true)
180        .value_name("FORMAT")
181        .help("Output format: text, json, or yaml");
182
183    let quiet_arg = Arg::new("quiet")
184        .long("quiet")
185        .short('q')
186        .action(ArgAction::SetTrue)
187        .global(true)
188        .help("Suppress command output");
189
190    let log_level_arg = Arg::new("log-level")
191        .long("log-level")
192        .num_args(1)
193        .global(true)
194        .value_name("LEVEL")
195        .help("Log verbosity level");
196
197    let color_arg = Arg::new("color")
198        .long("color")
199        .num_args(1)
200        .global(true)
201        .value_name("MODE")
202        .help("ANSI color policy");
203
204    let pretty_arg = Arg::new("pretty")
205        .long("pretty")
206        .action(ArgAction::SetTrue)
207        .overrides_with("no-pretty")
208        .global(true)
209        .help("Pretty-print structured output");
210
211    let no_pretty_arg = Arg::new("no-pretty")
212        .long("no-pretty")
213        .action(ArgAction::SetTrue)
214        .overrides_with("pretty")
215        .global(true)
216        .help("Emit compact structured output");
217    let config_path_arg = Arg::new("config-path")
218        .long("config-path")
219        .num_args(1)
220        .global(true)
221        .value_name("PATH")
222        .help("Use explicit config file path");
223    let json_arg = Arg::new("json")
224        .long("json")
225        .action(ArgAction::SetTrue)
226        .overrides_with_all(["text", "format"])
227        .hide(true)
228        .global(true);
229    let text_arg = Arg::new("text")
230        .long("text")
231        .action(ArgAction::SetTrue)
232        .overrides_with_all(["json", "format"])
233        .hide(true)
234        .global(true);
235
236    let config_group = Command::new("config")
237        .subcommand_required(false)
238        .subcommand(Command::new("list"))
239        .subcommand(Command::new("get").arg(Arg::new("key").num_args(1)))
240        .subcommand(Command::new("set").arg(Arg::new("pair").num_args(1)))
241        .subcommand(Command::new("unset").arg(Arg::new("key").num_args(1)))
242        .subcommand(Command::new("clear"))
243        .subcommand(Command::new("reload"))
244        .subcommand(Command::new("export").arg(Arg::new("path").num_args(1)))
245        .subcommand(Command::new("load").arg(Arg::new("path").num_args(1)));
246
247    let plugins_group = Command::new("plugins")
248        .subcommand(Command::new("list"))
249        .subcommand(Command::new("info"))
250        .subcommand(Command::new("inspect").arg(Arg::new("plugin").num_args(1)))
251        .subcommand(Command::new("check").arg(Arg::new("plugin").num_args(1)))
252        .subcommand(Command::new("enable").arg(Arg::new("plugin").num_args(1)))
253        .subcommand(Command::new("disable").arg(Arg::new("plugin").num_args(1)))
254        .subcommand(
255            Command::new("install")
256                .arg(Arg::new("manifest").num_args(1))
257                .arg(
258                    Arg::new("source")
259                        .long("source")
260                        .num_args(1)
261                        .value_name("LABEL")
262                        .help("Override the displayed provenance label without changing local manifest resolution"),
263                )
264                .arg(
265                    Arg::new("trust")
266                        .long("trust")
267                        .num_args(1)
268                        .value_parser(["core", "verified", "community", "unknown"]),
269                ),
270        )
271        .subcommand(Command::new("uninstall").arg(Arg::new("namespace").num_args(1)))
272        .subcommand(
273            Command::new("scaffold")
274                .arg(Arg::new("kind").num_args(1).required(true))
275                .arg(Arg::new("namespace").num_args(1).required(true))
276                .arg(Arg::new("path").long("path").num_args(1))
277                .arg(Arg::new("force").long("force").action(ArgAction::SetTrue)),
278        )
279        .subcommand(Command::new("doctor"))
280        .subcommand(Command::new("reserved-names"))
281        .subcommand(Command::new("where"))
282        .subcommand(Command::new("explain").arg(Arg::new("plugin").num_args(1)))
283        .subcommand(Command::new("schema"));
284    let completion_group = Command::new("completion").arg(
285        Arg::new("shell")
286            .long("shell")
287            .num_args(1)
288            .value_name("SHELL")
289            .value_parser(["bash", "zsh", "fish", "pwsh"])
290            .help("Generate completion output for an explicit shell target"),
291    );
292
293    let cli_group = Command::new("cli")
294        .subcommand(Command::new("status"))
295        .subcommand(Command::new("paths"))
296        .subcommand(Command::new("doctor"))
297        .subcommand(Command::new("version"))
298        .subcommand(Command::new("repl"))
299        .subcommand(completion_group.clone())
300        .subcommand(Command::new("inspect").hide(true))
301        .subcommand(config_group.clone())
302        .subcommand(Command::new("self-test"))
303        .subcommand(plugins_group.clone());
304
305    Command::new("bijux")
306        .args([
307            format_arg,
308            quiet_arg,
309            log_level_arg,
310            color_arg,
311            pretty_arg,
312            no_pretty_arg,
313            config_path_arg,
314            json_arg,
315            text_arg,
316        ])
317        .subcommand_required(false)
318        .allow_external_subcommands(true)
319        .subcommand(cli_group)
320        // Legacy roots kept for alias normalization.
321        .subcommand(Command::new("status"))
322        .subcommand(Command::new("audit"))
323        .subcommand(Command::new("docs"))
324        .subcommand(Command::new("doctor"))
325        .subcommand(Command::new("version"))
326        .subcommand(
327            Command::new("install")
328                .arg(Arg::new("target").num_args(1))
329                .arg(Arg::new("dry-run").long("dry-run").action(ArgAction::SetTrue)),
330        )
331        .subcommand(config_group)
332        .subcommand(plugins_group)
333        .subcommand(Command::new("repl"))
334        .subcommand(completion_group)
335        .subcommand(Command::new("inspect").hide(true))
336        .subcommand(
337            Command::new("history")
338                .subcommand(
339                    Command::new("clear").arg(
340                        Arg::new("force")
341                            .long("force")
342                            .action(ArgAction::SetTrue)
343                            .help("Clear history even when existing state is malformed"),
344                    ),
345                )
346                .arg(
347                    Arg::new("limit")
348                        .long("limit")
349                        .short('l')
350                        .num_args(1)
351                        .value_parser(clap::value_parser!(usize)),
352                )
353                .arg(Arg::new("filter").long("filter").short('F').num_args(1))
354                .arg(Arg::new("sort").long("sort").num_args(1).value_parser(["timestamp"])),
355        )
356        .subcommand(
357            Command::new("memory")
358                .subcommand(Command::new("list"))
359                .subcommand(Command::new("get").arg(Arg::new("key").num_args(1)))
360                .subcommand(Command::new("set").arg(Arg::new("pair").num_args(1)))
361                .subcommand(Command::new("delete").arg(Arg::new("key").num_args(1)))
362                .subcommand(Command::new("clear")),
363        )
364}
365
366fn extract_path(matches: &ArgMatches) -> Vec<String> {
367    let mut out = Vec::<String>::new();
368    let mut curr = matches;
369
370    while let Some((name, next)) = curr.subcommand() {
371        out.push(name.to_string());
372        curr = next;
373    }
374
375    out
376}
377
378/// Parse argv and normalize global flags + command path.
379pub fn parse_intent(argv: &[String]) -> Result<ParsedIntent, ParseError> {
380    let Ok(raw_matches) = root_command().try_get_matches_from(argv) else {
381        // Keep parser deterministic for routing tests by returning empty intent on clap usage failures.
382        return Ok(ParsedIntent {
383            command_path: Vec::new(),
384            normalized_path: Vec::new(),
385            global_flags: ParsedGlobalFlags {
386                output_format: None,
387                pretty_mode: None,
388                color_mode: None,
389                log_level: None,
390                quiet: false,
391                config_path: None,
392            },
393        });
394    };
395
396    let command_path = extract_path(&raw_matches);
397    let normalize_external_globals = matches!(
398        command_path.as_slice(),
399        [a, ..] if known_bijux_tool_namespaces().contains(&a.as_str())
400    );
401
402    let global_flags = if normalize_external_globals {
403        let parse_argv = parse_argv_with_global_flags_front(argv);
404        let Ok(reparsed) = root_command().try_get_matches_from(&parse_argv) else {
405            return Ok(ParsedIntent {
406                command_path: Vec::new(),
407                normalized_path: Vec::new(),
408                global_flags: ParsedGlobalFlags {
409                    output_format: None,
410                    pretty_mode: None,
411                    color_mode: None,
412                    log_level: None,
413                    quiet: false,
414                    config_path: None,
415                },
416            });
417        };
418        global_flags_from_matches(&reparsed)?
419    } else {
420        global_flags_from_matches(&raw_matches)?
421    };
422
423    let normalized_path = normalize_command_path(&command_path);
424
425    Ok(ParsedIntent { command_path, normalized_path, global_flags })
426}
427
428#[cfg(test)]
429mod tests {
430    use super::root_command;
431
432    #[test]
433    fn cli_help_lists_registered_subcommands() {
434        let argv = vec!["bijux".to_string(), "cli".to_string(), "--help".to_string()];
435        let help = match root_command().try_get_matches_from(argv) {
436            Err(error) if matches!(error.kind(), clap::error::ErrorKind::DisplayHelp) => {
437                error.to_string()
438            }
439            other => panic!("expected clap help output, got {other:?}"),
440        };
441
442        assert!(help.contains("Commands:"));
443        assert!(help.contains("status"));
444        assert!(help.contains("plugins"));
445    }
446}