1use 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#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct ParsedGlobalFlags {
13 pub output_format: Option<OutputFormat>,
15 pub pretty_mode: Option<PrettyMode>,
17 pub color_mode: Option<ColorMode>,
19 pub log_level: Option<LogLevel>,
21 pub quiet: bool,
23 pub config_path: Option<String>,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct ParsedIntent {
30 pub command_path: Vec<String>,
32 pub normalized_path: Vec<String>,
34 pub global_flags: ParsedGlobalFlags,
36}
37
38#[derive(Debug, thiserror::Error, PartialEq, Eq)]
40pub enum ParseError {
41 #[error("invalid format: {0}")]
43 InvalidFormat(String),
44 #[error("invalid color mode: {0}")]
46 InvalidColor(String),
47 #[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#[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 .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
378pub fn parse_intent(argv: &[String]) -> Result<ParsedIntent, ParseError> {
380 let Ok(raw_matches) = root_command().try_get_matches_from(argv) else {
381 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}