Skip to main content

oxdock_parser/
command.rs

1use crate::ast::{Arg, Expr, StepKind};
2use anyhow::{Result, anyhow, bail};
3
4/// Metadata for a single command argument.
5pub struct ArgSpec {
6    pub name: &'static str,
7    pub arg_type: ArgType,
8    pub description: &'static str,
9    pub io: IoDirection,
10    pub index: usize,
11    pub required: bool,
12    pub fallback_stream: Option<Stream>,
13}
14
15/// Closed vocabulary for argument value types.
16///
17/// The closed enum keeps the vocabulary compiler-checked and lets
18/// docs-gen link each type cell to its reference section instead of
19/// printing bare words like `duration` with no explanation.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum ArgType {
22    String,
23    Path,
24    Int,
25    Duration,
26    Var,
27    KeyValue,
28    /// Inline alternation for one-off enums (e.g. `SNAPSHOT|LOCAL`).
29    /// Self-describing, so it renders unlinked.
30    OneOf(&'static [&'static str]),
31    /// Trailing variadic repetition (e.g. `RUN`'s `string...`).
32    Rest(&'static ArgType),
33}
34
35impl ArgType {
36    /// Table-cell label for the argument table's Type column.
37    /// Always a real [`crate::ast::TypeKind`] name — never a shape. Reference and
38    /// assignment shapes (`$var`, `KEY=value`) display as the `STRING`
39    /// values they bind or resolve to; the `$`/assignment requirement
40    /// itself lives in the argument description and command syntax.
41    pub fn label(&self) -> String {
42        use crate::ast::TypeKind;
43        match self {
44            ArgType::String => TypeKind::String.label().to_string(),
45            ArgType::Path => TypeKind::Path.label().to_string(),
46            ArgType::Int => TypeKind::Int.label().to_string(),
47            ArgType::Duration => TypeKind::Duration.label().to_string(),
48            ArgType::Var => TypeKind::String.label().to_string(),
49            ArgType::KeyValue => TypeKind::String.label().to_string(),
50            ArgType::OneOf(options) => options.join("|"),
51            ArgType::Rest(inner) => format!("{}...", inner.label()),
52        }
53    }
54
55    /// Anchor of the type's reference section, delegated to [`crate::ast::TypeKind`].
56    /// Only types with a `TypeKind` reference section link; argument shapes
57    /// (`$var`, `KEY=value`) and inline alternations render unlinked.
58    pub fn anchor(&self) -> Option<String> {
59        use crate::ast::TypeKind;
60        match self {
61            ArgType::String => Some(TypeKind::String.anchor()),
62            ArgType::Path => Some(TypeKind::Path.anchor()),
63            ArgType::Int => Some(TypeKind::Int.anchor()),
64            ArgType::Duration => Some(TypeKind::Duration.anchor()),
65            ArgType::Var => None,
66            ArgType::KeyValue => None,
67            ArgType::OneOf(_) => None,
68            ArgType::Rest(inner) => inner.anchor(),
69        }
70    }
71
72    /// Validate a statically-known literal against this type.
73    /// Templates and variables are never passed here — see `check_arg`.
74    pub fn validate_literal(&self, literal: &str) -> Result<()> {
75        match self {
76            ArgType::String | ArgType::Path => Ok(()),
77            ArgType::Int => literal
78                .parse::<i32>()
79                .map(|_| ())
80                .map_err(|_| anyhow!("expected int, got {literal:?}")),
81            ArgType::Duration => parse_duration(literal).map(|_| ()),
82            ArgType::Var => {
83                if literal.starts_with('$') {
84                    Ok(())
85                } else {
86                    bail!("expected $var, got {literal:?}")
87                }
88            }
89            ArgType::KeyValue => match split_assignment(literal)? {
90                Some(_) => Ok(()),
91                None => bail!("expected KEY=value, got {literal:?}"),
92            },
93            ArgType::OneOf(options) => {
94                // Match the lower-time normalization: bare lowercase
95                // spellings are accepted alongside exact options.
96                if options
97                    .iter()
98                    .any(|o| *o == literal || o.to_lowercase() == literal)
99                {
100                    Ok(())
101                } else {
102                    bail!("expected one of {}, got {literal:?}", options.join("|"))
103                }
104            }
105            ArgType::Rest(inner) => inner.validate_literal(literal),
106        }
107    }
108
109    /// Classify one positional arg for lower-time checking.
110    /// `Static` literals validate now; templates, variables (except a
111    /// `$var` where `Var` is required), and mixed fragments defer to the
112    /// runtime resolvers, which see interpolated values.
113    pub fn check_arg(&self, arg: &Arg) -> Result<CheckOutcome> {
114        match arg {
115            Arg::String(s, _) if !s.contains("{{") => {
116                self.validate_literal(s)?;
117                Ok(CheckOutcome::Static)
118            }
119            Arg::String(_, _) => Ok(CheckOutcome::Deferred),
120            Arg::Parts(_) => Ok(CheckOutcome::Deferred),
121            Arg::Expr(Expr::Var(_)) => {
122                if *self == ArgType::Var {
123                    Ok(CheckOutcome::Static)
124                } else {
125                    Ok(CheckOutcome::Deferred)
126                }
127            }
128            Arg::Expr(_) => {
129                if *self == ArgType::Var {
130                    bail!("expected $var, got expression {}", arg.render())
131                } else {
132                    Ok(CheckOutcome::Deferred)
133                }
134            }
135        }
136    }
137}
138
139/// Lower-time checking outcome for one positional arg.
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum CheckOutcome {
142    /// Validated now; nothing deferred.
143    Static,
144    /// Unknowable until runtime (template, variable, or fragment);
145    /// runtime resolvers enforce the type on the resolved value.
146    Deferred,
147}
148
149/// Validate positional args against a command's declared specs.
150/// Missing required positionals, statically-known type violations, and
151/// trailing positionals beyond a fixed-arity spec all fail here;
152/// templates, variables, and fragments defer to the runtime resolvers.
153/// A trailing `Rest` spec absorbs any number of positionals.
154pub fn validate_positionals_against_meta(
155    cmd_name: &str,
156    specs: &[ArgSpec],
157    args: &[Arg],
158) -> Result<()> {
159    let has_rest = specs
160        .last()
161        .is_some_and(|s| matches!(s.arg_type, ArgType::Rest(_)));
162
163    if !has_rest && args.len() > specs.len() {
164        bail!(
165            "invalid syntax for command {cmd_name}: expects at most {} positional argument(s), got {}",
166            specs.len(),
167            args.len()
168        );
169    }
170
171    for spec in specs {
172        if let ArgType::Rest(inner) = spec.arg_type {
173            // Variadic tail: every trailing positional checks against
174            // the inner type, not just the first.
175            let tail = args.get(spec.index..).unwrap_or(&[]);
176            if tail.is_empty() && spec.required {
177                bail!(
178                    "invalid syntax for command {cmd_name}: requires argument `{}`",
179                    spec.name
180                )
181            }
182            for arg in tail {
183                check_one(cmd_name, spec, inner, arg)?;
184            }
185            return Ok(());
186        }
187        match args.get(spec.index) {
188            Some(arg) => check_one(cmd_name, spec, &spec.arg_type, arg)?,
189            None if spec.required => {
190                bail!(
191                    "invalid syntax for command {cmd_name}: requires argument `{}`",
192                    spec.name
193                )
194            }
195            None => {}
196        }
197    }
198    Ok(())
199}
200
201fn check_one(cmd_name: &str, spec: &ArgSpec, arg_type: &ArgType, arg: &Arg) -> Result<()> {
202    match arg_type.check_arg(arg) {
203        Ok(_) => Ok(()),
204        Err(e) => bail!(
205            "invalid syntax for command {cmd_name}: argument `{}` got {} — {e:#}",
206            spec.name,
207            arg.render()
208        ),
209    }
210}
211
212/// Strip one layer of surrounding `"` or `'` quotes (both kinds, everywhere).
213pub fn strip_surrounding_quotes(value: &str) -> &str {
214    value
215        .strip_prefix('"')
216        .and_then(|s| s.strip_suffix('"'))
217        .or_else(|| value.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
218        .unwrap_or(value)
219}
220
221/// Single-token `KEY=value` split for direct `lower_command` callers and
222/// exotic keys the grammar cannot classify (single tokens only — no whitespace
223/// reassembly, so the quoted-space corruption class cannot arise here).
224/// Returns `Ok(None)` when there is no `=`.
225pub fn split_assignment(text: &str) -> Result<Option<(String, Arg)>> {
226    let Some((key, raw)) = text.split_once('=') else {
227        return Ok(None);
228    };
229    if key.is_empty() {
230        bail!("assignment requires KEY=value format");
231    }
232    Ok(Some((
233        key.to_string(),
234        Arg::String(strip_surrounding_quotes(raw).to_string(), false),
235    )))
236}
237
238/// Parse a TIMEOUT duration token (`500ms`, `10s`, `2m`, `1h`; a bare
239/// number means seconds).
240pub fn parse_duration(s: &str) -> Result<std::time::Duration> {
241    let (digits, unit_ms): (&str, u64) = if let Some(v) = s.strip_suffix("ms") {
242        (v, 1)
243    } else if let Some(v) = s.strip_suffix('s') {
244        (v, 1_000)
245    } else if let Some(v) = s.strip_suffix('m') {
246        (v, 60_000)
247    } else if let Some(v) = s.strip_suffix('h') {
248        (v, 3_600_000)
249    } else {
250        (s, 1_000)
251    };
252    let n: u64 = digits
253        .parse()
254        .map_err(|_| anyhow!("invalid TIMEOUT duration: {s}"))?;
255    let millis = n
256        .checked_mul(unit_ms)
257        .ok_or_else(|| anyhow!("TIMEOUT duration out of range: {s}"))?;
258    if millis == 0 {
259        bail!("TIMEOUT duration must be positive, got: {s}");
260    }
261    Ok(std::time::Duration::from_millis(millis))
262}
263
264/// Canonical display for a duration: largest exact unit (`500ms`, `10s`,
265/// `2m`, `1h`), falling back to milliseconds. Round-trips through
266/// [`parse_duration`].
267pub fn format_duration(d: &std::time::Duration) -> String {
268    let millis = d.as_millis();
269    if millis.is_multiple_of(3_600_000) {
270        format!("{}h", millis / 3_600_000)
271    } else if millis.is_multiple_of(60_000) {
272        format!("{}m", millis / 60_000)
273    } else if millis.is_multiple_of(1_000) {
274        format!("{}s", millis / 1_000)
275    } else {
276        format!("{millis}ms")
277    }
278}
279
280/// Data direction for an argument.
281#[derive(Debug, Clone, Copy, PartialEq, Eq)]
282pub enum IoDirection {
283    Read,
284    Write,
285}
286
287/// Stream type for fallback or default output.
288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
289pub enum Stream {
290    Stdin,
291    Stdout,
292    Stderr,
293}
294
295/// Metadata for a single flag.
296pub struct FlagSpec {
297    pub name: &'static str,
298    pub long: &'static str,
299    pub value_type: FlagValueType,
300    pub required: bool,
301    pub description: &'static str,
302}
303
304/// Type of value a flag accepts.
305#[derive(Debug, Clone, Copy, PartialEq, Eq)]
306pub enum FlagValueType {
307    /// Boolean flag (no value required).
308    Flag,
309    /// String-valued flag.
310    String,
311    /// Integer-valued flag.
312    Int,
313}
314
315impl FlagValueType {
316    /// Display label using the real [`crate::ast::TypeKind`] vocabulary. A bare `Flag`
317    /// switch carries no value; `BOOL` names what its presence asserts.
318    pub fn label(&self) -> &'static str {
319        use crate::ast::TypeKind;
320        match self {
321            FlagValueType::Flag => TypeKind::Bool.label(),
322            FlagValueType::String => TypeKind::String.label(),
323            FlagValueType::Int => TypeKind::Int.label(),
324        }
325    }
326}
327
328/// Complete metadata for a command.
329pub struct CommandMeta {
330    pub name: &'static str,
331    pub syntax: &'static str,
332    pub summary: &'static str,
333    pub description: &'static str,
334    pub args: &'static [ArgSpec],
335    pub flags: &'static [FlagSpec],
336    pub default_output: Option<Stream>,
337    pub examples: &'static [Example],
338}
339
340/// An executable example for a command.
341pub struct Example {
342    pub name: &'static str,
343    pub fence_meta: Option<&'static str>,
344    pub code: &'static str,
345}
346
347/// Trait for command metadata and lowering. No execution types.
348///
349/// This trait lives in `oxdock-parser` and has zero dependencies on
350/// `oxdock-core`. Execution dispatch is handled separately by the
351/// `define_pipeline!` macro in `oxdock-core`.
352pub trait CommandSpec {
353    const NAME: &'static str;
354
355    fn metadata() -> CommandMeta;
356    fn lower(flags: Vec<(String, Arg)>, args: Vec<Arg>) -> Result<StepKind>;
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use crate::ast::Expr;
363
364    fn lit(text: &str) -> Arg {
365        Arg::String(text.to_string(), false)
366    }
367
368    fn var(name: &str) -> Arg {
369        Arg::Expr(Expr::Var(name.to_string()))
370    }
371
372    #[test]
373    fn validate_literal_covers_each_variant() {
374        ArgType::String.check_arg(&lit("anything at all")).unwrap();
375        ArgType::Path.check_arg(&lit("a/b/../c")).unwrap();
376        ArgType::Int.check_arg(&lit("3")).unwrap();
377        assert!(ArgType::Int.check_arg(&lit("banana")).is_err());
378        ArgType::Duration.check_arg(&lit("10s")).unwrap();
379        ArgType::Duration.check_arg(&lit("30")).unwrap();
380        assert!(ArgType::Duration.check_arg(&lit("banana")).is_err());
381        assert!(ArgType::Duration.check_arg(&lit("0s")).is_err());
382        ArgType::Var.check_arg(&lit("$x")).unwrap();
383        assert!(ArgType::Var.check_arg(&lit("x")).is_err());
384        ArgType::KeyValue.check_arg(&lit("K=v")).unwrap();
385        ArgType::KeyValue.check_arg(&lit("K=a=b")).unwrap();
386        assert!(ArgType::KeyValue.check_arg(&lit("no-equals")).is_err());
387        assert!(ArgType::KeyValue.check_arg(&lit("=v")).is_err());
388        ArgType::OneOf(&["SNAPSHOT", "LOCAL"])
389            .check_arg(&lit("LOCAL"))
390            .unwrap();
391        // Lowercase spellings stay accepted (WORKSPACE parity).
392        ArgType::OneOf(&["SNAPSHOT", "LOCAL"])
393            .check_arg(&lit("local"))
394            .unwrap();
395        assert!(
396            ArgType::OneOf(&["SNAPSHOT", "LOCAL"])
397                .check_arg(&lit("REMOTE"))
398                .is_err()
399        );
400    }
401
402    #[test]
403    fn check_arg_defers_dynamics_and_enforces_var() {
404        // Templates defer: their values only exist after interpolation.
405        assert_eq!(
406            ArgType::Duration.check_arg(&lit("{{ $d }}")).unwrap(),
407            CheckOutcome::Deferred
408        );
409        // Variables satisfy Var statically and defer for everything else.
410        assert_eq!(
411            ArgType::Var.check_arg(&var("x")).unwrap(),
412            CheckOutcome::Static
413        );
414        assert_eq!(
415            ArgType::Duration.check_arg(&var("d")).unwrap(),
416            CheckOutcome::Deferred
417        );
418        // Non-variable expressions where Var is required fail at lower.
419        let list = Arg::Expr(Expr::List(vec![]));
420        assert!(ArgType::Var.check_arg(&list).is_err());
421        assert_eq!(
422            ArgType::String.check_arg(&list).unwrap(),
423            CheckOutcome::Deferred
424        );
425    }
426
427    fn spec(index: usize, required: bool, arg_type: ArgType) -> ArgSpec {
428        ArgSpec {
429            name: "p",
430            arg_type,
431            description: "",
432            io: IoDirection::Write,
433            index,
434            required,
435            fallback_stream: None,
436        }
437    }
438
439    #[test]
440    fn positionals_enforce_required_and_rest_tails() {
441        let specs = [spec(0, true, ArgType::Int)];
442        assert!(validate_positionals_against_meta("T", &specs, &[]).is_err());
443        assert!(validate_positionals_against_meta("T", &specs, &[lit("3")]).is_ok());
444        assert!(validate_positionals_against_meta("T", &specs, &[lit("banana")]).is_err());
445
446        // Rest validates EVERY trailing positional, not just the first.
447        let specs = [ArgSpec {
448            arg_type: ArgType::Rest(&ArgType::Int),
449            ..spec(0, true, ArgType::Int)
450        }];
451        assert!(
452            validate_positionals_against_meta("T", &specs, &[lit("1"), lit("2"), lit("banana")])
453                .is_err()
454        );
455        assert!(validate_positionals_against_meta("T", &specs, &[lit("1"), lit("2")]).is_ok());
456        // Extras beyond fixed-arity specs fail (no silent truncation).
457        let specs = [spec(0, true, ArgType::Int)];
458        let err = validate_positionals_against_meta("T", &specs, &[lit("1"), lit("extra")])
459            .expect_err("extras must fail");
460        assert!(
461            err.to_string().contains("at most 1"),
462            "unexpected error: {err:#}"
463        );
464    }
465}