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