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