Skip to main content

runi_cli/launcher/
parser.rs

1use std::collections::{HashMap, HashSet};
2
3use super::error::{Error, Result};
4use super::schema::{CLOption, CommandSchema};
5use super::types::FromArg;
6
7/// Outcome of parsing a raw argv slice against a [`CommandSchema`].
8///
9/// Holds typed-but-unconverted values; callers pull values out via
10/// [`ParseResult::flag`], [`ParseResult::get`], etc. Conversion happens
11/// lazily at extraction time so a single result can be probed with
12/// different target types during tests without repeated parsing.
13#[derive(Debug, Default)]
14pub struct ParseResult {
15    /// Option values keyed by canonical name. Multi-use options append.
16    values: HashMap<String, Vec<String>>,
17    /// Flags that appeared on the command line, keyed by canonical name.
18    flags: HashSet<String>,
19    /// Positional arguments keyed by name.
20    args: HashMap<String, String>,
21    /// Short → canonical lookup, so callers can ask for `-v` or `--verbose`
22    /// interchangeably.
23    short_to_canonical: HashMap<String, String>,
24    /// Matched subcommand, if any.
25    subcommand: Option<(String, Box<ParseResult>)>,
26}
27
28impl ParseResult {
29    /// Look up the canonical key for any user-supplied option token.
30    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    /// Check whether a boolean flag was provided.
39    pub fn flag(&self, name: &str) -> bool {
40        let key = self.canonical_key(name);
41        self.flags.contains(&key)
42    }
43
44    /// Get the last value supplied for an option, converted via [`FromArg`].
45    /// Returns `Ok(None)` when the option is absent.
46    ///
47    /// Looks up positional arguments as a fallback so callers don't need two
48    /// code paths for "option or argument by name".
49    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        // Positional fallback only applies to non-option names. A schema
57        // that declares both `argument("config")` and `option("--config")`
58        // must not let a missing option be silently satisfied by the
59        // positional's value.
60        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    /// Like [`ParseResult::get`] but errors if the value is missing.
72    ///
73    /// The error variant depends on the name shape: dash-prefixed names
74    /// (e.g. `--num`, `-n`) become `MissingOption`, everything else becomes
75    /// `MissingArgument`. That way a command that marks an option as
76    /// required via `require::<T>("--num")` gets a diagnostic mentioning
77    /// the option, not a positional argument.
78    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    /// Get all values supplied for a repeatable option.
89    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    /// Return the matched subcommand name and its parse result, if any.
101    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    /// Raw access for advanced callers. Follows the same option/positional
108    /// split as [`ParseResult::get`]: dash-prefixed names only look at
109    /// option values; non-dash names look at options first, then
110    /// positionals.
111    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
123/// Hand-rolled tokenizer. Translates a flat argv slice into a [`ParseResult`]
124/// guided by the schema.
125pub struct OptionParser;
126
127impl OptionParser {
128    /// Parse `args` against `schema`, producing a [`ParseResult`] or an error.
129    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            // Tokens like `-1`, `-.5`, or `-/path` are values, not options.
157            // A dash-prefixed token is only treated as an option when it
158            // starts with a letter (short `-x`) or a word (long `--name`).
159            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            // Bind required positionals before considering subcommand
177            // dispatch — `app <workspace> <sub>`-style schemas need the
178            // workspace to bind first even when the workspace value happens
179            // to match a subcommand name.
180            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            // For optional positionals, a token that matches a known
190            // subcommand dispatches first; otherwise it fills the optional
191            // slot. Users can force a subcommand-named string into the
192            // positional slot with `--`.
193            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        // Long option: --<word>. Leading digit means it's a value like `--1`
255        // (unusual), not an option.
256        return rest
257            .chars()
258            .next()
259            .map(|c| c.is_ascii_alphabetic())
260            .unwrap_or(false);
261    }
262    // Short option: -<letter>. Digit / dot / slash → value.
263    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            // Reject `--output --verbose` when `--verbose` is a *known*
288            // option on this schema — that's almost certainly a typo and
289            // silently consuming the flag would hide the real intent.
290            // Arbitrary dash-prefixed strings (values like `-draft.txt`
291            // or negative numbers like `-1`) still bind as values.
292            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    // Parent positionals belong to the parent and must be satisfied even
338    // when a subcommand took over — a schema like
339    // `[optional, required, <subcommand>]` otherwise lets the subcommand
340    // dispatch over the optional before the required positional was bound.
341    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        // A schema with both a positional and an option of the same
398        // canonical name must keep the two lookups independent — a missing
399        // option should not be answered by the positional's value.
400        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        // Mirror of the above: positional uses MissingArgument.
419        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        // Unknown option inside the subcommand should surface with path info so
486        // the launcher can print the subcommand's help rather than the root.
487        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        // `[optional, required, <sub>]` — if the user types just the sub
526        // name, the required positional was never bound. The parser must
527        // still report it missing rather than silently accept.
528        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        // After `--`, the token `run` binds to the positional slot rather
589        // than dispatching to the `run` subcommand.
590        let err = OptionParser::parse(&schema, &args(&["--", "run"])).unwrap_err();
591        // Without a real subcommand token, the launcher reports a missing
592        // subcommand — not a subcommand dispatch to `run`.
593        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        // `-draft.txt` is not a registered option — the user probably
642        // meant it as a literal filename value, so accept it.
643        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}