Skip to main content

oxdock_parser/
command.rs

1use crate::ast::{Arg, Expr, StepKind};
2use crate::error::{ParseError, SpanContext};
3use anyhow::{Result, anyhow, bail};
4
5/// Metadata for a single command argument.
6pub struct ArgSpec {
7    pub name: &'static str,
8    pub arg_type: ArgType,
9    pub description: &'static str,
10    pub io: IoDirection,
11    pub index: usize,
12    pub required: bool,
13    pub fallback_stream: Option<Stream>,
14}
15
16/// Closed vocabulary for argument value types: every variant names a
17/// type explicitly present in the app, so the reference table never
18/// lists a type that does not exist. Shapes (`$var` targets, `KEY=value`
19/// pairs) are not types: the central validator only checks value types
20/// and arity, while each command's `lower` enforces its own shapes with
21/// command-specific errors.
22/// [`ArgType::Any`] is the single exception: it renders as `<any>`,
23/// visibly a placeholder rather than a type name.
24/// [`ArgType::OneOf`] renders its inline options, likewise self-describing.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum ArgType {
27    String,
28    Path,
29    Int,
30    Duration,
31    /// A `$variable` that must hold a LIST at runtime. Non-variable
32    /// expressions fail at lower time; renders linked as `LIST`.
33    List,
34    /// Any evaluated value (plus stream markers like `stdout` where the
35    /// command accepts them). Renders unlinked as `<any>`, visibly a
36    /// placeholder rather than a type name.
37    Any,
38    /// Inline alternation for one-off enums (e.g. `SNAPSHOT|LOCAL`).
39    /// Self-describing, so it renders unlinked.
40    OneOf(&'static [&'static str]),
41    /// Trailing variadic repetition (e.g. `RUN`'s `string...`).
42    Rest(&'static ArgType),
43}
44
45impl ArgType {
46    /// Table-cell label for the argument table's Type column.
47    /// Canonical value types use their descriptor names; `ANY` renders
48    /// as `<any>` and inline alternations render their options, so
49    /// neither reads as a standalone type. Shapes (`$var` targets,
50    /// `KEY=value` pairs) are validated in each command's `lower`, and
51    /// the shape requirement lives in the argument description and
52    /// command syntax.
53    pub fn label(&self) -> String {
54        match self {
55            ArgType::String => "STRING".to_string(),
56            ArgType::Path => "PATH".to_string(),
57            ArgType::Int => "INT".to_string(),
58            ArgType::Duration => "DURATION".to_string(),
59            ArgType::List => "LIST".to_string(),
60            ArgType::Any => "<any>".to_string(),
61            ArgType::OneOf(options) => options.join("|"),
62            ArgType::Rest(inner) => format!("{}...", inner.label()),
63        }
64    }
65
66    /// Anchor of the type's reference section. Only types with a value-type
67    /// reference section link; `<any>` and inline alternations render
68    /// unlinked.
69    pub fn anchor(&self) -> Option<String> {
70        match self {
71            ArgType::String => Some(crate::value::type_anchor("STRING")),
72            ArgType::Path => Some(crate::value::type_anchor("PATH")),
73            ArgType::Int => Some(crate::value::type_anchor("INT")),
74            ArgType::Duration => Some(crate::value::type_anchor("DURATION")),
75            ArgType::List => Some(crate::value::type_anchor("LIST")),
76            ArgType::Any => None,
77            ArgType::OneOf(_) => None,
78            ArgType::Rest(inner) => inner.anchor(),
79        }
80    }
81
82    /// Validate a statically-known literal against this type.
83    /// Templates and variables are never passed here : see `check_arg`.
84    pub fn validate_literal(&self, literal: &str) -> Result<()> {
85        match self {
86            ArgType::String | ArgType::Path | ArgType::Any => Ok(()),
87            ArgType::Int => strip_digit_separators(literal)
88                .parse::<i64>()
89                .map(|_| ())
90                .map_err(|_| anyhow!("expected int, got {literal:?}")),
91            ArgType::Duration => parse_duration(literal).map(|_| ()),
92            ArgType::List => {
93                if literal.starts_with('$') {
94                    Ok(())
95                } else {
96                    bail!("expected $var, got {literal:?}")
97                }
98            }
99            ArgType::OneOf(options) => {
100                // Exact match only: lowercase spellings are rejected so
101                // keyword arguments stay uppercase across the DSL.
102                if options.contains(&literal) {
103                    Ok(())
104                } else {
105                    bail!("expected one of {}, got {literal:?}", options.join("|"))
106                }
107            }
108            ArgType::Rest(inner) => inner.validate_literal(literal),
109        }
110    }
111
112    /// Classify one positional arg for lower-time checking.
113    /// `Static` literals validate now; templates, variables (except a
114    /// `$var` where `List` is required), and mixed fragments defer to the
115    /// runtime resolvers, which see interpolated values.
116    pub fn check_arg(&self, arg: &Arg) -> Result<CheckOutcome> {
117        match arg {
118            Arg::String(s, _) if !s.contains("{{") => {
119                self.validate_literal(s)?;
120                Ok(CheckOutcome::Static)
121            }
122            Arg::String(_, _) => Ok(CheckOutcome::Deferred),
123            Arg::Parts(_) => Ok(CheckOutcome::Deferred),
124            Arg::Expr(Expr::Var(_)) => {
125                if matches!(*self, ArgType::List) {
126                    Ok(CheckOutcome::Static)
127                } else {
128                    Ok(CheckOutcome::Deferred)
129                }
130            }
131            Arg::Expr(_) => {
132                if matches!(*self, ArgType::List) {
133                    bail!("expected $var, got expression {}", arg.render())
134                } else {
135                    Ok(CheckOutcome::Deferred)
136                }
137            }
138        }
139    }
140}
141
142/// Lower-time checking outcome for one positional arg.
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum CheckOutcome {
145    /// Validated now; nothing deferred.
146    Static,
147    /// Unknowable until runtime (template, variable, or fragment);
148    /// runtime resolvers enforce the type on the resolved value.
149    Deferred,
150}
151
152/// Validate positional args against a command's declared specs.
153/// Missing required positionals, statically-known type violations, and
154/// trailing positionals beyond a fixed-arity spec all fail here;
155/// templates, variables, and fragments defer to the runtime resolvers.
156/// A trailing `Rest` spec absorbs any number of positionals.
157pub fn validate_positionals_against_meta(
158    cmd_name: &str,
159    specs: &[ArgSpec],
160    args: &[Arg],
161) -> Result<(), ParseError> {
162    let has_rest = specs
163        .last()
164        .is_some_and(|s| matches!(s.arg_type, ArgType::Rest(_)));
165
166    if !has_rest && args.len() > specs.len() {
167        return Err(ParseError::validation(
168            cmd_name,
169            format!(
170                "invalid syntax for command {cmd_name}: expects at most {} positional argument(s), got {}",
171                specs.len(),
172                args.len()
173            ),
174            &SpanContext::line_only(0),
175        ));
176    }
177
178    for spec in specs {
179        if let ArgType::Rest(inner) = spec.arg_type {
180            // Variadic tail: every trailing positional checks against
181            // the inner type, not just the first.
182            let tail = args.get(spec.index..).unwrap_or(&[]);
183            if tail.is_empty() && spec.required {
184                return Err(ParseError::validation(
185                    cmd_name,
186                    format!(
187                        "invalid syntax for command {cmd_name}: requires argument `{}`",
188                        spec.name
189                    ),
190                    &SpanContext::line_only(0),
191                ));
192            }
193            for arg in tail {
194                check_one(cmd_name, spec, inner, arg)?;
195            }
196            return Ok(());
197        }
198        match args.get(spec.index) {
199            Some(arg) => check_one(cmd_name, spec, &spec.arg_type, arg)?,
200            None if spec.required => {
201                return Err(ParseError::validation(
202                    cmd_name,
203                    format!(
204                        "invalid syntax for command {cmd_name}: requires argument `{}`",
205                        spec.name
206                    ),
207                    &SpanContext::line_only(0),
208                ));
209            }
210            None => {}
211        }
212    }
213    Ok(())
214}
215
216fn check_one(
217    cmd_name: &str,
218    spec: &ArgSpec,
219    arg_type: &ArgType,
220    arg: &Arg,
221) -> Result<(), ParseError> {
222    match arg_type.check_arg(arg) {
223        Ok(_) => Ok(()),
224        Err(e) => Err(ParseError::validation(
225            cmd_name,
226            format!(
227                "invalid syntax for command {cmd_name}: argument `{}` got {} — {e:#}",
228                spec.name,
229                arg.render()
230            ),
231            &SpanContext::line_only(0),
232        )),
233    }
234}
235
236/// Strip one layer of surrounding `"` or `'` quotes (both kinds, everywhere).
237pub fn strip_surrounding_quotes(value: &str) -> &str {
238    value
239        .strip_prefix('"')
240        .and_then(|s| s.strip_suffix('"'))
241        .or_else(|| value.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
242        .unwrap_or(value)
243}
244
245/// Single-token `KEY=value` split for direct `lower_command` callers and
246/// exotic keys the grammar cannot classify (single tokens only : no whitespace
247/// reassembly, so the quoted-space corruption class cannot arise here).
248/// Returns `Ok(None)` when there is no `=`.
249pub fn split_assignment(text: &str) -> Result<Option<(String, Arg)>> {
250    let Some((key, raw)) = text.split_once('=') else {
251        return Ok(None);
252    };
253    if key.is_empty() {
254        bail!("assignment requires KEY=value format");
255    }
256    Ok(Some((
257        key.to_string(),
258        Arg::String(strip_surrounding_quotes(raw).to_string(), false),
259    )))
260}
261
262/// Parse a TIMEOUT duration token (`500ms`, `10s`, `2m`, `1h`; a bare
263/// number means seconds). One optional space may separate the number
264/// from its unit (`30 s`), like byte counts: bare DSL tokens still
265/// split upstream, so this covers quoted strings and variables.
266pub fn parse_duration(s: &str) -> Result<std::time::Duration> {
267    let s = join_single_space(s)?;
268    let (digits, unit_ms): (&str, u64) = if let Some(v) = s.strip_suffix("ms") {
269        (v, 1)
270    } else if let Some(v) = s.strip_suffix('s') {
271        (v, 1_000)
272    } else if let Some(v) = s.strip_suffix('m') {
273        (v, 60_000)
274    } else if let Some(v) = s.strip_suffix('h') {
275        (v, 3_600_000)
276    } else {
277        (s.as_str(), 1_000)
278    };
279    let n: u64 = digits
280        .parse()
281        .map_err(|_| anyhow!("invalid TIMEOUT duration: {s}"))?;
282    let millis = n
283        .checked_mul(unit_ms)
284        .ok_or_else(|| anyhow!("TIMEOUT duration out of range: {s}"))?;
285    if millis == 0 {
286        bail!("TIMEOUT duration must be positive, got: {s}");
287    }
288    Ok(std::time::Duration::from_millis(millis))
289}
290
291/// Canonical display for a duration: largest exact unit (`500ms`, `10s`,
292/// `2m`, `1h`), falling back to milliseconds. Round-trips through
293/// [`parse_duration`].
294pub fn format_duration(d: &std::time::Duration) -> String {
295    let millis = d.as_millis();
296    if millis.is_multiple_of(3_600_000) {
297        format!("{}h", millis / 3_600_000)
298    } else if millis.is_multiple_of(60_000) {
299        format!("{}m", millis / 60_000)
300    } else if millis.is_multiple_of(1_000) {
301        format!("{}s", millis / 1_000)
302    } else {
303        format!("{millis}ms")
304    }
305}
306
307/// Strip Rust-style `_` digit separators when well-formed (digits and
308/// at most one `.`, separators only between digits, never leading,
309/// trailing, or doubled, with an optional leading `+`/`-` sign passing
310/// through for signed domains); otherwise return the text untouched so
311/// the caller's own parse decides. The single rule behind every
312/// string-to-number coercion (`INT` validation, assertion operands,
313/// byte counts), so `1_000_000` and `-1_000` read identically everywhere
314/// while `1__0` and `1_` never silently become numbers. Unsigned
315/// callers still reject `-` at parse time; `+` parses like every other
316/// Rust-parsed number in the DSL.
317pub fn strip_digit_separators(text: &str) -> String {
318    let body = text
319        .strip_prefix('+')
320        .or_else(|| text.strip_prefix('-'))
321        .unwrap_or(text);
322    let bytes = body.as_bytes();
323    let well_formed = !body.is_empty()
324        && bytes[0].is_ascii_digit()
325        && bytes[bytes.len() - 1].is_ascii_digit()
326        && bytes
327            .iter()
328            .all(|b| b.is_ascii_digit() || *b == b'_' || *b == b'.')
329        && !body.contains("__")
330        && body.bytes().filter(|b| *b == b'.').count() <= 1;
331    if well_formed {
332        text.replace('_', "")
333    } else {
334        text.to_string()
335    }
336}
337
338/// Join a numeric token whose unit may ride separated by one space
339/// (`1 GB`, `30 s`): surrounding whitespace trims, and exactly two
340/// whitespace-separated parts rejoin digit-run plus unit. Anything else
341/// (three parts, a split digit run like `1 0`, an empty string) fails:
342/// the space may only separate the number from its unit, never split
343/// the digits themselves.
344fn join_single_space(s: &str) -> Result<String> {
345    let mut parts = s.split_whitespace();
346    let (first, second) = (parts.next(), parts.next());
347    if parts.next().is_some() {
348        bail!("invalid numeric token: {s}");
349    }
350    match (first, second) {
351        (Some(digits), None) => Ok(digits.to_string()),
352        (Some(digits), Some(unit))
353            if digits
354                .strip_prefix('+')
355                .unwrap_or(digits)
356                .bytes()
357                .all(|b| b.is_ascii_digit() || b == b'_')
358                && unit.bytes().all(|b| b.is_ascii_alphabetic()) =>
359        {
360            Ok(format!("{digits}{unit}"))
361        }
362        _ => bail!("invalid numeric token: {s}"),
363    }
364}
365
366/// Parse a byte-count token (`500`, `500B`, `10KB`, `2MB`, `1GB`, `1TB`
367/// decimal; `10KIB`, `2MIB`, `1GIB`, `1TIB` binary; a bare number means
368/// bytes). Digit runs may group with `_` separators (`1_000_000`),
369/// Rust-literal style: leading, trailing, and doubled separators fail.
370/// One optional space may separate the number from its unit (`1 GB`);
371/// anything more stays rejected, and bare DSL tokens still split
372/// upstream (this covers quoted strings, variables, and flag values).
373/// Fractions stay rejected by design (`1.5GB` fails: float parsing
374/// cannot hold exact byte boundaries past 2^53, and every sub-unit has
375/// an exact integer spelling like `1500MB`). Matching is
376/// case-insensitive (`1gb`, `1GB`, and `1Gb` agree), mirroring
377/// [`parse_duration`]: digits only, no fractions, no signs.
378pub fn parse_byte_count(s: &str) -> Result<u64> {
379    let compact = join_single_space(s)?;
380    let s = compact.as_str();
381    let lower = s.to_ascii_lowercase();
382    let (digits, unit): (&str, u64) = if let Some(v) = lower.strip_suffix("kib") {
383        (v, 1 << 10)
384    } else if let Some(v) = lower.strip_suffix("mib") {
385        (v, 1 << 20)
386    } else if let Some(v) = lower.strip_suffix("gib") {
387        (v, 1 << 30)
388    } else if let Some(v) = lower.strip_suffix("tib") {
389        (v, 1 << 40)
390    } else if let Some(v) = lower.strip_suffix("kb") {
391        (v, 1_000)
392    } else if let Some(v) = lower.strip_suffix("mb") {
393        (v, 1_000_000)
394    } else if let Some(v) = lower.strip_suffix("gb") {
395        (v, 1_000_000_000)
396    } else if let Some(v) = lower.strip_suffix("tb") {
397        (v, 1_000_000_000_000)
398    } else if let Some(v) = lower.strip_suffix('b') {
399        (v, 1)
400    } else {
401        (lower.as_str(), 1)
402    };
403    // Suffixes strip from the lowercased copy; slice the original at
404    // the same boundary so errors echo the caller's spelling.
405    // Separators follow the shared rule, so `1_0KB` parses while
406    // `1__0KB` fails like everywhere else. A leading `+` strips before
407    // the unsigned parse (`u64` rejects it; `-` still fails below).
408    let digits = strip_digit_separators(&s[..digits.len()]);
409    let digits = digits.strip_prefix('+').unwrap_or(&digits);
410    let n: u64 = digits
411        .parse()
412        .map_err(|_| anyhow!("invalid byte count: {s}"))?;
413    let bytes = n
414        .checked_mul(unit)
415        .ok_or_else(|| anyhow!("byte count out of range: {s}"))?;
416    if bytes == 0 {
417        bail!("byte count must be positive, got: {s}");
418    }
419    Ok(bytes)
420}
421
422/// Canonical display for a byte count: largest exact unit (`500B`,
423/// `10KB`, `2MB`, `1GB`, binary `10KIB` and friends), falling back to
424/// bytes. Round-trips through [`parse_byte_count`].
425pub fn format_byte_count(n: u64) -> String {
426    const TIB: u64 = 1 << 40;
427    const TB: u64 = 1_000_000_000_000;
428    const GIB: u64 = 1 << 30;
429    const GB: u64 = 1_000_000_000;
430    const MIB: u64 = 1 << 20;
431    const MB: u64 = 1_000_000;
432    const KIB: u64 = 1 << 10;
433    const KB: u64 = 1_000;
434    if n >= TIB && n.is_multiple_of(TIB) {
435        format!("{}TiB", n / TIB)
436    } else if n >= TB && n.is_multiple_of(TB) {
437        format!("{}TB", n / TB)
438    } else if n >= GIB && n.is_multiple_of(GIB) {
439        format!("{}GiB", n / GIB)
440    } else if n >= GB && n.is_multiple_of(GB) {
441        format!("{}GB", n / GB)
442    } else if n >= MIB && n.is_multiple_of(MIB) {
443        format!("{}MiB", n / MIB)
444    } else if n >= MB && n.is_multiple_of(MB) {
445        format!("{}MB", n / MB)
446    } else if n >= KIB && n.is_multiple_of(KIB) {
447        format!("{}KiB", n / KIB)
448    } else if n >= KB && n.is_multiple_of(KB) {
449        format!("{}KB", n / KB)
450    } else {
451        format!("{n}B")
452    }
453}
454
455/// Data direction for an argument.
456#[derive(Debug, Clone, Copy, PartialEq, Eq)]
457pub enum IoDirection {
458    Read,
459    Write,
460}
461
462/// Stream type for fallback or default output.
463#[derive(Debug, Clone, Copy, PartialEq, Eq)]
464pub enum Stream {
465    Stdin,
466    Stdout,
467    Stderr,
468}
469
470/// Metadata for a single flag.
471pub struct FlagSpec {
472    pub name: &'static str,
473    pub long: &'static str,
474    pub value_type: FlagValueType,
475    pub required: bool,
476    pub description: &'static str,
477}
478
479/// Type of value a flag accepts.
480#[derive(Debug, Clone, Copy, PartialEq, Eq)]
481pub enum FlagValueType {
482    /// Boolean flag (no value required).
483    Flag,
484    /// String-valued flag.
485    String,
486    /// Integer-valued flag.
487    Int,
488}
489
490impl FlagValueType {
491    /// Display label using the type vocabulary. A bare `Flag`
492    /// switch carries no value; `BOOL` names what its presence asserts.
493    pub fn label(&self) -> &'static str {
494        match self {
495            FlagValueType::Flag => "BOOL",
496            FlagValueType::String => "STRING",
497            FlagValueType::Int => "INT",
498        }
499    }
500}
501
502/// Complete metadata for a command.
503pub struct CommandMeta {
504    pub name: &'static str,
505    pub syntax: &'static str,
506    pub summary: &'static str,
507    pub description: &'static str,
508    pub args: &'static [ArgSpec],
509    pub flags: &'static [FlagSpec],
510    pub default_output: Option<Stream>,
511    pub examples: &'static [Example],
512}
513
514/// An executable example for a command or function.
515#[derive(Debug, Clone, Copy, PartialEq, Eq)]
516pub struct Example {
517    pub name: &'static str,
518    pub fence_meta: Option<&'static str>,
519    pub code: &'static str,
520}
521
522/// Trait for command metadata and lowering. No execution types.
523///
524/// This trait lives in `oxdock-parser` and has zero dependencies on
525/// `oxdock-core`. Execution dispatch is handled separately by the
526/// `define_pipeline!` macro in `oxdock-core`.
527pub trait CommandSpec {
528    const NAME: &'static str;
529
530    fn metadata() -> CommandMeta;
531    fn lower(flags: Vec<(String, Arg)>, args: Vec<Arg>) -> Result<StepKind>;
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537    use crate::ast::Expr;
538
539    fn lit(text: &str) -> Arg {
540        Arg::String(text.to_string(), false)
541    }
542
543    fn var(name: &str) -> Arg {
544        Arg::Expr(Expr::Var(name.to_string()))
545    }
546
547    #[test]
548    fn validate_literal_covers_each_variant() {
549        ArgType::String.check_arg(&lit("anything at all")).unwrap();
550        ArgType::Path.check_arg(&lit("a/b/../c")).unwrap();
551        ArgType::Int.check_arg(&lit("3")).unwrap();
552        assert!(ArgType::Int.check_arg(&lit("banana")).is_err());
553        ArgType::Duration.check_arg(&lit("10s")).unwrap();
554        ArgType::Duration.check_arg(&lit("30")).unwrap();
555        assert!(ArgType::Duration.check_arg(&lit("banana")).is_err());
556        assert!(ArgType::Duration.check_arg(&lit("0s")).is_err());
557        ArgType::List.check_arg(&lit("$x")).unwrap();
558        assert!(ArgType::List.check_arg(&lit("x")).is_err());
559        // Only app types plus the visibly-marked placeholder remain.
560        assert_eq!(ArgType::Any.label(), "<any>");
561        ArgType::OneOf(&["SNAPSHOT", "LOCAL"])
562            .check_arg(&lit("LOCAL"))
563            .unwrap();
564        // Lowercase spellings are rejected: keyword arguments stay uppercase.
565        assert!(
566            ArgType::OneOf(&["SNAPSHOT", "LOCAL"])
567                .check_arg(&lit("local"))
568                .is_err()
569        );
570        assert!(
571            ArgType::OneOf(&["SNAPSHOT", "LOCAL"])
572                .check_arg(&lit("REMOTE"))
573                .is_err()
574        );
575    }
576
577    #[test]
578    fn check_arg_defers_dynamics_and_enforces_var() {
579        // Templates defer: their values only exist after interpolation.
580        assert_eq!(
581            ArgType::Duration.check_arg(&lit("{{ $d }}")).unwrap(),
582            CheckOutcome::Deferred
583        );
584        // Variables satisfy List statically and defer for everything else.
585        assert_eq!(
586            ArgType::List.check_arg(&var("x")).unwrap(),
587            CheckOutcome::Static
588        );
589        assert_eq!(
590            ArgType::Duration.check_arg(&var("d")).unwrap(),
591            CheckOutcome::Deferred
592        );
593        // Non-variable expressions where a $var is required fail at lower.
594        let list = Arg::Expr(Expr::List(vec![]));
595        assert!(ArgType::List.check_arg(&list).is_err());
596        assert_eq!(
597            ArgType::String.check_arg(&list).unwrap(),
598            CheckOutcome::Deferred
599        );
600        assert_eq!(ArgType::List.label(), "LIST");
601        // `$var` shapes are enforced per command in `lower`, not here:
602        // a bare word passes the String check and fails lowering.
603        assert_eq!(
604            ArgType::String.check_arg(&lit("x")).unwrap(),
605            CheckOutcome::Static
606        );
607    }
608
609    fn spec(index: usize, required: bool, arg_type: ArgType) -> ArgSpec {
610        ArgSpec {
611            name: "p",
612            arg_type,
613            description: "",
614            io: IoDirection::Write,
615            index,
616            required,
617            fallback_stream: None,
618        }
619    }
620
621    #[test]
622    fn positionals_enforce_required_and_rest_tails() {
623        let specs = [spec(0, true, ArgType::Int)];
624        assert!(validate_positionals_against_meta("T", &specs, &[]).is_err());
625        assert!(validate_positionals_against_meta("T", &specs, &[lit("3")]).is_ok());
626        assert!(validate_positionals_against_meta("T", &specs, &[lit("banana")]).is_err());
627
628        // Rest validates EVERY trailing positional, not just the first.
629        let specs = [ArgSpec {
630            arg_type: ArgType::Rest(&ArgType::Int),
631            ..spec(0, true, ArgType::Int)
632        }];
633        assert!(
634            validate_positionals_against_meta("T", &specs, &[lit("1"), lit("2"), lit("banana")])
635                .is_err()
636        );
637        assert!(validate_positionals_against_meta("T", &specs, &[lit("1"), lit("2")]).is_ok());
638        // Extras beyond fixed-arity specs fail (no silent truncation).
639        let specs = [spec(0, true, ArgType::Int)];
640        let err = validate_positionals_against_meta("T", &specs, &[lit("1"), lit("extra")])
641            .expect_err("extras must fail");
642        assert!(
643            err.to_string().contains("at most 1"),
644            "unexpected error: {err:#}"
645        );
646    }
647}