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 => 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                // Match the lower-time normalization: bare lowercase
101                // spellings are accepted alongside exact options.
102                if options
103                    .iter()
104                    .any(|o| *o == literal || o.to_lowercase() == literal)
105                {
106                    Ok(())
107                } else {
108                    bail!("expected one of {}, got {literal:?}", options.join("|"))
109                }
110            }
111            ArgType::Rest(inner) => inner.validate_literal(literal),
112        }
113    }
114
115    /// Classify one positional arg for lower-time checking.
116    /// `Static` literals validate now; templates, variables (except a
117    /// `$var` where `List` is required), and mixed fragments defer to the
118    /// runtime resolvers, which see interpolated values.
119    pub fn check_arg(&self, arg: &Arg) -> Result<CheckOutcome> {
120        match arg {
121            Arg::String(s, _) if !s.contains("{{") => {
122                self.validate_literal(s)?;
123                Ok(CheckOutcome::Static)
124            }
125            Arg::String(_, _) => Ok(CheckOutcome::Deferred),
126            Arg::Parts(_) => Ok(CheckOutcome::Deferred),
127            Arg::Expr(Expr::Var(_)) => {
128                if matches!(*self, ArgType::List) {
129                    Ok(CheckOutcome::Static)
130                } else {
131                    Ok(CheckOutcome::Deferred)
132                }
133            }
134            Arg::Expr(_) => {
135                if matches!(*self, ArgType::List) {
136                    bail!("expected $var, got expression {}", arg.render())
137                } else {
138                    Ok(CheckOutcome::Deferred)
139                }
140            }
141        }
142    }
143}
144
145/// Lower-time checking outcome for one positional arg.
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum CheckOutcome {
148    /// Validated now; nothing deferred.
149    Static,
150    /// Unknowable until runtime (template, variable, or fragment);
151    /// runtime resolvers enforce the type on the resolved value.
152    Deferred,
153}
154
155/// Validate positional args against a command's declared specs.
156/// Missing required positionals, statically-known type violations, and
157/// trailing positionals beyond a fixed-arity spec all fail here;
158/// templates, variables, and fragments defer to the runtime resolvers.
159/// A trailing `Rest` spec absorbs any number of positionals.
160pub fn validate_positionals_against_meta(
161    cmd_name: &str,
162    specs: &[ArgSpec],
163    args: &[Arg],
164) -> Result<(), ParseError> {
165    let has_rest = specs
166        .last()
167        .is_some_and(|s| matches!(s.arg_type, ArgType::Rest(_)));
168
169    if !has_rest && args.len() > specs.len() {
170        return Err(ParseError::validation(
171            cmd_name,
172            format!(
173                "invalid syntax for command {cmd_name}: expects at most {} positional argument(s), got {}",
174                specs.len(),
175                args.len()
176            ),
177            &SpanContext::line_only(0),
178        ));
179    }
180
181    for spec in specs {
182        if let ArgType::Rest(inner) = spec.arg_type {
183            // Variadic tail: every trailing positional checks against
184            // the inner type, not just the first.
185            let tail = args.get(spec.index..).unwrap_or(&[]);
186            if tail.is_empty() && spec.required {
187                return Err(ParseError::validation(
188                    cmd_name,
189                    format!(
190                        "invalid syntax for command {cmd_name}: requires argument `{}`",
191                        spec.name
192                    ),
193                    &SpanContext::line_only(0),
194                ));
195            }
196            for arg in tail {
197                check_one(cmd_name, spec, inner, arg)?;
198            }
199            return Ok(());
200        }
201        match args.get(spec.index) {
202            Some(arg) => check_one(cmd_name, spec, &spec.arg_type, arg)?,
203            None if spec.required => {
204                return Err(ParseError::validation(
205                    cmd_name,
206                    format!(
207                        "invalid syntax for command {cmd_name}: requires argument `{}`",
208                        spec.name
209                    ),
210                    &SpanContext::line_only(0),
211                ));
212            }
213            None => {}
214        }
215    }
216    Ok(())
217}
218
219fn check_one(
220    cmd_name: &str,
221    spec: &ArgSpec,
222    arg_type: &ArgType,
223    arg: &Arg,
224) -> Result<(), ParseError> {
225    match arg_type.check_arg(arg) {
226        Ok(_) => Ok(()),
227        Err(e) => Err(ParseError::validation(
228            cmd_name,
229            format!(
230                "invalid syntax for command {cmd_name}: argument `{}` got {} — {e:#}",
231                spec.name,
232                arg.render()
233            ),
234            &SpanContext::line_only(0),
235        )),
236    }
237}
238
239/// Strip one layer of surrounding `"` or `'` quotes (both kinds, everywhere).
240pub fn strip_surrounding_quotes(value: &str) -> &str {
241    value
242        .strip_prefix('"')
243        .and_then(|s| s.strip_suffix('"'))
244        .or_else(|| value.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
245        .unwrap_or(value)
246}
247
248/// Single-token `KEY=value` split for direct `lower_command` callers and
249/// exotic keys the grammar cannot classify (single tokens only — no whitespace
250/// reassembly, so the quoted-space corruption class cannot arise here).
251/// Returns `Ok(None)` when there is no `=`.
252pub fn split_assignment(text: &str) -> Result<Option<(String, Arg)>> {
253    let Some((key, raw)) = text.split_once('=') else {
254        return Ok(None);
255    };
256    if key.is_empty() {
257        bail!("assignment requires KEY=value format");
258    }
259    Ok(Some((
260        key.to_string(),
261        Arg::String(strip_surrounding_quotes(raw).to_string(), false),
262    )))
263}
264
265/// Parse a TIMEOUT duration token (`500ms`, `10s`, `2m`, `1h`; a bare
266/// number means seconds).
267pub fn parse_duration(s: &str) -> Result<std::time::Duration> {
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, 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/// Data direction for an argument.
308#[derive(Debug, Clone, Copy, PartialEq, Eq)]
309pub enum IoDirection {
310    Read,
311    Write,
312}
313
314/// Stream type for fallback or default output.
315#[derive(Debug, Clone, Copy, PartialEq, Eq)]
316pub enum Stream {
317    Stdin,
318    Stdout,
319    Stderr,
320}
321
322/// Metadata for a single flag.
323pub struct FlagSpec {
324    pub name: &'static str,
325    pub long: &'static str,
326    pub value_type: FlagValueType,
327    pub required: bool,
328    pub description: &'static str,
329}
330
331/// Type of value a flag accepts.
332#[derive(Debug, Clone, Copy, PartialEq, Eq)]
333pub enum FlagValueType {
334    /// Boolean flag (no value required).
335    Flag,
336    /// String-valued flag.
337    String,
338    /// Integer-valued flag.
339    Int,
340}
341
342impl FlagValueType {
343    /// Display label using the type vocabulary. A bare `Flag`
344    /// switch carries no value; `BOOL` names what its presence asserts.
345    pub fn label(&self) -> &'static str {
346        match self {
347            FlagValueType::Flag => "BOOL",
348            FlagValueType::String => "STRING",
349            FlagValueType::Int => "INT",
350        }
351    }
352}
353
354/// Complete metadata for a command.
355pub struct CommandMeta {
356    pub name: &'static str,
357    pub syntax: &'static str,
358    pub summary: &'static str,
359    pub description: &'static str,
360    pub args: &'static [ArgSpec],
361    pub flags: &'static [FlagSpec],
362    pub default_output: Option<Stream>,
363    pub examples: &'static [Example],
364}
365
366/// An executable example for a command.
367pub struct Example {
368    pub name: &'static str,
369    pub fence_meta: Option<&'static str>,
370    pub code: &'static str,
371}
372
373/// Trait for command metadata and lowering. No execution types.
374///
375/// This trait lives in `oxdock-parser` and has zero dependencies on
376/// `oxdock-core`. Execution dispatch is handled separately by the
377/// `define_pipeline!` macro in `oxdock-core`.
378pub trait CommandSpec {
379    const NAME: &'static str;
380
381    fn metadata() -> CommandMeta;
382    fn lower(flags: Vec<(String, Arg)>, args: Vec<Arg>) -> Result<StepKind>;
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use crate::ast::Expr;
389
390    fn lit(text: &str) -> Arg {
391        Arg::String(text.to_string(), false)
392    }
393
394    fn var(name: &str) -> Arg {
395        Arg::Expr(Expr::Var(name.to_string()))
396    }
397
398    #[test]
399    fn validate_literal_covers_each_variant() {
400        ArgType::String.check_arg(&lit("anything at all")).unwrap();
401        ArgType::Path.check_arg(&lit("a/b/../c")).unwrap();
402        ArgType::Int.check_arg(&lit("3")).unwrap();
403        assert!(ArgType::Int.check_arg(&lit("banana")).is_err());
404        ArgType::Duration.check_arg(&lit("10s")).unwrap();
405        ArgType::Duration.check_arg(&lit("30")).unwrap();
406        assert!(ArgType::Duration.check_arg(&lit("banana")).is_err());
407        assert!(ArgType::Duration.check_arg(&lit("0s")).is_err());
408        ArgType::List.check_arg(&lit("$x")).unwrap();
409        assert!(ArgType::List.check_arg(&lit("x")).is_err());
410        // Only app types plus the visibly-marked placeholder remain.
411        assert_eq!(ArgType::Any.label(), "<any>");
412        ArgType::OneOf(&["SNAPSHOT", "LOCAL"])
413            .check_arg(&lit("LOCAL"))
414            .unwrap();
415        // Lowercase spellings stay accepted (WORKSPACE parity).
416        ArgType::OneOf(&["SNAPSHOT", "LOCAL"])
417            .check_arg(&lit("local"))
418            .unwrap();
419        assert!(
420            ArgType::OneOf(&["SNAPSHOT", "LOCAL"])
421                .check_arg(&lit("REMOTE"))
422                .is_err()
423        );
424    }
425
426    #[test]
427    fn check_arg_defers_dynamics_and_enforces_var() {
428        // Templates defer: their values only exist after interpolation.
429        assert_eq!(
430            ArgType::Duration.check_arg(&lit("{{ $d }}")).unwrap(),
431            CheckOutcome::Deferred
432        );
433        // Variables satisfy List statically and defer for everything else.
434        assert_eq!(
435            ArgType::List.check_arg(&var("x")).unwrap(),
436            CheckOutcome::Static
437        );
438        assert_eq!(
439            ArgType::Duration.check_arg(&var("d")).unwrap(),
440            CheckOutcome::Deferred
441        );
442        // Non-variable expressions where a $var is required fail at lower.
443        let list = Arg::Expr(Expr::List(vec![]));
444        assert!(ArgType::List.check_arg(&list).is_err());
445        assert_eq!(
446            ArgType::String.check_arg(&list).unwrap(),
447            CheckOutcome::Deferred
448        );
449        assert_eq!(ArgType::List.label(), "LIST");
450        // `$var` shapes are enforced per command in `lower`, not here:
451        // a bare word passes the String check and fails lowering.
452        assert_eq!(
453            ArgType::String.check_arg(&lit("x")).unwrap(),
454            CheckOutcome::Static
455        );
456    }
457
458    fn spec(index: usize, required: bool, arg_type: ArgType) -> ArgSpec {
459        ArgSpec {
460            name: "p",
461            arg_type,
462            description: "",
463            io: IoDirection::Write,
464            index,
465            required,
466            fallback_stream: None,
467        }
468    }
469
470    #[test]
471    fn positionals_enforce_required_and_rest_tails() {
472        let specs = [spec(0, true, ArgType::Int)];
473        assert!(validate_positionals_against_meta("T", &specs, &[]).is_err());
474        assert!(validate_positionals_against_meta("T", &specs, &[lit("3")]).is_ok());
475        assert!(validate_positionals_against_meta("T", &specs, &[lit("banana")]).is_err());
476
477        // Rest validates EVERY trailing positional, not just the first.
478        let specs = [ArgSpec {
479            arg_type: ArgType::Rest(&ArgType::Int),
480            ..spec(0, true, ArgType::Int)
481        }];
482        assert!(
483            validate_positionals_against_meta("T", &specs, &[lit("1"), lit("2"), lit("banana")])
484                .is_err()
485        );
486        assert!(validate_positionals_against_meta("T", &specs, &[lit("1"), lit("2")]).is_ok());
487        // Extras beyond fixed-arity specs fail (no silent truncation).
488        let specs = [spec(0, true, ArgType::Int)];
489        let err = validate_positionals_against_meta("T", &specs, &[lit("1"), lit("extra")])
490            .expect_err("extras must fail");
491        assert!(
492            err.to_string().contains("at most 1"),
493            "unexpected error: {err:#}"
494        );
495    }
496}