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