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