1use std::collections::{HashMap, HashSet};
2
3use super::error::{Error, Result};
4use super::schema::{CLOption, CommandSchema};
5use super::types::FromArg;
6
7#[derive(Debug, Default)]
14pub struct ParseResult {
15 values: HashMap<String, Vec<String>>,
17 flags: HashSet<String>,
19 args: HashMap<String, String>,
21 short_to_canonical: HashMap<String, String>,
24 subcommand: Option<(String, Box<ParseResult>)>,
26}
27
28impl ParseResult {
29 fn canonical_key(&self, name: &str) -> String {
31 let stripped = name.trim_start_matches('-');
32 self.short_to_canonical
33 .get(stripped)
34 .cloned()
35 .unwrap_or_else(|| stripped.to_string())
36 }
37
38 pub fn flag(&self, name: &str) -> bool {
40 let key = self.canonical_key(name);
41 self.flags.contains(&key)
42 }
43
44 pub fn get<T: FromArg>(&self, name: &str) -> Result<Option<T>> {
50 let key = self.canonical_key(name);
51 if let Some(last) = self.values.get(&key).and_then(|v| v.last()) {
52 return T::from_arg(last)
53 .map(Some)
54 .map_err(|m| Error::invalid_value(name, last, m));
55 }
56 if name.starts_with('-') {
61 return Ok(None);
62 }
63 if let Some(raw) = self.args.get(&key) {
64 return T::from_arg(raw)
65 .map(Some)
66 .map_err(|m| Error::invalid_value(name, raw, m));
67 }
68 Ok(None)
69 }
70
71 pub fn require<T: FromArg>(&self, name: &str) -> Result<T> {
79 self.get::<T>(name)?.ok_or_else(|| {
80 if name.starts_with('-') {
81 Error::MissingOption(name.to_string())
82 } else {
83 Error::MissingArgument(name.to_string())
84 }
85 })
86 }
87
88 pub fn all<T: FromArg>(&self, name: &str) -> Result<Vec<T>> {
90 let key = self.canonical_key(name);
91 let Some(values) = self.values.get(&key) else {
92 return Ok(Vec::new());
93 };
94 values
95 .iter()
96 .map(|v| T::from_arg(v).map_err(|m| Error::invalid_value(name, v, m)))
97 .collect()
98 }
99
100 pub fn subcommand(&self) -> Option<(&str, &ParseResult)> {
102 self.subcommand
103 .as_ref()
104 .map(|(n, r)| (n.as_str(), r.as_ref()))
105 }
106
107 pub fn raw_value(&self, name: &str) -> Option<&str> {
112 let key = self.canonical_key(name);
113 if let Some(v) = self.values.get(&key).and_then(|v| v.last()) {
114 return Some(v.as_str());
115 }
116 if name.starts_with('-') {
117 return None;
118 }
119 self.args.get(&key).map(String::as_str)
120 }
121}
122
123pub struct OptionParser;
126
127impl OptionParser {
128 pub fn parse(schema: &CommandSchema, args: &[String]) -> Result<ParseResult> {
130 let mut result = ParseResult::default();
131 populate_short_map(&mut result.short_to_canonical, schema);
132
133 let mut i = 0;
134 let mut positional_idx = 0;
135 let mut dash_dash = false;
136
137 while i < args.len() {
138 let arg = &args[i];
139
140 if dash_dash {
141 consume_positional(&mut result, schema, &mut positional_idx, arg)?;
142 i += 1;
143 continue;
144 }
145
146 if arg == "--" {
147 dash_dash = true;
148 i += 1;
149 continue;
150 }
151
152 if arg == "-h" || arg == "--help" {
153 return Err(Error::HelpRequested);
154 }
155
156 if looks_like_option(arg) {
160 if let Some(rest) = arg.strip_prefix("--") {
161 let (name, inline) = split_eq(rest);
162 let opt = schema
163 .find_option_long(name)
164 .ok_or_else(|| Error::UnknownOption(arg.clone()))?;
165 i = consume_option(schema, opt, args, i, inline, &mut result)?;
166 continue;
167 }
168 let name = &arg[1..];
169 let opt = schema
170 .find_option_short(name)
171 .ok_or_else(|| Error::UnknownOption(arg.clone()))?;
172 i = consume_option(schema, opt, args, i, None, &mut result)?;
173 continue;
174 }
175
176 let next_positional = schema.arguments.get(positional_idx);
181 let next_is_required = next_positional.map(|a| a.required).unwrap_or(false);
182
183 if next_is_required {
184 consume_positional(&mut result, schema, &mut positional_idx, arg)?;
185 i += 1;
186 continue;
187 }
188
189 if let Some(sub) = schema.find_subcommand(arg) {
194 match OptionParser::parse(sub, &args[i + 1..]) {
195 Ok(sub_result) => {
196 result.subcommand = Some((sub.name.clone(), Box::new(sub_result)));
197 return finalize(result, schema);
198 }
199 Err(Error::InSubcommand { mut path, source }) => {
200 path.insert(0, sub.name.clone());
201 return Err(Error::InSubcommand { path, source });
202 }
203 Err(e) => {
204 return Err(Error::InSubcommand {
205 path: vec![sub.name.clone()],
206 source: Box::new(e),
207 });
208 }
209 }
210 }
211
212 if next_positional.is_some() {
213 consume_positional(&mut result, schema, &mut positional_idx, arg)?;
214 i += 1;
215 continue;
216 }
217
218 if !schema.subcommands.is_empty() {
219 return Err(Error::UnknownSubcommand {
220 name: arg.clone(),
221 available: schema.subcommands.iter().map(|s| s.name.clone()).collect(),
222 });
223 }
224
225 return Err(Error::ExtraArgument(arg.clone()));
226 }
227
228 finalize(result, schema)
229 }
230}
231
232fn populate_short_map(map: &mut HashMap<String, String>, schema: &CommandSchema) {
233 for opt in &schema.options {
234 if let (Some(short), Some(long)) = (&opt.short, &opt.long) {
235 let short = short.trim_start_matches('-').to_string();
236 let long = long.trim_start_matches('-').to_string();
237 map.insert(short, long);
238 }
239 }
240}
241
242fn split_eq(s: &str) -> (&str, Option<&str>) {
243 match s.find('=') {
244 Some(idx) => (&s[..idx], Some(&s[idx + 1..])),
245 None => (s, None),
246 }
247}
248
249fn looks_like_option(arg: &str) -> bool {
250 if !arg.starts_with('-') || arg.len() < 2 || arg == "--" {
251 return false;
252 }
253 if let Some(rest) = arg.strip_prefix("--") {
254 return rest
257 .chars()
258 .next()
259 .map(|c| c.is_ascii_alphabetic())
260 .unwrap_or(false);
261 }
262 arg.chars()
264 .nth(1)
265 .map(|c| c.is_ascii_alphabetic())
266 .unwrap_or(false)
267}
268
269fn consume_option(
270 schema: &CommandSchema,
271 opt: &CLOption,
272 args: &[String],
273 mut i: usize,
274 inline: Option<&str>,
275 result: &mut ParseResult,
276) -> Result<usize> {
277 let key = opt.canonical();
278 let token = &args[i];
279 if opt.takes_value {
280 let value = if let Some(v) = inline {
281 v.to_string()
282 } else {
283 i += 1;
284 let raw = args
285 .get(i)
286 .ok_or_else(|| Error::MissingValue(token.clone()))?;
287 if is_known_option_token(schema, raw) || raw == "-h" || raw == "--help" {
293 return Err(Error::MissingValue(token.clone()));
294 }
295 raw.clone()
296 };
297 result.values.entry(key).or_default().push(value);
298 } else {
299 if inline.is_some() {
300 return Err(Error::UnexpectedValue(token.clone()));
301 }
302 result.flags.insert(key);
303 }
304 Ok(i + 1)
305}
306
307fn is_known_option_token(schema: &CommandSchema, raw: &str) -> bool {
308 if !looks_like_option(raw) {
309 return false;
310 }
311 if let Some(rest) = raw.strip_prefix("--") {
312 let (name, _) = split_eq(rest);
313 return schema.find_option_long(name).is_some();
314 }
315 if let Some(rest) = raw.strip_prefix('-') {
316 return schema.find_option_short(rest).is_some();
317 }
318 false
319}
320
321fn consume_positional(
322 result: &mut ParseResult,
323 schema: &CommandSchema,
324 positional_idx: &mut usize,
325 value: &str,
326) -> Result<()> {
327 let arg_def = schema
328 .arguments
329 .get(*positional_idx)
330 .ok_or_else(|| Error::ExtraArgument(value.to_string()))?;
331 result.args.insert(arg_def.name.clone(), value.to_string());
332 *positional_idx += 1;
333 Ok(())
334}
335
336fn finalize(result: ParseResult, schema: &CommandSchema) -> Result<ParseResult> {
337 for arg in &schema.arguments {
342 if arg.required && !result.args.contains_key(&arg.name) {
343 return Err(Error::MissingArgument(arg.name.clone()));
344 }
345 }
346
347 if result.subcommand.is_some() {
348 return Ok(result);
349 }
350
351 if !schema.subcommands.is_empty() {
352 return Err(Error::MissingSubcommand {
353 available: schema.subcommands.iter().map(|s| s.name.clone()).collect(),
354 });
355 }
356
357 Ok(result)
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363 use runi_test::pretty_assertions::assert_eq;
364
365 fn args(items: &[&str]) -> Vec<String> {
366 items.iter().map(|s| s.to_string()).collect()
367 }
368
369 #[test]
370 fn parses_flag_and_value_option() {
371 let schema = CommandSchema::new("app", "")
372 .flag("-v,--verbose", "v")
373 .option("-n,--count", "n");
374 let r = OptionParser::parse(&schema, &args(&["-v", "--count", "3"])).unwrap();
375 assert!(r.flag("--verbose"));
376 assert!(r.flag("-v"));
377 assert_eq!(r.get::<u32>("--count").unwrap(), Some(3));
378 assert_eq!(r.get::<u32>("-n").unwrap(), Some(3));
379 }
380
381 #[test]
382 fn parses_equals_form() {
383 let schema = CommandSchema::new("app", "").option("--count", "");
384 let r = OptionParser::parse(&schema, &args(&["--count=7"])).unwrap();
385 assert_eq!(r.get::<u32>("--count").unwrap(), Some(7));
386 }
387
388 #[test]
389 fn required_argument_reported_when_missing() {
390 let schema = CommandSchema::new("app", "").argument("file", "input");
391 let err = OptionParser::parse(&schema, &args(&[])).unwrap_err();
392 assert!(matches!(err, Error::MissingArgument(ref n) if n == "file"));
393 }
394
395 #[test]
396 fn same_name_positional_does_not_satisfy_missing_option() {
397 let schema = CommandSchema::new("app", "")
401 .argument("config", "positional config")
402 .option("--config", "option config");
403 let r = OptionParser::parse(&schema, &args(&["prod.toml"])).unwrap();
404 assert_eq!(r.require::<String>("config").unwrap(), "prod.toml");
405 assert!(r.get::<String>("--config").unwrap().is_none());
406 }
407
408 #[test]
409 fn require_on_missing_option_reports_missing_option() {
410 let schema = CommandSchema::new("app", "").option("--num", "");
411 let r = OptionParser::parse(&schema, &args(&[])).unwrap();
412 let err = r.require::<u32>("--num").unwrap_err();
413 assert!(matches!(err, Error::MissingOption(ref n) if n == "--num"));
414 }
415
416 #[test]
417 fn require_on_missing_positional_reports_missing_argument() {
418 let schema = CommandSchema::new("app", "").optional_argument("file", "");
420 let r = OptionParser::parse(&schema, &args(&[])).unwrap();
421 let err = r.require::<String>("file").unwrap_err();
422 assert!(matches!(err, Error::MissingArgument(ref n) if n == "file"));
423 }
424
425 #[test]
426 fn optional_argument_absent_is_ok() {
427 let schema = CommandSchema::new("app", "").optional_argument("out", "output");
428 let r = OptionParser::parse(&schema, &args(&[])).unwrap();
429 assert!(r.get::<String>("out").unwrap().is_none());
430 }
431
432 #[test]
433 fn repeated_option_captures_all() {
434 let schema = CommandSchema::new("app", "").option("-f,--file", "file");
435 let r = OptionParser::parse(&schema, &args(&["-f", "a", "--file", "b"])).unwrap();
436 assert_eq!(
437 r.all::<String>("--file").unwrap(),
438 vec!["a".to_string(), "b".to_string()]
439 );
440 }
441
442 #[test]
443 fn dash_dash_treats_remainder_as_positional() {
444 let schema = CommandSchema::new("app", "")
445 .flag("-v,--verbose", "")
446 .argument("first", "")
447 .argument("second", "");
448 let r = OptionParser::parse(&schema, &args(&["-v", "--", "-x", "-y"])).unwrap();
449 assert!(r.flag("-v"));
450 assert_eq!(r.require::<String>("first").unwrap(), "-x");
451 assert_eq!(r.require::<String>("second").unwrap(), "-y");
452 }
453
454 #[test]
455 fn help_requested_returns_sentinel() {
456 let schema = CommandSchema::new("app", "");
457 let err = OptionParser::parse(&schema, &args(&["--help"])).unwrap_err();
458 assert!(matches!(err, Error::HelpRequested));
459 }
460
461 #[test]
462 fn subcommand_dispatch() {
463 let sub = CommandSchema::new("clone", "")
464 .argument("url", "")
465 .option("--depth", "");
466 let schema = CommandSchema::new("git", "")
467 .flag("-v,--verbose", "")
468 .subcommand(sub);
469 let r = OptionParser::parse(
470 &schema,
471 &args(&["-v", "clone", "--depth", "1", "https://x"]),
472 )
473 .unwrap();
474 assert!(r.flag("-v"));
475 let (name, sub_r) = r.subcommand().unwrap();
476 assert_eq!(name, "clone");
477 assert_eq!(sub_r.require::<u32>("--depth").unwrap(), 1);
478 assert_eq!(sub_r.require::<String>("url").unwrap(), "https://x");
479 }
480
481 #[test]
482 fn subcommand_error_carries_context() {
483 let sub = CommandSchema::new("clone", "").option("--depth", "");
484 let schema = CommandSchema::new("git", "").subcommand(sub);
485 let err = OptionParser::parse(&schema, &args(&["clone", "--bad"])).unwrap_err();
488 match err {
489 Error::InSubcommand { path, source } => {
490 assert_eq!(path, vec!["clone".to_string()]);
491 assert!(matches!(*source, Error::UnknownOption(_)));
492 }
493 other => panic!("unexpected: {other:?}"),
494 }
495 }
496
497 #[test]
498 fn subcommand_help_carries_context() {
499 let sub = CommandSchema::new("clone", "").option("--depth", "");
500 let schema = CommandSchema::new("git", "").subcommand(sub);
501 let err = OptionParser::parse(&schema, &args(&["clone", "--help"])).unwrap_err();
502 match err {
503 Error::InSubcommand { path, source } => {
504 assert_eq!(path, vec!["clone".to_string()]);
505 assert!(matches!(*source, Error::HelpRequested));
506 }
507 other => panic!("unexpected: {other:?}"),
508 }
509 }
510
511 #[test]
512 fn positional_consumed_before_subcommand() {
513 let sub = CommandSchema::new("run", "");
514 let schema = CommandSchema::new("app", "")
515 .argument("workspace", "workspace name")
516 .subcommand(sub);
517 let r = OptionParser::parse(&schema, &args(&["myws", "run"])).unwrap();
518 assert_eq!(r.require::<String>("workspace").unwrap(), "myws");
519 let (name, _) = r.subcommand().unwrap();
520 assert_eq!(name, "run");
521 }
522
523 #[test]
524 fn required_parent_positional_enforced_after_subcommand_dispatch() {
525 let sub = CommandSchema::new("run", "");
529 let schema = CommandSchema::new("app", "")
530 .optional_argument("out", "")
531 .argument("must", "")
532 .subcommand(sub);
533 let err = OptionParser::parse(&schema, &args(&["run"])).unwrap_err();
534 assert!(matches!(err, Error::MissingArgument(ref n) if n == "must"));
535 }
536
537 #[test]
538 fn subcommand_wins_over_optional_positional() {
539 let sub = CommandSchema::new("run", "");
540 let schema = CommandSchema::new("app", "")
541 .optional_argument("out", "output")
542 .subcommand(sub);
543 let r = OptionParser::parse(&schema, &args(&["run"])).unwrap();
544 assert!(r.get::<String>("out").unwrap().is_none());
545 let (name, _) = r.subcommand().unwrap();
546 assert_eq!(name, "run");
547 }
548
549 #[test]
550 fn optional_positional_consumed_when_not_a_subcommand_name() {
551 let sub = CommandSchema::new("run", "");
552 let schema = CommandSchema::new("app", "")
553 .optional_argument("out", "output")
554 .subcommand(sub);
555 let r = OptionParser::parse(&schema, &args(&["out.txt", "run"])).unwrap();
556 assert_eq!(r.get::<String>("out").unwrap().as_deref(), Some("out.txt"));
557 let (name, _) = r.subcommand().unwrap();
558 assert_eq!(name, "run");
559 }
560
561 #[test]
562 fn dash_prefixed_numeric_positional_parses() {
563 let schema = CommandSchema::new("app", "").argument("offset", "signed offset");
564 let r = OptionParser::parse(&schema, &args(&["-1"])).unwrap();
565 assert_eq!(r.require::<i32>("offset").unwrap(), -1);
566 }
567
568 #[test]
569 fn dash_prefixed_decimal_positional_parses() {
570 let schema = CommandSchema::new("app", "").argument("n", "number");
571 let r = OptionParser::parse(&schema, &args(&["-.5"])).unwrap();
572 assert!((r.require::<f64>("n").unwrap() + 0.5).abs() < 1e-9);
573 }
574
575 #[test]
576 fn dash_prefixed_word_still_parsed_as_option() {
577 let schema = CommandSchema::new("app", "").argument("x", "");
578 let err = OptionParser::parse(&schema, &args(&["--bad"])).unwrap_err();
579 assert!(matches!(err, Error::UnknownOption(_)));
580 }
581
582 #[test]
583 fn dash_dash_forces_positional_even_if_name_matches_subcommand() {
584 let sub = CommandSchema::new("run", "");
585 let schema = CommandSchema::new("app", "")
586 .optional_argument("out", "output")
587 .subcommand(sub);
588 let err = OptionParser::parse(&schema, &args(&["--", "run"])).unwrap_err();
591 assert!(matches!(err, Error::MissingSubcommand { .. }));
594 }
595
596 #[test]
597 fn missing_subcommand_reported() {
598 let schema = CommandSchema::new("git", "").subcommand(CommandSchema::new("init", ""));
599 let err = OptionParser::parse(&schema, &args(&[])).unwrap_err();
600 assert!(matches!(err, Error::MissingSubcommand { .. }));
601 }
602
603 #[test]
604 fn unknown_subcommand_reports_alternatives() {
605 let schema = CommandSchema::new("git", "").subcommand(CommandSchema::new("init", ""));
606 let err = OptionParser::parse(&schema, &args(&["clone"])).unwrap_err();
607 match err {
608 Error::UnknownSubcommand { name, available } => {
609 assert_eq!(name, "clone");
610 assert_eq!(available, vec!["init".to_string()]);
611 }
612 other => panic!("unexpected error: {other:?}"),
613 }
614 }
615
616 #[test]
617 fn unknown_option_rejected() {
618 let schema = CommandSchema::new("app", "");
619 let err = OptionParser::parse(&schema, &args(&["--nope"])).unwrap_err();
620 assert!(matches!(err, Error::UnknownOption(ref s) if s == "--nope"));
621 }
622
623 #[test]
624 fn option_followed_by_another_option_is_missing_value() {
625 let schema = CommandSchema::new("app", "")
626 .option("--output", "")
627 .flag("-v,--verbose", "");
628 let err = OptionParser::parse(&schema, &args(&["--output", "--verbose"])).unwrap_err();
629 assert!(matches!(err, Error::MissingValue(_)));
630 }
631
632 #[test]
633 fn option_accepts_negative_number_as_value() {
634 let schema = CommandSchema::new("app", "").option("--offset", "");
635 let r = OptionParser::parse(&schema, &args(&["--offset", "-1"])).unwrap();
636 assert_eq!(r.require::<i32>("--offset").unwrap(), -1);
637 }
638
639 #[test]
640 fn option_accepts_unknown_dash_prefixed_string_as_value() {
641 let schema = CommandSchema::new("app", "").option("--file", "");
644 let r = OptionParser::parse(&schema, &args(&["--file", "-draft.txt"])).unwrap();
645 assert_eq!(r.require::<String>("--file").unwrap(), "-draft.txt");
646 }
647
648 #[test]
649 fn option_rejects_help_as_value() {
650 let schema = CommandSchema::new("app", "").option("--file", "");
651 let err = OptionParser::parse(&schema, &args(&["--file", "--help"])).unwrap_err();
652 assert!(matches!(err, Error::MissingValue(_)));
653 }
654
655 #[test]
656 fn flag_with_inline_value_rejected() {
657 let schema = CommandSchema::new("app", "").flag("--verbose", "");
658 let err = OptionParser::parse(&schema, &args(&["--verbose=1"])).unwrap_err();
659 assert!(matches!(err, Error::UnexpectedValue(_)));
660 }
661
662 #[test]
663 fn extra_positional_rejected() {
664 let schema = CommandSchema::new("app", "").argument("file", "");
665 let err = OptionParser::parse(&schema, &args(&["a", "b"])).unwrap_err();
666 assert!(matches!(err, Error::ExtraArgument(ref s) if s == "b"));
667 }
668}