Skip to main content

veks_completion/
cli.rs

1// Copyright (c) Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! The veks-completion CLI framework: argument parsing, help, and a command
5//! definition model — the in-tree replacement for clap.
6//!
7//! This is the runtime half. A command's CLI is described by a [`CommandSpec`]
8//! (subcommands, options, positionals); [`parse`] turns an `argv` slice into a
9//! [`ParsedArgs`]; [`render_help`] renders `--help`. The derive macro
10//! (`veks-completion-derive`) generates the [`CommandSpec`] and the typed
11//! extraction from this same model, so one declaration drives parsing, help,
12//! **and** completion (the completion [`CommandTree`](crate::CommandTree) is
13//! built from the very same [`CommandSpec`]).
14//!
15//! The option's *parse-defining shape* is the existing [`OptionDef`] — shared
16//! and consistency-checked across commands. Per-command facets that may
17//! legitimately vary (required-ness, default value) live on [`OptionSpec`],
18//! deliberately outside the shape that the consistency audit compares.
19
20use std::collections::{BTreeMap, HashSet};
21
22use crate::OptionDef;
23
24/// One option as it appears on a specific command: the shared [`OptionDef`]
25/// shape plus this command's required-ness, default, and value completer.
26#[derive(Clone)]
27pub struct OptionSpec {
28    /// The shared, parse-defining shape (flag, short, arity, value name, help).
29    pub def: OptionDef,
30    /// Whether this command requires the option. May differ per command.
31    pub required: bool,
32    /// Default value applied when the option is absent (value options only).
33    pub default: Option<String>,
34    /// Per-command value completer (closed set, path, dynamic). The completion
35    /// bridge attaches it to this command's node. It is *not* part of the parse
36    /// shape or the consistency audit, so it may legitimately differ between
37    /// commands that share the same `def`.
38    pub value_completion: Option<crate::ValueProvider>,
39}
40
41impl std::fmt::Debug for OptionSpec {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        f.debug_struct("OptionSpec")
44            .field("def", &self.def)
45            .field("required", &self.required)
46            .field("default", &self.default)
47            .field("value_completion", &self.value_completion.as_ref().map(|_| "<provider>"))
48            .finish()
49    }
50}
51
52impl OptionSpec {
53    pub fn new(def: OptionDef) -> Self {
54        OptionSpec { def, required: false, default: None, value_completion: None }
55    }
56    pub fn required(mut self, yes: bool) -> Self {
57        self.required = yes;
58        self
59    }
60    pub fn default(mut self, v: impl Into<String>) -> Self {
61        self.default = Some(v.into());
62        self
63    }
64    /// Attach a value completer for this option on this command.
65    pub fn value_completion(mut self, provider: crate::ValueProvider) -> Self {
66        self.value_completion = Some(provider);
67        self
68    }
69    /// The canonical long token, e.g. `"--at"`.
70    pub fn flag(&self) -> &str {
71        &self.def.name
72    }
73}
74
75/// A positional argument.
76#[derive(Clone, Debug)]
77pub struct PositionalSpec {
78    /// Display name, e.g. `"DATASET"`.
79    pub name: String,
80    pub required: bool,
81    /// Greedy trailing positional (collects the rest).
82    pub multiple: bool,
83    pub help: Option<String>,
84}
85
86impl PositionalSpec {
87    pub fn new(name: impl Into<String>) -> Self {
88        PositionalSpec { name: name.into(), required: false, multiple: false, help: None }
89    }
90    pub fn required(mut self, yes: bool) -> Self {
91        self.required = yes;
92        self
93    }
94    pub fn multiple(mut self, yes: bool) -> Self {
95        self.multiple = yes;
96        self
97    }
98    pub fn help(mut self, h: impl Into<String>) -> Self {
99        self.help = Some(h.into());
100        self
101    }
102}
103
104/// A command and (recursively) its subcommands. The single source consumed by
105/// the parser, the help renderer, and the completion-tree builder.
106#[derive(Clone, Debug, Default)]
107pub struct CommandSpec {
108    pub name: String,
109    pub about: Option<String>,
110    pub aliases: Vec<String>,
111    pub options: Vec<OptionSpec>,
112    pub positionals: Vec<PositionalSpec>,
113    pub subcommands: Vec<CommandSpec>,
114    /// When true, a subcommand must be given (a group command).
115    pub subcommand_required: bool,
116    /// Free-form text appended after the options block in `--help` (examples,
117    /// notes). From `#[command(after_help/after_long_help = …)]`.
118    pub after_help: Option<String>,
119    /// Maturity tier (see [`crate::Stability`]) — governs whether this command is
120    /// offered during completion. From `#[command(stability = "…")]`. Defaults
121    /// to `Stable`.
122    pub stability: crate::Stability,
123}
124
125impl CommandSpec {
126    pub fn new(name: impl Into<String>) -> Self {
127        CommandSpec { name: name.into(), ..Default::default() }
128    }
129    pub fn about(mut self, a: impl Into<String>) -> Self {
130        self.about = Some(a.into());
131        self
132    }
133    /// Add an alternate name this command also answers to (e.g. `ls` for `list`).
134    pub fn alias(mut self, a: impl Into<String>) -> Self {
135        self.aliases.push(a.into());
136        self
137    }
138    pub fn after_help(mut self, a: impl Into<String>) -> Self {
139        self.after_help = Some(a.into());
140        self
141    }
142    /// Declare this command's maturity tier (see [`crate::Stability`]).
143    pub fn stability(mut self, s: crate::Stability) -> Self {
144        self.stability = s;
145        self
146    }
147    pub fn option(mut self, o: OptionSpec) -> Self {
148        self.options.push(o);
149        self
150    }
151    pub fn positional(mut self, p: PositionalSpec) -> Self {
152        self.positionals.push(p);
153        self
154    }
155    pub fn subcommand(mut self, c: CommandSpec) -> Self {
156        self.subcommands.push(c);
157        self
158    }
159
160    /// Find an option by long token (with or without leading dashes) or short.
161    fn find_long(&self, token: &str) -> Option<&OptionSpec> {
162        let want = token.trim_start_matches('-');
163        self.options.iter().find(|o| o.def.name.trim_start_matches('-') == want)
164    }
165    fn find_short(&self, c: char) -> Option<&OptionSpec> {
166        self.options.iter().find(|o| o.def.short == Some(c))
167    }
168    fn find_subcommand(&self, name: &str) -> Option<&CommandSpec> {
169        self.subcommands
170            .iter()
171            .find(|s| s.name == name || s.aliases.iter().any(|a| a == name))
172    }
173}
174
175/// The result of parsing one command level. Values are kept as strings (the
176/// derive macro performs typed conversion); repeatable options accumulate.
177#[derive(Clone, Debug, Default)]
178pub struct ParsedArgs {
179    /// Boolean flags that were present (canonical long token, no dashes).
180    flags: HashSet<String>,
181    /// Value options: canonical long token (no dashes) → values in order.
182    values: BTreeMap<String, Vec<String>>,
183    /// Positional arguments in order.
184    positionals: Vec<String>,
185    /// The chosen subcommand and its own parsed args, if any.
186    subcommand: Option<(String, Box<ParsedArgs>)>,
187}
188
189impl ParsedArgs {
190    pub fn has_flag(&self, name: &str) -> bool {
191        self.flags.contains(name.trim_start_matches('-'))
192    }
193    /// First value for an option, if present.
194    pub fn value(&self, name: &str) -> Option<&str> {
195        self.values.get(name.trim_start_matches('-')).and_then(|v| v.first()).map(|s| s.as_str())
196    }
197    /// All values for a (repeatable) option.
198    pub fn values(&self, name: &str) -> &[String] {
199        const EMPTY: &[String] = &[];
200        self.values.get(name.trim_start_matches('-')).map(|v| v.as_slice()).unwrap_or(EMPTY)
201    }
202    pub fn positionals(&self) -> &[String] {
203        &self.positionals
204    }
205    pub fn subcommand(&self) -> Option<(&str, &ParsedArgs)> {
206        self.subcommand.as_ref().map(|(n, p)| (n.as_str(), p.as_ref()))
207    }
208}
209
210/// A parse failure, with enough context to render a useful message.
211#[derive(Clone, Debug, PartialEq, Eq)]
212pub enum ParseError {
213    UnknownFlag { command: String, flag: String },
214    MissingValue { command: String, flag: String },
215    MissingRequiredOption { command: String, flag: String },
216    MissingRequiredPositional { command: String, name: String },
217    UnexpectedPositional { command: String, value: String },
218    UnknownSubcommand { command: String, name: String },
219    MissingSubcommand { command: String },
220    /// A value failed to convert to the field's type (e.g. `--count abc` for a
221    /// `usize`). Produced during typed extraction by the derive macro.
222    InvalidValue { flag: String, value: String, message: String },
223    /// Two mutually exclusive options were both supplied (see
224    /// [`OptionDef::conflicts_with`]).
225    ConflictingOptions { command: String, flag: String, other: String },
226}
227
228/// Implemented by `#[derive(VeksCli)]` types: a command/args struct or a
229/// subcommand enum. Provides the [`CommandSpec`] (drives parse + help +
230/// completion) and the typed extraction from a [`ParsedArgs`].
231pub trait VeksCli: Sized {
232    /// The full spec for this type used as a command named `name`.
233    fn veks_command_spec(name: &str) -> CommandSpec;
234    /// Add this type's options/positionals/subcommands to an existing spec
235    /// (used by `#[command(flatten)]` and subcommand fields).
236    fn veks_augment_spec(spec: CommandSpec) -> CommandSpec;
237    /// Build `Self` from already-parsed args.
238    fn veks_from_parsed(parsed: &ParsedArgs) -> Result<Self, ParseError>;
239}
240
241impl std::fmt::Display for ParseError {
242    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
243        match self {
244            ParseError::UnknownFlag { command, flag } =>
245                write!(f, "{command}: unexpected option '{flag}'"),
246            ParseError::MissingValue { command, flag } =>
247                write!(f, "{command}: option '{flag}' requires a value"),
248            ParseError::MissingRequiredOption { command, flag } =>
249                write!(f, "{command}: required option '{flag}' not provided"),
250            ParseError::MissingRequiredPositional { command, name } =>
251                write!(f, "{command}: required argument <{name}> not provided"),
252            ParseError::UnexpectedPositional { command, value } =>
253                write!(f, "{command}: unexpected argument '{value}'"),
254            ParseError::UnknownSubcommand { command, name } =>
255                write!(f, "{command}: unknown subcommand '{name}'"),
256            ParseError::MissingSubcommand { command } =>
257                write!(f, "{command}: a subcommand is required"),
258            ParseError::InvalidValue { flag, value, message } =>
259                write!(f, "invalid value '{value}' for '{flag}': {message}"),
260            ParseError::ConflictingOptions { command, flag, other } =>
261                write!(f, "'{flag}' cannot be combined with '{other}' (in '{command}')"),
262        }
263    }
264}
265
266impl std::error::Error for ParseError {}
267
268/// Parse `argv` (the words *after* the program name) against `spec`.
269///
270/// Grammar handled: `--long`, `--long=value`, `--long value`, `-s`, `-s value`,
271/// `-s=value`, `--` (end-of-options), positionals, and nested subcommands.
272/// Boolean options take no value; value options consume the next word (or the
273/// `=`-suffix). Repeatable options accumulate. Defaults fill absent value
274/// options; required options/positionals are checked after parsing.
275pub fn parse(spec: &CommandSpec, argv: &[String]) -> Result<ParsedArgs, ParseError> {
276    let mut out = ParsedArgs::default();
277    let mut i = 0;
278    let mut options_ended = false;
279
280    while i < argv.len() {
281        let arg = &argv[i];
282
283        if !options_ended && arg == "--" {
284            options_ended = true;
285            i += 1;
286            continue;
287        }
288
289        // Triple-dash tokens are reserved engine meta (e.g. `---experimental`
290        // steers tab-completion; `---dump-tree` is a diagnostic). They are never
291        // real `--` flags, so skip them — a line the user completed with one
292        // (`veks ---experimental datasets list`) still parses and runs.
293        if !options_ended && arg.starts_with("---") {
294            i += 1;
295            continue;
296        }
297
298        if !options_ended && arg.starts_with("--") {
299            // --long or --long=value
300            let body = &arg[2..];
301            let (name, inline) = match body.split_once('=') {
302                Some((n, v)) => (n, Some(v.to_string())),
303                None => (body, None),
304            };
305            let opt = spec
306                .find_long(name)
307                .ok_or_else(|| ParseError::UnknownFlag { command: spec.name.clone(), flag: arg.clone() })?;
308            let canon = opt.def.name.trim_start_matches('-').to_string();
309            if !opt.def.takes_value {
310                out.flags.insert(canon);
311            } else {
312                let value = match inline {
313                    Some(v) => v,
314                    None => {
315                        i += 1;
316                        argv.get(i)
317                            .cloned()
318                            .ok_or_else(|| ParseError::MissingValue { command: spec.name.clone(), flag: arg.clone() })?
319                    }
320                };
321                out.values.entry(canon).or_default().push(value);
322            }
323            i += 1;
324            continue;
325        }
326
327        if !options_ended && arg.starts_with('-') && arg.len() > 1 {
328            // -s or -s=value or -sVALUE (single short; bundling not supported)
329            let body = &arg[1..];
330            let mut chars = body.chars();
331            let short = chars.next().unwrap();
332            let rest: String = chars.collect();
333            let opt = spec
334                .find_short(short)
335                .ok_or_else(|| ParseError::UnknownFlag { command: spec.name.clone(), flag: arg.clone() })?;
336            let canon = opt.def.name.trim_start_matches('-').to_string();
337            if !opt.def.takes_value {
338                out.flags.insert(canon);
339            } else {
340                let value = if let Some(stripped) = rest.strip_prefix('=') {
341                    stripped.to_string()
342                } else if !rest.is_empty() {
343                    rest
344                } else {
345                    i += 1;
346                    argv.get(i)
347                        .cloned()
348                        .ok_or_else(|| ParseError::MissingValue { command: spec.name.clone(), flag: arg.clone() })?
349                };
350                out.values.entry(canon).or_default().push(value);
351            }
352            i += 1;
353            continue;
354        }
355
356        // A bare word. If this command has subcommands and no positional has
357        // been consumed yet, treat it as a subcommand selector; otherwise it's
358        // a positional.
359        if !spec.subcommands.is_empty() && out.positionals.is_empty() {
360            let sub = spec.find_subcommand(arg).ok_or_else(|| ParseError::UnknownSubcommand {
361                command: spec.name.clone(),
362                name: arg.clone(),
363            })?;
364            let sub_parsed = parse(sub, &argv[i + 1..])?;
365            out.subcommand = Some((sub.name.clone(), Box::new(sub_parsed)));
366            // A subcommand consumes the remainder.
367            finalize(spec, &mut out)?;
368            return Ok(out);
369        }
370
371        out.positionals.push(arg.clone());
372        i += 1;
373    }
374
375    finalize(spec, &mut out)?;
376    Ok(out)
377}
378
379/// Apply defaults, then validate required options/positionals and subcommand
380/// presence.
381fn finalize(spec: &CommandSpec, out: &mut ParsedArgs) -> Result<(), ParseError> {
382    // Mutual exclusions first — before defaults are injected, so
383    // only options the user actually supplied count as present.
384    for opt in &spec.options {
385        let canon = opt.def.name.trim_start_matches('-');
386        if !out.flags.contains(canon) && !out.values.contains_key(canon) {
387            continue;
388        }
389        for conflict in &opt.def.conflicts_with {
390            let other = conflict.trim_start_matches('-');
391            if out.flags.contains(other) || out.values.contains_key(other) {
392                return Err(ParseError::ConflictingOptions {
393                    command: spec.name.clone(),
394                    flag: opt.def.name.clone(),
395                    other: conflict.clone(),
396                });
397            }
398        }
399    }
400
401    for opt in &spec.options {
402        let canon = opt.def.name.trim_start_matches('-').to_string();
403        let present = out.flags.contains(&canon) || out.values.contains_key(&canon);
404        if !present {
405            if let Some(def) = &opt.default {
406                out.values.entry(canon.clone()).or_default().push(def.clone());
407            } else if opt.required {
408                return Err(ParseError::MissingRequiredOption {
409                    command: spec.name.clone(),
410                    flag: opt.def.name.clone(),
411                });
412            }
413        }
414    }
415
416    // Positional arity: count required positionals satisfied.
417    let required_positionals = spec.positionals.iter().filter(|p| p.required).count();
418    if out.positionals.len() < required_positionals {
419        let missing = &spec.positionals[out.positionals.len()];
420        return Err(ParseError::MissingRequiredPositional {
421            command: spec.name.clone(),
422            name: missing.name.clone(),
423        });
424    }
425
426    if spec.subcommand_required && out.subcommand.is_none() {
427        return Err(ParseError::MissingSubcommand { command: spec.name.clone() });
428    }
429
430    Ok(())
431}
432
433/// Render `--help` text for a command spec.
434/// Target line width for help text (clap's default for a non-tty).
435const HELP_WIDTH: usize = 100;
436
437/// Word-wrap `text` to `width`, honoring existing newlines as hard breaks.
438fn wrap_text(text: &str, width: usize) -> Vec<String> {
439    let mut lines = Vec::new();
440    for para in text.split('\n') {
441        if para.trim().is_empty() {
442            lines.push(String::new());
443            continue;
444        }
445        let mut cur = String::new();
446        for word in para.split_whitespace() {
447            if cur.is_empty() {
448                cur.push_str(word);
449            } else if width == 0 || cur.len() + 1 + word.len() <= width {
450                cur.push(' ');
451                cur.push_str(word);
452            } else {
453                lines.push(std::mem::take(&mut cur));
454                cur.push_str(word);
455            }
456        }
457        lines.push(cur);
458    }
459    if lines.is_empty() {
460        lines.push(String::new());
461    }
462    lines
463}
464
465/// Two-column (term, description) renderer with a capped left column and a
466/// wrapped, hanging-indented right column — the shape clap uses for its
467/// Commands/Arguments/Options blocks.
468fn render_two_col(out: &mut String, rows: &[(String, String)]) {
469    if rows.is_empty() {
470        return;
471    }
472    let col = rows.iter().map(|(l, _)| l.len()).max().unwrap_or(0).min(28);
473    let help_width = HELP_WIDTH.saturating_sub(col + 4).max(20);
474    for (left, help) in rows {
475        let wrapped = wrap_text(help, help_width);
476        let mut iter = wrapped.iter();
477        let first = iter.next().map(|s| s.as_str()).unwrap_or("");
478        if left.len() <= col {
479            out.push_str(&format!("  {:<col$}  {}\n", left, first, col = col));
480        } else {
481            // Left entry overflows the column — put it on its own line.
482            out.push_str(&format!("  {}\n", left));
483            out.push_str(&format!("  {:<col$}  {}\n", "", first, col = col));
484        }
485        for cont in iter {
486            out.push_str(&format!("  {:<col$}  {}\n", "", cont, col = col));
487        }
488    }
489}
490
491/// Render `--help` for the deepest subcommand named by the leading words of
492/// `argv`, so `app group leaf --help` shows the leaf's options rather than the
493/// group overview. Stops descending at the first flag (or unknown word) and
494/// renders whatever level was reached (the root when `argv` names none).
495pub fn render_help_for<S: AsRef<str>>(root: &CommandSpec, argv: &[S]) -> String {
496    let mut spec = root;
497    for word in argv {
498        let word = word.as_ref();
499        if word.starts_with('-') {
500            break;
501        }
502        match spec.find_subcommand(word) {
503            Some(sub) => spec = sub,
504            None => break,
505        }
506    }
507    render_help(spec)
508}
509
510/// Render `--help` text for a command spec, formatted comparably to clap:
511/// about, usage, aliases, commands, arguments, options (with an auto
512/// `-h, --help`), and any `after_help`.
513pub fn render_help(spec: &CommandSpec) -> String {
514    let mut s = String::new();
515
516    if let Some(about) = &spec.about {
517        for line in wrap_text(about, HELP_WIDTH) {
518            s.push_str(&line);
519            s.push('\n');
520        }
521        s.push('\n');
522    }
523
524    // Usage line.
525    s.push_str(&format!("Usage: {}", spec.name));
526    if !spec.options.is_empty() {
527        s.push_str(" [OPTIONS]");
528    }
529    for p in &spec.positionals {
530        let token = if p.multiple {
531            format!("[{}]...", p.name)
532        } else if p.required {
533            format!("<{}>", p.name)
534        } else {
535            format!("[{}]", p.name)
536        };
537        s.push(' ');
538        s.push_str(&token);
539    }
540    if !spec.subcommands.is_empty() {
541        s.push_str(" <COMMAND>");
542    }
543    s.push('\n');
544
545    if !spec.aliases.is_empty() {
546        s.push_str(&format!("\nAliases: {}\n", spec.aliases.join(", ")));
547    }
548
549    if !spec.subcommands.is_empty() {
550        s.push_str("\nCommands:\n");
551        let rows: Vec<(String, String)> = spec
552            .subcommands
553            .iter()
554            .map(|c| {
555                let name = if c.aliases.is_empty() {
556                    c.name.clone()
557                } else {
558                    format!("{}, {}", c.name, c.aliases.join(", "))
559                };
560                (name, c.about.clone().unwrap_or_default())
561            })
562            .collect();
563        render_two_col(&mut s, &rows);
564    }
565
566    if !spec.positionals.is_empty() {
567        s.push_str("\nArguments:\n");
568        let rows: Vec<(String, String)> = spec
569            .positionals
570            .iter()
571            .map(|p| (format!("<{}>", p.name), p.help.clone().unwrap_or_default()))
572            .collect();
573        render_two_col(&mut s, &rows);
574    }
575
576    {
577        s.push_str("\nOptions:\n");
578        let mut rows: Vec<(String, String)> = spec
579            .options
580            .iter()
581            .map(|o| {
582                // Align long flags whether or not a short exists ("-x, " is 4 wide).
583                let mut f = match o.def.short {
584                    Some(sh) => format!("-{}, ", sh),
585                    None => "    ".to_string(),
586                };
587                f.push_str(&o.def.name);
588                if o.def.takes_value {
589                    f.push_str(&format!(" <{}>", o.def.value_name.as_deref().unwrap_or("VALUE")));
590                }
591                (f, o.def.help.clone().unwrap_or_default())
592            })
593            .collect();
594        rows.push(("-h, --help".to_string(), "Print help".to_string()));
595        render_two_col(&mut s, &rows);
596    }
597
598    if let Some(after) = &spec.after_help {
599        s.push('\n');
600        s.push_str(after.trim_end());
601        s.push('\n');
602    }
603
604    s
605}
606
607// ---------------------------------------------------------------------------
608// Completion bridge: CommandSpec -> CommandTree
609// ---------------------------------------------------------------------------
610
611/// Build a completion [`CommandTree`](crate::CommandTree) from a
612/// [`CommandSpec`] — the same spec that drives parsing and help. This replaces
613/// walking a `clap::Command`: one definition now feeds parse + help + complete.
614///
615/// `resolvers` maps a flag's canonical long token (e.g. `"--at"`) to the value
616/// completer to attach **per command** — only flags a command actually declares
617/// receive one, so nothing leaks (the same property the option registry gives).
618pub fn build_completion_tree(
619    spec: &CommandSpec,
620    resolvers: &std::collections::BTreeMap<String, crate::ValueProvider>,
621) -> crate::CommandTree {
622    let mut tree = crate::CommandTree::new(&spec.name);
623    tree.root = spec_to_node(spec, resolvers, "");
624    tree
625}
626
627/// `path` is the space-joined subcommand path to this spec, excluding the binary
628/// name (e.g. `"backends remove"`). Positional providers are keyed by that path
629/// in `resolvers`; space-separated keys never collide with `--flag` keys.
630fn spec_to_node(
631    spec: &CommandSpec,
632    resolvers: &std::collections::BTreeMap<String, crate::ValueProvider>,
633    path: &str,
634) -> crate::Node {
635    if spec.subcommands.is_empty() {
636        let value_flags: Vec<&str> =
637            spec.options.iter().filter(|o| o.def.takes_value).map(|o| o.def.name.as_str()).collect();
638        let boolean_flags: Vec<&str> =
639            spec.options.iter().filter(|o| !o.def.takes_value).map(|o| o.def.name.as_str()).collect();
640        let mut node = crate::Node::leaf_with_flags(&value_flags, &boolean_flags);
641        for o in &spec.options {
642            if let Some(h) = &o.def.help {
643                node = node.with_flag_help(&o.def.name, h);
644            }
645            // Short aliases participate in value-position detection
646            // and value completion exactly like the long form —
647            // `attach -c <TAB>` must complete the same values as
648            // `attach --config <TAB>`.
649            if let Some(c) = o.def.short {
650                node = node.with_short_alias(&format!("-{c}"), &o.def.name);
651            }
652            if o.def.takes_value {
653                // The option's own completer wins; otherwise fall back to a
654                // shared resolver registered for that flag name.
655                let provider = o
656                    .value_completion
657                    .clone()
658                    .or_else(|| resolvers.get(&o.def.name).cloned());
659                if let Some(p) = provider {
660                    node = node.with_value_provider(&o.def.name, p);
661                }
662            }
663        }
664        // Mutual exclusions, symmetrized: declaring `--with-dim`
665        // conflicts_with `--with-min-dim` on either side hides each
666        // from completion once the other is on the line.
667        for o in &spec.options {
668            for c in &o.def.conflicts_with {
669                node = node
670                    .with_flag_conflict(&o.def.name, c)
671                    .with_flag_conflict(c, &o.def.name);
672            }
673        }
674        // First-positional completion: a resolver registered under this command's
675        // full path (e.g. "backends remove"), applied only when the command
676        // actually takes a positional.
677        if !spec.positionals.is_empty()
678            && let Some(p) = resolvers.get(path).cloned() {
679                node = node
680                    .with_positional_provider(p)
681                    .with_positional_slots(spec.positionals.len());
682            }
683        node.with_stability(spec.stability)
684    } else {
685        let mut node = crate::Node::empty_group();
686        for sub in &spec.subcommands {
687            let child_path =
688                if path.is_empty() { sub.name.clone() } else { format!("{path} {}", sub.name) };
689            node = node.with_child(&sub.name, spec_to_node(sub, resolvers, &child_path));
690        }
691        node.with_stability(spec.stability)
692    }
693}
694
695#[cfg(test)]
696mod tests {
697    use super::*;
698
699    fn vopt(name: &str) -> OptionSpec {
700        OptionSpec::new(OptionDef::value(name))
701    }
702    fn fopt(name: &str) -> OptionSpec {
703        OptionSpec::new(OptionDef::flag(name))
704    }
705
706    fn datasets_ping() -> CommandSpec {
707        CommandSpec::new("ping")
708            .about("Ping a remote dataset")
709            .option(vopt("--at").def_multiple())
710            .option(OptionSpec::new(OptionDef::value("--dataset")).required(true))
711            .option(OptionSpec::new(OptionDef::value("--profile")).default("default"))
712    }
713
714    // small helper so tests can flip the OptionDef.multiple bit inline
715    impl OptionSpec {
716        fn def_multiple(mut self) -> Self {
717            self.def = self.def.multiple(true);
718            self
719        }
720    }
721
722    fn argv(s: &[&str]) -> Vec<String> {
723        s.iter().map(|x| x.to_string()).collect()
724    }
725
726    /// An exact/min/max filter family: the exact flag declares the
727    /// conflicts; symmetry is the bridge's job.
728    fn dim_family() -> CommandSpec {
729        CommandSpec::new("list")
730            .option(OptionSpec::new(
731                OptionDef::value("--with-dim")
732                    .conflicts_with(&["--with-min-dim", "--with-max-dim"]),
733            ))
734            .option(vopt("--with-min-dim"))
735            .option(vopt("--with-max-dim"))
736    }
737
738    #[test]
739    fn conflicting_flags_withheld_from_completion_both_directions() {
740        let spec = dim_family();
741        let tree = build_completion_tree(&spec, &std::collections::BTreeMap::new());
742
743        // Exact on the line → neither range flag is offered.
744        let cands = crate::complete(&tree, &["list", "--with-dim", "5", "--with-"]);
745        assert!(!cands.iter().any(|c| c == "--with-min-dim"), "{cands:?}");
746        assert!(!cands.iter().any(|c| c == "--with-max-dim"), "{cands:?}");
747
748        // A range flag on the line → the exact flag is not offered,
749        // but the other end of the range still is.
750        let cands = crate::complete(&tree, &["list", "--with-min-dim", "4", "--with-"]);
751        assert!(!cands.iter().any(|c| c == "--with-dim"), "{cands:?}");
752        assert!(cands.iter().any(|c| c == "--with-max-dim"), "{cands:?}");
753    }
754
755    #[test]
756    fn conflicting_options_rejected_at_parse_in_either_order() {
757        let spec = dim_family();
758        for words in [
759            ["--with-dim", "5", "--with-min-dim", "4"],
760            ["--with-min-dim", "4", "--with-dim", "5"],
761        ] {
762            let err = parse(&spec, &argv(&words)).unwrap_err();
763            assert!(
764                matches!(err, ParseError::ConflictingOptions { .. }),
765                "expected conflict error, got {err:?}"
766            );
767        }
768        // The range pair itself is NOT a conflict.
769        assert!(parse(&spec, &argv(&["--with-min-dim", "4", "--with-max-dim", "9"])).is_ok());
770    }
771
772    #[test]
773    fn parses_value_space_and_equals_forms() {
774        let spec = datasets_ping();
775        let p = parse(&spec, &argv(&["--dataset", "glove", "--at", "1"])).unwrap();
776        assert_eq!(p.value("--dataset"), Some("glove"));
777        assert_eq!(p.values("--at"), &["1".to_string()]);
778        let p2 = parse(&spec, &argv(&["--dataset=glove"])).unwrap();
779        assert_eq!(p2.value("--dataset"), Some("glove"));
780    }
781
782    #[test]
783    fn repeatable_option_accumulates() {
784        let spec = datasets_ping();
785        let p = parse(&spec, &argv(&["--dataset", "d", "--at", "1", "--at", "2"])).unwrap();
786        assert_eq!(p.values("--at"), &["1".to_string(), "2".to_string()]);
787    }
788
789    #[test]
790    fn default_applies_when_absent() {
791        let spec = datasets_ping();
792        let p = parse(&spec, &argv(&["--dataset", "d"])).unwrap();
793        assert_eq!(p.value("--profile"), Some("default"));
794    }
795
796    #[test]
797    fn required_option_missing_errors() {
798        let spec = datasets_ping();
799        let err = parse(&spec, &argv(&["--at", "1"])).unwrap_err();
800        assert_eq!(err, ParseError::MissingRequiredOption { command: "ping".into(), flag: "--dataset".into() });
801    }
802
803    #[test]
804    fn unknown_flag_errors() {
805        let spec = datasets_ping();
806        let err = parse(&spec, &argv(&["--dataset", "d", "--nope"])).unwrap_err();
807        assert!(matches!(err, ParseError::UnknownFlag { .. }));
808    }
809
810    #[test]
811    fn boolean_flag_takes_no_value() {
812        let spec = CommandSpec::new("list").option(fopt("--verbose"));
813        let p = parse(&spec, &argv(&["--verbose"])).unwrap();
814        assert!(p.has_flag("--verbose"));
815        // The following word is a positional, not the flag's value.
816        let p2 = parse(&spec, &argv(&["--verbose", "x"])).unwrap();
817        assert_eq!(p2.positionals(), &["x".to_string()]);
818    }
819
820    #[test]
821    fn double_dash_ends_options() {
822        let spec = CommandSpec::new("run").option(fopt("--flag"));
823        let p = parse(&spec, &argv(&["--", "--flag"])).unwrap();
824        assert!(!p.has_flag("--flag"));
825        assert_eq!(p.positionals(), &["--flag".to_string()]);
826    }
827
828    #[test]
829    fn subcommand_dispatch_and_short_value() {
830        let spec = CommandSpec::new("datasets")
831            .subcommand(datasets_ping())
832            .subcommand(
833                CommandSpec::new("derive")
834                    .option(OptionSpec::new(OptionDef::value("--output").short('o')).required(true)),
835            );
836        let p = parse(&spec, &argv(&["derive", "-o", "/tmp/out"])).unwrap();
837        let (name, sub) = p.subcommand().unwrap();
838        assert_eq!(name, "derive");
839        assert_eq!(sub.value("--output"), Some("/tmp/out"));
840    }
841
842    #[test]
843    fn unknown_subcommand_errors() {
844        let spec = CommandSpec::new("datasets").subcommand(datasets_ping());
845        let err = parse(&spec, &argv(&["frobnicate"])).unwrap_err();
846        assert!(matches!(err, ParseError::UnknownSubcommand { .. }));
847    }
848
849    #[test]
850    fn completion_tree_built_from_spec() {
851        let spec = CommandSpec::new("veks").subcommand(
852            CommandSpec::new("datasets")
853                .subcommand(
854                    CommandSpec::new("ping")
855                        .option(vopt("--at").def_multiple())
856                        .option(vopt("--dataset")),
857                )
858                .subcommand(CommandSpec::new("list").option(fopt("--verbose"))),
859        );
860        let mut resolvers: std::collections::BTreeMap<String, crate::ValueProvider> =
861            std::collections::BTreeMap::new();
862        resolvers.insert(
863            "--at".to_string(),
864            crate::fn_provider(|_p, _c| vec!["1".to_string(), "2".to_string()]),
865        );
866        let tree = build_completion_tree(&spec, &resolvers);
867
868        // Per-command flags: ping has --at/--dataset, list has --verbose only.
869        let ping_flags = crate::complete(&tree, &["veks", "datasets", "ping", "--"]);
870        assert!(ping_flags.contains(&"--at".to_string()));
871        assert!(ping_flags.contains(&"--dataset".to_string()));
872        let list_flags = crate::complete(&tree, &["veks", "datasets", "list", "--"]);
873        assert!(list_flags.contains(&"--verbose".to_string()));
874        assert!(!list_flags.contains(&"--at".to_string()), "--at must not leak onto list");
875
876        // Value completion: the --at resolver fires on ping.
877        let at_vals = crate::complete(&tree, &["veks", "datasets", "ping", "--at", ""]);
878        assert_eq!(at_vals, vec!["1".to_string(), "2".to_string()]);
879    }
880
881    /// A command with two positional slots (`config set <key>
882    /// <value>`) completes the first slot, then the second — the
883    /// provider sees the entered positionals and decides. The engine
884    /// previously hard-gated providers to the first slot only.
885    #[test]
886    fn two_slot_positional_completion() {
887        let spec = CommandSpec::new("vectordata").subcommand(
888            CommandSpec::new("config").subcommand(
889                CommandSpec::new("set")
890                    .positional(PositionalSpec::new("key"))
891                    .positional(PositionalSpec::new("value"))
892                    .option(OptionSpec::new(OptionDef::flag("--force"))),
893            ),
894        );
895        let mut resolvers: std::collections::BTreeMap<String, crate::ValueProvider> =
896            std::collections::BTreeMap::new();
897        resolvers.insert(
898            "config set".to_string(),
899            crate::fn_provider(|p, ctx| {
900                let positionals: Vec<&&str> =
901                    ctx.iter().filter(|w| !w.starts_with('-')).collect();
902                let cands: Vec<&str> = match positionals.first() {
903                    None => vec!["cache"],
904                    Some(&&"cache") => vec!["auto", "/data/vectordata-cache"],
905                    Some(_) => vec![],
906                };
907                cands.iter()
908                    .filter(|c| p.is_empty() || c.starts_with(p))
909                    .map(|c| c.to_string())
910                    .collect()
911            }),
912        );
913        let tree = build_completion_tree(&spec, &resolvers);
914
915        // Slot 0: keys.
916        let keys = crate::complete(&tree, &["vectordata", "config", "set", ""]);
917        assert!(keys.contains(&"cache".to_string()), "{keys:?}");
918        // Slot 1: values for the entered key.
919        let vals = crate::complete(&tree, &["vectordata", "config", "set", "cache", ""]);
920        assert!(vals.contains(&"auto".to_string()), "{vals:?}");
921        assert!(vals.contains(&"/data/vectordata-cache".to_string()), "{vals:?}");
922        // Slot 2 doesn't exist: no positional candidates.
923        let done = crate::complete(&tree, &["vectordata", "config", "set", "cache", "auto", ""]);
924        assert!(!done.contains(&"auto".to_string()), "{done:?}");
925        // A flag between positionals doesn't shift slot counting.
926        let vals2 = crate::complete(&tree, &["vectordata", "config", "set", "--force", "cache", ""]);
927        assert!(vals2.contains(&"auto".to_string()), "{vals2:?}");
928    }
929
930    /// A short flag at the previous-word position must value-complete
931    /// exactly like its long form. The value-position branch used to
932    /// require a `--` prefix on the previous word, so `attach -c
933    /// <TAB>` silently fell through to flag/subcommand candidates.
934    #[test]
935    fn short_flag_value_completes_like_long_form() {
936        let spec = CommandSpec::new("veks").subcommand(
937            CommandSpec::new("attach")
938                .option(OptionSpec::new(OptionDef::value("--config").short('c')))
939                .option(OptionSpec::new(OptionDef::flag("--verbose").short('v'))),
940        );
941        let mut resolvers: std::collections::BTreeMap<String, crate::ValueProvider> =
942            std::collections::BTreeMap::new();
943        resolvers.insert(
944            "--config".to_string(),
945            crate::fn_provider(|p, _c| {
946                ["dev.yaml", "prod.yaml"].iter()
947                    .filter(|v| v.starts_with(p))
948                    .map(|v| v.to_string())
949                    .collect()
950            }),
951        );
952        let tree = build_completion_tree(&spec, &resolvers);
953
954        // Long and short forms produce identical value candidates.
955        let long_vals = crate::complete(&tree, &["veks", "attach", "--config", ""]);
956        let short_vals = crate::complete(&tree, &["veks", "attach", "-c", ""]);
957        assert_eq!(long_vals, vec!["dev.yaml".to_string(), "prod.yaml".to_string()]);
958        assert_eq!(short_vals, long_vals,
959            "short flag must value-complete like its long form");
960
961        // Prefix filtering flows through the short form too.
962        let filtered = crate::complete(&tree, &["veks", "attach", "-c", "pro"]);
963        assert_eq!(filtered, vec!["prod.yaml".to_string()]);
964
965        // A short BOOLEAN flag is not a value position — candidates
966        // are the node's flags, not values.
967        let after_bool = crate::complete(&tree, &["veks", "attach", "-v", "--"]);
968        assert!(after_bool.contains(&"--config".to_string()),
969            "boolean short must not open a value position: {after_bool:?}");
970
971        // An unregistered single-dash word (negative number value)
972        // is not mistaken for a flag.
973        let after_number = crate::complete(&tree, &["veks", "attach", "-5", "--"]);
974        assert!(after_number.contains(&"--config".to_string()),
975            "unregistered -word must not open a value position: {after_number:?}");
976    }
977}
978
979#[cfg(test)]
980mod derive_tests {
981    use crate::VeksCli;
982    use veks_completion_derive::VeksCli;
983
984    fn argv(s: &[&str]) -> Vec<String> {
985        s.iter().map(|x| x.to_string()).collect()
986    }
987
988    #[derive(VeksCli, Debug, PartialEq)]
989    #[command(about = "Ping a remote dataset")]
990    struct Ping {
991        /// Catalog locations
992        #[arg(long = "at")]
993        at: Vec<String>,
994        #[arg(long)]
995        dataset: String,
996        #[arg(long, default = "default")]
997        profile: String,
998        #[arg(long)]
999        verbose: bool,
1000    }
1001
1002    #[test]
1003    fn derive_struct_spec_and_extract() {
1004        let spec = Ping::veks_command_spec("ping");
1005        // spec carries the right shapes
1006        assert_eq!(spec.about.as_deref(), Some("Ping a remote dataset"));
1007        let p = crate::cli::parse(
1008            &spec,
1009            &argv(&["--dataset", "glove", "--at", "1", "--at", "2", "--verbose"]),
1010        )
1011        .unwrap();
1012        let ping = Ping::veks_from_parsed(&p).unwrap();
1013        assert_eq!(
1014            ping,
1015            Ping {
1016                at: vec!["1".into(), "2".into()],
1017                dataset: "glove".into(),
1018                profile: "default".into(),
1019                verbose: true,
1020            }
1021        );
1022    }
1023
1024    #[test]
1025    fn derive_typed_conversion_and_default() {
1026        #[derive(VeksCli, Debug, PartialEq)]
1027        struct Run {
1028            #[arg(long, default = "4")]
1029            threads: usize,
1030            #[arg(long)]
1031            tag: Option<String>,
1032        }
1033        let spec = Run::veks_command_spec("run");
1034        let p = crate::cli::parse(&spec, &argv(&["--threads", "8"])).unwrap();
1035        let run = Run::veks_from_parsed(&p).unwrap();
1036        assert_eq!(run, Run { threads: 8, tag: None });
1037        // default applies
1038        let p2 = crate::cli::parse(&spec, &argv(&[])).unwrap();
1039        assert_eq!(Run::veks_from_parsed(&p2).unwrap().threads, 4);
1040        // bad value → InvalidValue
1041        let p3 = crate::cli::parse(&spec, &argv(&["--threads", "abc"])).unwrap();
1042        assert!(matches!(
1043            Run::veks_from_parsed(&p3),
1044            Err(crate::cli::ParseError::InvalidValue { .. })
1045        ));
1046    }
1047
1048    #[derive(VeksCli, Debug, PartialEq)]
1049    enum Cmd {
1050        Ping(Ping),
1051        /// List datasets
1052        List {
1053            #[arg(long)]
1054            verbose: bool,
1055        },
1056    }
1057
1058    // Derive-macro fixtures: the fields exist to shape the generated
1059    // completion spec (field name → flag name); nothing reads their
1060    // values at runtime.
1061    #[derive(VeksCli)]
1062    #[command(stability = "preview")]
1063    struct PreviewArgs {
1064        #[arg(long)]
1065        #[allow(dead_code)]
1066        x: bool,
1067    }
1068
1069    #[derive(VeksCli)]
1070    enum StabilityCmd {
1071        /// Stable by default (no attribute).
1072        Steady {
1073            #[arg(long)]
1074            #[allow(dead_code)]
1075            a: bool,
1076        },
1077        #[command(stability = "experimental")]
1078        Risky {
1079            #[arg(long)]
1080            #[allow(dead_code)]
1081            b: bool,
1082        },
1083    }
1084
1085    #[test]
1086    fn derive_reads_command_stability() {
1087        use crate::Stability;
1088        // Type-level `#[command(stability = "preview")]`.
1089        assert_eq!(
1090            PreviewArgs::veks_command_spec("preview-args").stability,
1091            Stability::Preview
1092        );
1093        // Variant-level: explicit on one, default (Stable) on the other.
1094        let spec = StabilityCmd::veks_command_spec("app");
1095        let steady = spec.subcommands.iter().find(|c| c.name == "steady").unwrap();
1096        let risky = spec.subcommands.iter().find(|c| c.name == "risky").unwrap();
1097        assert_eq!(steady.stability, Stability::Stable);
1098        assert_eq!(risky.stability, Stability::Experimental);
1099    }
1100
1101    #[test]
1102    fn derive_enum_subcommand_dispatch() {
1103        let spec = Cmd::veks_command_spec("veks");
1104        assert!(spec.subcommand_required);
1105        // tuple variant delegating to a struct
1106        let p = crate::cli::parse(&spec, &argv(&["ping", "--dataset", "d"])).unwrap();
1107        match Cmd::veks_from_parsed(&p).unwrap() {
1108            Cmd::Ping(ping) => assert_eq!(ping.dataset, "d"),
1109            _ => panic!("expected Ping"),
1110        }
1111        // named-field variant
1112        let p2 = crate::cli::parse(&spec, &argv(&["list", "--verbose"])).unwrap();
1113        assert_eq!(Cmd::veks_from_parsed(&p2).unwrap(), Cmd::List { verbose: true });
1114    }
1115
1116    #[test]
1117    fn completion_hides_commands_below_stability_threshold() {
1118        use crate::{CommandSpec, Stability};
1119        let spec = CommandSpec::new("app")
1120            .subcommand(CommandSpec::new("stable-cmd"))
1121            .subcommand(CommandSpec::new("preview-cmd").stability(Stability::Preview))
1122            .subcommand(CommandSpec::new("exp-cmd").stability(Stability::Experimental));
1123        let resolvers = std::collections::BTreeMap::new();
1124        let mut tree = crate::cli::build_completion_tree(&spec, &resolvers);
1125
1126        let has = |t: &crate::CommandTree, name: &str| {
1127            crate::complete_at_tap_with_raw(t, &["app", ""], 1, "app ", 4)
1128                .iter()
1129                .any(|c| c.split('\t').next() == Some(name))
1130        };
1131
1132        // Default threshold (Preview): stable + preview shown, experimental hidden.
1133        tree.min_stability = Stability::Preview;
1134        assert!(has(&tree, "stable-cmd"));
1135        assert!(has(&tree, "preview-cmd"));
1136        assert!(!has(&tree, "exp-cmd"), "experimental hidden at the default threshold");
1137
1138        // Experimental threshold: everything, including experimental.
1139        tree.min_stability = Stability::Experimental;
1140        assert!(has(&tree, "exp-cmd"), "experimental shown when threshold is lowered");
1141
1142        // Stable threshold: only stable.
1143        tree.min_stability = Stability::Stable;
1144        assert!(has(&tree, "stable-cmd"));
1145        assert!(!has(&tree, "preview-cmd"), "preview hidden at the stable threshold");
1146    }
1147
1148    #[test]
1149    fn positional_provider_completes_by_command_path() {
1150        use crate::{CommandSpec, PositionalSpec, ValueProvider};
1151        // app -> backends -> {remove <name>, list}
1152        let spec = CommandSpec::new("app").subcommand(
1153            CommandSpec::new("backends")
1154                .subcommand(CommandSpec::new("remove").positional(PositionalSpec::new("name")))
1155                .subcommand(CommandSpec::new("list")),
1156        );
1157        let provider: ValueProvider = std::sync::Arc::new(|partial: &str, _: &[&str]| {
1158            ["store", "archive"]
1159                .iter()
1160                .filter(|s| s.starts_with(partial))
1161                .map(|s| s.to_string())
1162                .collect()
1163        });
1164        // Positional resolver keyed by the FULL command path.
1165        let mut resolvers = std::collections::BTreeMap::new();
1166        resolvers.insert("backends remove".to_string(), provider);
1167        let tree = crate::cli::build_completion_tree(&spec, &resolvers);
1168
1169        // `app backends remove <TAB>` → the positional's candidates.
1170        let all = crate::complete(&tree, &["app", "backends", "remove", ""]);
1171        assert!(all.contains(&"store".to_string()) && all.contains(&"archive".to_string()), "{all:?}");
1172
1173        // Prefix filter applies at the positional slot.
1174        let pref = crate::complete(&tree, &["app", "backends", "remove", "st"]);
1175        assert!(pref.contains(&"store".to_string()) && !pref.contains(&"archive".to_string()), "{pref:?}");
1176
1177        // Keyed by full path: a sibling command does NOT inherit the provider.
1178        let other = crate::complete(&tree, &["app", "backends", "list", ""]);
1179        assert!(!other.contains(&"store".to_string()), "sibling must not complete: {other:?}");
1180    }
1181
1182    #[test]
1183    fn parse_skips_triple_dash_engine_tokens() {
1184        use crate::{CommandSpec, OptionDef, OptionSpec};
1185        // A `---experimental` left on the line by tab-completion must not break
1186        // execution — `cli::parse` skips all `---…` tokens.
1187        let spec = CommandSpec::new("app").subcommand(
1188            CommandSpec::new("go").option(OptionSpec::new(OptionDef::flag("--verbose"))),
1189        );
1190        let p = crate::cli::parse(&spec, &argv(&["---experimental", "go", "--verbose"])).unwrap();
1191        let (sub, sp) = p.subcommand().unwrap();
1192        assert_eq!(sub, "go");
1193        assert!(sp.has_flag("--verbose"));
1194    }
1195
1196    #[test]
1197    fn stability_prefix_sets_threshold_and_strips_meta() {
1198        use crate::Stability;
1199        let (t, words) = crate::split_stability_prefix(
1200            vec!["---experimental".into(), "datasets".into()],
1201            Stability::Preview,
1202        );
1203        assert_eq!(t, Stability::Experimental);
1204        assert_eq!(words, vec!["datasets".to_string()]);
1205
1206        // No threshold token → the default is kept, non-meta words untouched.
1207        let (t2, w2) = crate::split_stability_prefix(vec!["x".into()], Stability::Preview);
1208        assert_eq!(t2, Stability::Preview);
1209        assert_eq!(w2, vec!["x".to_string()]);
1210    }
1211}