usage/
parse.rs

1use heck::ToSnakeCase;
2use indexmap::IndexMap;
3use itertools::Itertools;
4use log::trace;
5use miette::bail;
6use std::collections::{BTreeMap, VecDeque};
7use std::fmt::{Debug, Display, Formatter};
8use strum::EnumTryAs;
9
10#[cfg(feature = "docs")]
11use crate::docs;
12use crate::error::UsageErr;
13use crate::{Spec, SpecArg, SpecCommand, SpecFlag};
14
15/// Extract the flag key from a flag word for lookup in available_flags map
16/// Handles both long flags (--flag, --flag=value) and short flags (-f)
17fn get_flag_key(word: &str) -> &str {
18    if word.starts_with("--") {
19        // Long flag: strip =value if present
20        word.split_once('=').map(|(k, _)| k).unwrap_or(word)
21    } else if word.len() >= 2 {
22        // Short flag: first two chars (-X)
23        &word[0..2]
24    } else {
25        word
26    }
27}
28
29pub struct ParseOutput {
30    pub cmd: SpecCommand,
31    pub cmds: Vec<SpecCommand>,
32    pub args: IndexMap<SpecArg, ParseValue>,
33    pub flags: IndexMap<SpecFlag, ParseValue>,
34    pub available_flags: BTreeMap<String, SpecFlag>,
35    pub flag_awaiting_value: Vec<SpecFlag>,
36    pub errors: Vec<UsageErr>,
37}
38
39#[derive(Debug, EnumTryAs, Clone)]
40pub enum ParseValue {
41    Bool(bool),
42    String(String),
43    MultiBool(Vec<bool>),
44    MultiString(Vec<String>),
45}
46
47pub fn parse(spec: &Spec, input: &[String]) -> Result<ParseOutput, miette::Error> {
48    let mut out = parse_partial(spec, input)?;
49    trace!("{out:?}");
50
51    // Apply env vars and defaults for args
52    for arg in out.cmd.args.iter().skip(out.args.len()) {
53        if let Some(env_var) = arg.env.as_ref() {
54            if let Ok(env_value) = std::env::var(env_var) {
55                out.args.insert(arg.clone(), ParseValue::String(env_value));
56                continue;
57            }
58        }
59        if let Some(default) = arg.default.as_ref() {
60            out.args
61                .insert(arg.clone(), ParseValue::String(default.clone()));
62        }
63    }
64
65    // Apply env vars and defaults for flags
66    for flag in out.available_flags.values() {
67        if out.flags.contains_key(flag) {
68            continue;
69        }
70        if let Some(env_var) = flag.env.as_ref() {
71            if let Ok(env_value) = std::env::var(env_var) {
72                if flag.arg.is_some() {
73                    out.flags
74                        .insert(flag.clone(), ParseValue::String(env_value));
75                } else {
76                    // For boolean flags, check if env value is truthy
77                    let is_true = matches!(env_value.as_str(), "1" | "true" | "True" | "TRUE");
78                    out.flags.insert(flag.clone(), ParseValue::Bool(is_true));
79                }
80                continue;
81            }
82        }
83        if let Some(default) = flag.default.as_ref() {
84            out.flags
85                .insert(flag.clone(), ParseValue::String(default.clone()));
86        }
87        if let Some(Some(default)) = flag.arg.as_ref().map(|a| &a.default) {
88            out.flags
89                .insert(flag.clone(), ParseValue::String(default.clone()));
90        }
91    }
92    if let Some(err) = out.errors.iter().find(|e| matches!(e, UsageErr::Help(_))) {
93        bail!("{err}");
94    }
95    if !out.errors.is_empty() {
96        bail!("{}", out.errors.iter().map(|e| e.to_string()).join("\n"));
97    }
98    Ok(out)
99}
100
101pub fn parse_partial(spec: &Spec, input: &[String]) -> Result<ParseOutput, miette::Error> {
102    trace!("parse_partial: {input:?}");
103    let mut input = input.iter().cloned().collect::<VecDeque<_>>();
104    input.pop_front();
105
106    let gather_flags = |cmd: &SpecCommand| {
107        cmd.flags
108            .iter()
109            .flat_map(|f| {
110                let mut flags = f
111                    .long
112                    .iter()
113                    .map(|l| (format!("--{l}"), f.clone()))
114                    .chain(f.short.iter().map(|s| (format!("-{s}"), f.clone())))
115                    .collect::<Vec<_>>();
116                if let Some(negate) = &f.negate {
117                    flags.push((negate.clone(), f.clone()));
118                }
119                flags
120            })
121            .collect()
122    };
123
124    let mut out = ParseOutput {
125        cmd: spec.cmd.clone(),
126        cmds: vec![spec.cmd.clone()],
127        args: IndexMap::new(),
128        flags: IndexMap::new(),
129        available_flags: gather_flags(&spec.cmd),
130        flag_awaiting_value: vec![],
131        errors: vec![],
132    };
133
134    // Phase 1: Scan for subcommands and collect global flags
135    //
136    // This phase identifies subcommands early because they may have mount points
137    // that need to be executed with the global flags that appeared before them.
138    //
139    // Example: "usage --verbose run task"
140    //   -> finds "run" subcommand, passes ["--verbose"] to its mount command
141    //   -> then finds "task" as a subcommand of "run" (if it exists)
142    //
143    // We only collect global flags because:
144    // - Non-global flags are specific to the current command, not subcommands
145    // - Global flags affect all commands and should be passed to mount points
146    let mut prefix_words: Vec<String> = vec![];
147    let mut idx = 0;
148
149    while idx < input.len() {
150        if let Some(subcommand) = out.cmd.find_subcommand(&input[idx]) {
151            let mut subcommand = subcommand.clone();
152            // Pass prefix words (global flags before this subcommand) to mount
153            subcommand.mount(&prefix_words)?;
154            out.available_flags.retain(|_, f| f.global);
155            out.available_flags.extend(gather_flags(&subcommand));
156            // Remove subcommand from input
157            input.remove(idx);
158            out.cmds.push(subcommand.clone());
159            out.cmd = subcommand.clone();
160            prefix_words.clear();
161            // Continue from current position (don't reset to 0)
162            // After remove(), idx now points to the next element
163        } else if input[idx].starts_with('-') {
164            // Check if this is a known flag and if it's global
165            let word = &input[idx];
166            let flag_key = get_flag_key(word);
167
168            if let Some(f) = out.available_flags.get(flag_key) {
169                // Only collect global flags for mount execution
170                if f.global {
171                    prefix_words.push(input[idx].clone());
172                    idx += 1;
173
174                    // Only consume next word if flag takes an argument AND value isn't embedded
175                    // Example: "--dir foo" consumes "foo", but "--dir=foo" or "--verbose" do not
176                    if f.arg.is_some()
177                        && !word.contains('=')
178                        && idx < input.len()
179                        && !input[idx].starts_with('-')
180                    {
181                        prefix_words.push(input[idx].clone());
182                        idx += 1;
183                    }
184                } else {
185                    // Non-global flag encountered - stop subcommand search
186                    // This prevents incorrect parsing like: "cmd --local-flag run"
187                    // where "run" might be mistaken for a subcommand
188                    break;
189                }
190            } else {
191                // Unknown flag - stop looking for subcommands
192                // Let the main parsing phase handle the error
193                break;
194            }
195        } else {
196            // Found a word that's not a flag or subcommand
197            // This could be a positional argument, so stop subcommand search
198            break;
199        }
200    }
201
202    // Phase 2: Main argument and flag parsing
203    //
204    // Now that we've identified all subcommands and executed their mounts,
205    // we can parse the remaining arguments, flags, and their values.
206    let mut next_arg = out.cmd.args.first();
207    let mut enable_flags = true;
208    let mut grouped_flag = false;
209
210    while !input.is_empty() {
211        let mut w = input.pop_front().unwrap();
212
213        if w == "--" {
214            enable_flags = false;
215            continue;
216        }
217
218        // long flags
219        if enable_flags && w.starts_with("--") {
220            grouped_flag = false;
221            let (word, val) = w.split_once('=').unwrap_or_else(|| (&w, ""));
222            if !val.is_empty() {
223                input.push_front(val.to_string());
224            }
225            if let Some(f) = out.available_flags.get(word) {
226                if f.arg.is_some() {
227                    out.flag_awaiting_value.push(f.clone());
228                } else if f.var {
229                    let arr = out
230                        .flags
231                        .entry(f.clone())
232                        .or_insert_with(|| ParseValue::MultiBool(vec![]))
233                        .try_as_multi_bool_mut()
234                        .unwrap();
235                    arr.push(true);
236                } else {
237                    let negate = f.negate.clone().unwrap_or_default();
238                    out.flags.insert(f.clone(), ParseValue::Bool(w != negate));
239                }
240                continue;
241            }
242            if is_help_arg(spec, &w) {
243                out.errors
244                    .push(render_help_err(spec, &out.cmd, w.len() > 2));
245                return Ok(out);
246            }
247        }
248
249        // short flags
250        if enable_flags && w.starts_with('-') && w.len() > 1 {
251            let short = w.chars().nth(1).unwrap();
252            if let Some(f) = out.available_flags.get(&format!("-{short}")) {
253                if w.len() > 2 {
254                    input.push_front(format!("-{}", &w[2..]));
255                    grouped_flag = true;
256                }
257                if f.arg.is_some() {
258                    out.flag_awaiting_value.push(f.clone());
259                } else if f.var {
260                    let arr = out
261                        .flags
262                        .entry(f.clone())
263                        .or_insert_with(|| ParseValue::MultiBool(vec![]))
264                        .try_as_multi_bool_mut()
265                        .unwrap();
266                    arr.push(true);
267                } else {
268                    let negate = f.negate.clone().unwrap_or_default();
269                    out.flags.insert(f.clone(), ParseValue::Bool(w != negate));
270                }
271                continue;
272            }
273            if is_help_arg(spec, &w) {
274                out.errors
275                    .push(render_help_err(spec, &out.cmd, w.len() > 2));
276                return Ok(out);
277            }
278            if grouped_flag {
279                grouped_flag = false;
280                w.remove(0);
281            }
282        }
283
284        if !out.flag_awaiting_value.is_empty() {
285            while let Some(flag) = out.flag_awaiting_value.pop() {
286                let arg = flag.arg.as_ref().unwrap();
287                if flag.var {
288                    let arr = out
289                        .flags
290                        .entry(flag)
291                        .or_insert_with(|| ParseValue::MultiString(vec![]))
292                        .try_as_multi_string_mut()
293                        .unwrap();
294                    arr.push(w);
295                } else {
296                    if let Some(choices) = &arg.choices {
297                        if !choices.choices.contains(&w) {
298                            if is_help_arg(spec, &w) {
299                                out.errors
300                                    .push(render_help_err(spec, &out.cmd, w.len() > 2));
301                                return Ok(out);
302                            }
303                            bail!(
304                                "Invalid choice for option {}: {w}, expected one of {}",
305                                flag.name,
306                                choices.choices.join(", ")
307                            );
308                        }
309                    }
310                    out.flags.insert(flag, ParseValue::String(w));
311                }
312                w = "".to_string();
313            }
314            continue;
315        }
316
317        if let Some(arg) = next_arg {
318            if arg.var {
319                let arr = out
320                    .args
321                    .entry(arg.clone())
322                    .or_insert_with(|| ParseValue::MultiString(vec![]))
323                    .try_as_multi_string_mut()
324                    .unwrap();
325                arr.push(w);
326                if arr.len() >= arg.var_max.unwrap_or(usize::MAX) {
327                    next_arg = out.cmd.args.get(out.args.len());
328                }
329            } else {
330                if let Some(choices) = &arg.choices {
331                    if !choices.choices.contains(&w) {
332                        if is_help_arg(spec, &w) {
333                            out.errors
334                                .push(render_help_err(spec, &out.cmd, w.len() > 2));
335                            return Ok(out);
336                        }
337                        bail!(
338                            "Invalid choice for arg {}: {w}, expected one of {}",
339                            arg.name,
340                            choices.choices.join(", ")
341                        );
342                    }
343                }
344                out.args.insert(arg.clone(), ParseValue::String(w));
345                next_arg = out.cmd.args.get(out.args.len());
346            }
347            continue;
348        }
349        if is_help_arg(spec, &w) {
350            out.errors
351                .push(render_help_err(spec, &out.cmd, w.len() > 2));
352            return Ok(out);
353        }
354        bail!("unexpected word: {w}");
355    }
356
357    for arg in out.cmd.args.iter().skip(out.args.len()) {
358        if arg.required && arg.default.is_none() {
359            // Check if there's an env var available
360            let has_env = arg
361                .env
362                .as_ref()
363                .map(|e| std::env::var(e).is_ok())
364                .unwrap_or(false);
365            if !has_env {
366                out.errors.push(UsageErr::MissingArg(arg.name.clone()));
367            }
368        }
369    }
370
371    for flag in out.available_flags.values() {
372        if out.flags.contains_key(flag) {
373            continue;
374        }
375        let has_default = flag.default.is_some() || flag.arg.iter().any(|a| a.default.is_some());
376        let has_env = flag
377            .env
378            .as_ref()
379            .map(|e| std::env::var(e).is_ok())
380            .unwrap_or(false);
381        if flag.required && !has_default && !has_env {
382            out.errors.push(UsageErr::MissingFlag(flag.name.clone()));
383        }
384    }
385
386    Ok(out)
387}
388
389#[cfg(feature = "docs")]
390fn render_help_err(spec: &Spec, cmd: &SpecCommand, long: bool) -> UsageErr {
391    UsageErr::Help(docs::cli::render_help(spec, cmd, long))
392}
393
394#[cfg(not(feature = "docs"))]
395fn render_help_err(_spec: &Spec, _cmd: &SpecCommand, _long: bool) -> UsageErr {
396    UsageErr::Help("help".to_string())
397}
398
399fn is_help_arg(spec: &Spec, w: &str) -> bool {
400    spec.disable_help != Some(true)
401        && (w == "--help"
402            || w == "-h"
403            || w == "-?"
404            || (spec.cmd.subcommands.is_empty() && w == "help"))
405}
406
407impl ParseOutput {
408    pub fn as_env(&self) -> BTreeMap<String, String> {
409        let mut env = BTreeMap::new();
410        for (flag, val) in &self.flags {
411            let key = format!("usage_{}", flag.name.to_snake_case());
412            let val = match val {
413                ParseValue::Bool(b) => if *b { "true" } else { "false" }.to_string(),
414                ParseValue::String(s) => s.clone(),
415                ParseValue::MultiBool(b) => b.iter().filter(|b| **b).count().to_string(),
416                ParseValue::MultiString(s) => shell_words::join(s),
417            };
418            env.insert(key, val);
419        }
420        for (arg, val) in &self.args {
421            let key = format!("usage_{}", arg.name.to_snake_case());
422            env.insert(key, val.to_string());
423        }
424        env
425    }
426}
427
428impl Display for ParseValue {
429    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
430        match self {
431            ParseValue::Bool(b) => write!(f, "{b}"),
432            ParseValue::String(s) => write!(f, "{s}"),
433            ParseValue::MultiBool(b) => write!(f, "{}", b.iter().join(" ")),
434            ParseValue::MultiString(s) => write!(f, "{}", shell_words::join(s)),
435        }
436    }
437}
438
439impl Debug for ParseOutput {
440    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
441        f.debug_struct("ParseOutput")
442            .field("cmds", &self.cmds.iter().map(|c| &c.name).join(" ").trim())
443            .field(
444                "args",
445                &self
446                    .args
447                    .iter()
448                    .map(|(a, w)| format!("{}: {w}", &a.name))
449                    .collect_vec(),
450            )
451            .field(
452                "available_flags",
453                &self
454                    .available_flags
455                    .iter()
456                    .map(|(f, w)| format!("{f}: {w}"))
457                    .collect_vec(),
458            )
459            .field(
460                "flags",
461                &self
462                    .flags
463                    .iter()
464                    .map(|(f, w)| format!("{}: {w}", &f.name))
465                    .collect_vec(),
466            )
467            .field("flag_awaiting_value", &self.flag_awaiting_value)
468            .field("errors", &self.errors)
469            .finish()
470    }
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476
477    #[test]
478    fn test_parse() {
479        let mut cmd = SpecCommand::default();
480        cmd.name = "test".to_string();
481        cmd.args = vec![SpecArg {
482            name: "arg".to_string(),
483            ..Default::default()
484        }];
485        cmd.flags = vec![SpecFlag {
486            name: "flag".to_string(),
487            long: vec!["flag".to_string()],
488            ..Default::default()
489        }];
490        let spec = Spec {
491            name: "test".to_string(),
492            bin: "test".to_string(),
493            cmd,
494            ..Default::default()
495        };
496        let input = vec!["test".to_string(), "arg1".to_string(), "--flag".to_string()];
497        let parsed = parse(&spec, &input).unwrap();
498        assert_eq!(parsed.cmds.len(), 1);
499        assert_eq!(parsed.cmds[0].name, "test");
500        assert_eq!(parsed.args.len(), 1);
501        assert_eq!(parsed.flags.len(), 1);
502        assert_eq!(parsed.available_flags.len(), 1);
503    }
504
505    #[test]
506    fn test_as_env() {
507        let mut cmd = SpecCommand::default();
508        cmd.name = "test".to_string();
509        cmd.args = vec![SpecArg {
510            name: "arg".to_string(),
511            ..Default::default()
512        }];
513        cmd.flags = vec![
514            SpecFlag {
515                name: "flag".to_string(),
516                long: vec!["flag".to_string()],
517                ..Default::default()
518            },
519            SpecFlag {
520                name: "force".to_string(),
521                long: vec!["force".to_string()],
522                negate: Some("--no-force".to_string()),
523                ..Default::default()
524            },
525        ];
526        let spec = Spec {
527            name: "test".to_string(),
528            bin: "test".to_string(),
529            cmd,
530            ..Default::default()
531        };
532        let input = vec![
533            "test".to_string(),
534            "--flag".to_string(),
535            "--no-force".to_string(),
536        ];
537        let parsed = parse(&spec, &input).unwrap();
538        let env = parsed.as_env();
539        assert_eq!(env.len(), 2);
540        assert_eq!(env.get("usage_flag"), Some(&"true".to_string()));
541        assert_eq!(env.get("usage_force"), Some(&"false".to_string()));
542    }
543
544    #[test]
545    fn test_arg_env_var() {
546        let mut cmd = SpecCommand::default();
547        cmd.name = "test".to_string();
548        cmd.args = vec![SpecArg {
549            name: "input".to_string(),
550            env: Some("TEST_ARG_INPUT".to_string()),
551            required: true,
552            ..Default::default()
553        }];
554        let spec = Spec {
555            name: "test".to_string(),
556            bin: "test".to_string(),
557            cmd,
558            ..Default::default()
559        };
560
561        // Set env var
562        std::env::set_var("TEST_ARG_INPUT", "test_file.txt");
563
564        let input = vec!["test".to_string()];
565        let parsed = parse(&spec, &input).unwrap();
566
567        assert_eq!(parsed.args.len(), 1);
568        let arg = parsed.args.keys().next().unwrap();
569        assert_eq!(arg.name, "input");
570        let value = parsed.args.values().next().unwrap();
571        assert_eq!(value.to_string(), "test_file.txt");
572
573        // Clean up
574        std::env::remove_var("TEST_ARG_INPUT");
575    }
576
577    #[test]
578    fn test_flag_env_var_with_arg() {
579        let mut cmd = SpecCommand::default();
580        cmd.name = "test".to_string();
581        cmd.flags = vec![SpecFlag {
582            name: "output".to_string(),
583            long: vec!["output".to_string()],
584            env: Some("TEST_FLAG_OUTPUT".to_string()),
585            arg: Some(SpecArg {
586                name: "file".to_string(),
587                ..Default::default()
588            }),
589            ..Default::default()
590        }];
591        let spec = Spec {
592            name: "test".to_string(),
593            bin: "test".to_string(),
594            cmd,
595            ..Default::default()
596        };
597
598        // Set env var
599        std::env::set_var("TEST_FLAG_OUTPUT", "output.txt");
600
601        let input = vec!["test".to_string()];
602        let parsed = parse(&spec, &input).unwrap();
603
604        assert_eq!(parsed.flags.len(), 1);
605        let flag = parsed.flags.keys().next().unwrap();
606        assert_eq!(flag.name, "output");
607        let value = parsed.flags.values().next().unwrap();
608        assert_eq!(value.to_string(), "output.txt");
609
610        // Clean up
611        std::env::remove_var("TEST_FLAG_OUTPUT");
612    }
613
614    #[test]
615    fn test_flag_env_var_boolean() {
616        let mut cmd = SpecCommand::default();
617        cmd.name = "test".to_string();
618        cmd.flags = vec![SpecFlag {
619            name: "verbose".to_string(),
620            long: vec!["verbose".to_string()],
621            env: Some("TEST_FLAG_VERBOSE".to_string()),
622            ..Default::default()
623        }];
624        let spec = Spec {
625            name: "test".to_string(),
626            bin: "test".to_string(),
627            cmd,
628            ..Default::default()
629        };
630
631        // Set env var to true
632        std::env::set_var("TEST_FLAG_VERBOSE", "true");
633
634        let input = vec!["test".to_string()];
635        let parsed = parse(&spec, &input).unwrap();
636
637        assert_eq!(parsed.flags.len(), 1);
638        let flag = parsed.flags.keys().next().unwrap();
639        assert_eq!(flag.name, "verbose");
640        let value = parsed.flags.values().next().unwrap();
641        assert_eq!(value.to_string(), "true");
642
643        // Clean up
644        std::env::remove_var("TEST_FLAG_VERBOSE");
645    }
646
647    #[test]
648    fn test_env_var_precedence() {
649        // CLI args should take precedence over env vars
650        let mut cmd = SpecCommand::default();
651        cmd.name = "test".to_string();
652        cmd.args = vec![SpecArg {
653            name: "input".to_string(),
654            env: Some("TEST_PRECEDENCE_INPUT".to_string()),
655            required: true,
656            ..Default::default()
657        }];
658        let spec = Spec {
659            name: "test".to_string(),
660            bin: "test".to_string(),
661            cmd,
662            ..Default::default()
663        };
664
665        // Set env var
666        std::env::set_var("TEST_PRECEDENCE_INPUT", "env_file.txt");
667
668        let input = vec!["test".to_string(), "cli_file.txt".to_string()];
669        let parsed = parse(&spec, &input).unwrap();
670
671        assert_eq!(parsed.args.len(), 1);
672        let value = parsed.args.values().next().unwrap();
673        // CLI arg should take precedence
674        assert_eq!(value.to_string(), "cli_file.txt");
675
676        // Clean up
677        std::env::remove_var("TEST_PRECEDENCE_INPUT");
678    }
679}