Skip to main content

oxdock_parser/
commands.rs

1//! Single-site command registry for all OxDock commands.
2//!
3//! `declare_commands!` is the sole source of truth. It generates:
4//! - StepKind enum — all command + structural AST variants
5//! - `pub fn lower_command(name, raw_args)` — name-dispatched lowering
6//! - `pub fn all_metadata()` — collects `CommandMeta` from all declarations
7//!   plus `all_structural_metadata()` (structural statements are documented
8//!   through the same pipeline so reference docs cannot drift).
9//!
10//! To add a command: add one block inside `declare_commands!`.
11//! To add a structural statement: extend the `structural [...]` list,
12//! `all_structural_metadata()`, and the `structural_metadata_covers_all_structural_kinds`
13//! tripwire below.
14
15use std::fmt;
16
17use crate::ast::{Arg, ArgPart, Expr, IoBinding, IoStream, Step, WorkspaceTarget};
18use crate::command::{
19    ArgSpec, ArgType, CommandMeta, Example, FlagSpec, FlagValueType, IoDirection, Stream,
20    split_assignment,
21};
22use anyhow::{Result, anyhow, bail};
23use indoc::indoc;
24
25// ── Helpers ────────────────────────────────────────────────────────────────
26
27// Value-parsing helpers (`strip_surrounding_quotes`,
28// `split_assignment`, `parse_duration`, `format_duration`) live in
29// `crate::command` beside the `ArgType` validators that call them.
30
31/// Join free-text tail arguments into one value. Single args pass through
32/// untouched (preserving `Arg::Expr`); all-`String` tails join exactly like the
33/// historical `join_args`; tails containing expressions become `Arg::Parts`
34/// with single-space separators so `$x` is never silently dropped.
35fn join_value(args: Vec<Arg>, cmd_name: &str) -> Result<Arg> {
36    if args.is_empty() {
37        bail!("{cmd_name} requires at least one argument");
38    }
39    if args.len() == 1 {
40        return Ok(args.into_iter().next().unwrap());
41    }
42    if args.iter().all(|a| matches!(a, Arg::String(..))) {
43        return Ok(Arg::String(
44            args.iter()
45                .map(|a| a.as_str())
46                .collect::<Vec<_>>()
47                .join(" "),
48            false,
49        ));
50    }
51    let mut parts = Vec::new();
52    for (index, arg) in args.into_iter().enumerate() {
53        if index > 0 {
54            parts.push(ArgPart::Text(" ".to_string(), false));
55        }
56        match arg {
57            Arg::String(text, quoted) => parts.push(ArgPart::Text(text, quoted)),
58            Arg::Expr(expr) => parts.push(ArgPart::Expr(expr)),
59            Arg::Parts(inner) => parts.extend(inner),
60        }
61    }
62    Ok(Arg::Parts(parts))
63}
64
65/// Canonical `lower_command` entry for direct callers holding one pre-joined
66/// `KEY=value` token. Script parsing never reaches this — the grammar splits
67/// assignments on raw spans first (see `lower_env_command` in parser.rs).
68pub fn lower_env_assignment(args: Vec<Arg>) -> Result<StepKind> {
69    let arg = args
70        .into_iter()
71        .next()
72        .ok_or_else(|| anyhow!("ENV requires KEY=value"))?;
73    let Some((key, value)) = split_assignment(arg.as_str())? else {
74        bail!("ENV requires KEY=value format")
75    };
76    Ok(StepKind::Env { key, value })
77}
78
79/// Collapse a grammar-classified assignment for commands that take no
80/// assignments (`RUN`, `COPY`, ...): canonical `key=<rendered value>` text.
81/// Runtime semantics survive intact — `{{ }}` templates stay textual for
82/// `expand_string`, and `RUN`'s own post-pass expands bare `$var`.
83pub(crate) fn canonical_assignment_arg(key: &str, value: &Arg) -> Arg {
84    Arg::String(format!("{key}={}", value.render()), false)
85}
86
87/// Render one `Arg` for `Display`: expressions print raw (`$x` must never be
88/// quoted or reparsing would literalize them); mixed values print raw unless
89/// they hold instruction-boundary characters (`;`, `}`, linebreaks), which
90/// force quoting for reparseability.
91fn fmt_value(arg: &Arg, quote: fn(&str) -> String) -> String {
92    match arg {
93        Arg::Expr(_) => arg.render(),
94        Arg::String(text, _) => quote(text),
95        Arg::Parts(_) => {
96            let rendered = arg.render();
97            if rendered.contains(';')
98                || rendered.contains('}')
99                || rendered.contains('\n')
100                || rendered.contains('\r')
101            {
102                quote(&rendered)
103            } else {
104                rendered
105            }
106        }
107    }
108}
109
110fn quote_arg(s: &str) -> String {
111    let is_safe = s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
112        && !s.starts_with(|c: char| c.is_ascii_digit() || c == '-' || c == '/' || c == '.')
113        && crate::Command::parse(s).is_none();
114    if is_safe && !s.is_empty() {
115        s.to_string()
116    } else {
117        format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
118    }
119}
120
121fn quote_msg(s: &str) -> String {
122    let safe = s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
123        && !s.starts_with(|c: char| c.is_ascii_digit())
124        && crate::Command::parse(s).is_none();
125    if safe && !s.is_empty() {
126        s.to_string()
127    } else {
128        format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
129    }
130}
131
132fn quote_run(s: &str) -> String {
133    if s.is_empty() || s.chars().any(|c| c == ';' || c == '\n') || s.contains("//") {
134        return format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""));
135    }
136    s.split(' ')
137        .map(|w| {
138            if w.starts_with(|c: char| c.is_ascii_digit())
139                || w.starts_with(['/', '.', '-', ':', '='])
140            {
141                format!("\"{}\"", w.replace('\\', "\\\\").replace('"', "\\\""))
142            } else {
143                w.to_string()
144            }
145        })
146        .collect::<Vec<_>>()
147        .join(" ")
148}
149
150/// Render one exec-form (`RUN [...]`) argv element for `Display`:
151/// string literals print JSON-quoted; typed expressions (`$var`,
152/// `CALL()`, ints, bools, nested lists) print raw via `render` so
153/// reparsing yields the same typed element; mixed values print raw
154/// unless they hold instruction-boundary characters.
155fn fmt_exec_arg(arg: &Arg) -> String {
156    match arg {
157        Arg::String(text, _) => {
158            format!("\"{}\"", text.replace('\\', "\\\\").replace('"', "\\\""))
159        }
160        Arg::Expr(_) => arg.render(),
161        Arg::Parts(_) => {
162            let rendered = arg.render();
163            if rendered.contains(';')
164                || rendered.contains('}')
165                || rendered.contains('\n')
166                || rendered.contains('\r')
167            {
168                format!(
169                    "\"{}\"",
170                    rendered.replace('\\', "\\\\").replace('"', "\\\"")
171                )
172            } else {
173                rendered
174            }
175        }
176    }
177}
178
179/// Render an [`Arg`] for `Display`: the quoted flag drives quoting (not
180/// content sniffing — digit-leading values like `10s` or `0` must stay
181/// bare to reparse with the same flag).
182fn fmt_raw_arg(arg: &Arg) -> String {
183    match arg {
184        Arg::String(s, true) => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")),
185        _ => arg.render(),
186    }
187}
188
189fn fmt_io(b: &IoBinding) -> String {
190    let s = match b.stream {
191        IoStream::Stdin => "stdin",
192        IoStream::Stdout => "stdout",
193        IoStream::Stderr => "stderr",
194    };
195    if let Some(p) = &b.pipe {
196        format!("{}=pipe:{}", s, p)
197    } else {
198        s.to_string()
199    }
200}
201
202// ── declare_commands! ──────────────────────────────────────────────────────
203
204// Keywords parsed by PEG rules rather than plain-command lowering (`WITH_IO`,
205// `AWAIT`, ...). When a line starts with one of these but fails to parse as
206// such, lowering falls through here — report a committed syntax error instead
207// of an unknown command.
208pub(crate) fn is_known_command(name: &str) -> bool {
209    if name == "ELSE" {
210        return true;
211    }
212    all_metadata().iter().any(|meta| meta.name == name)
213}
214
215pub(crate) fn invalid_syntax_error(name: &str, raw_args: &[Arg]) -> anyhow::Error {
216    let received = raw_args
217        .iter()
218        .map(Arg::render)
219        .collect::<Vec<_>>()
220        .join(" ");
221    let got = if received.is_empty() {
222        "nothing".to_string()
223    } else {
224        format!("`{received}`")
225    };
226    match structural_hint(name, &received) {
227        Some(hint) => anyhow!("invalid syntax for command {name}: {hint}"),
228        None => anyhow!("invalid syntax for command {name}: got {got}."),
229    }
230}
231
232fn unknown_command_error(name: &str, raw_args: &[Arg]) -> anyhow::Error {
233    let received = raw_args
234        .iter()
235        .map(Arg::render)
236        .collect::<Vec<_>>()
237        .join(" ");
238    let hint = structural_hint(name, &received).or_else(|| case_hint(name));
239    match hint {
240        Some(hint) => anyhow!("unknown command: {name}\n{hint}"),
241        None => anyhow!("unknown command: {name}"),
242    }
243}
244
245fn structural_hint(name: &str, received: &str) -> Option<String> {
246    let got = if received.is_empty() {
247        "nothing".to_string()
248    } else {
249        format!("`{received}`")
250    };
251    match name {
252        "WITH_IO" => Some(with_io_hint(&got, received)),
253        "AWAIT" => Some(format!(
254            "AWAIT waits for a background task variable, e.g. `LET $t = ASYNC ECHO hi` then `AWAIT $t`; got {got}."
255        )),
256        "CANCEL" => Some(format!(
257            "CANCEL stops a background task variable, e.g. `CANCEL $t` (from `LET $t = ASYNC ...`); got {got}."
258        )),
259        "ASYNC" => Some(format!(
260            "ASYNC runs a command in the background, e.g. `ASYNC RUN ...`, `ASYNC {{ ... }}`, or `LET $t = ASYNC ...`; got {got}."
261        )),
262        "FOR" => Some(format!(
263            "FOR loops need `FOR $item IN <expr> {{ ... }}` (or `FOR $key, $value IN <expr> {{ ... }}`); got {got}."
264        )),
265        "IF" => Some(format!(
266            "IF needs a condition and a block, e.g. `IF true {{ ECHO yes }}`; got {got}."
267        )),
268        "ELSE" => Some(format!(
269            "ELSE must directly follow an `IF ... {{ ... }}` block, e.g. `IF true {{ ECHO yes }} ELSE {{ ECHO no }}`; got {got}."
270        )),
271        "LET" => Some(format!(
272            "LET assigns a variable, e.g. `LET $name = <expr>`, `LET $t = ASYNC ...`, `LET $out = <command>` (capture), or `LET $out = AWAIT $t`; got {got}."
273        )),
274        "TIMEOUT" => Some(format!(
275            "TIMEOUT needs a duration and a command or block, e.g. `TIMEOUT 30s RUN ...`; got {got}."
276        )),
277        "INHERIT_ENV" => Some(format!(
278            "INHERIT_ENV takes a key list, e.g. `INHERIT_ENV [HOME PATH]`; got {got}."
279        )),
280        _ => None,
281    }
282}
283
284/// Diagnose a `WITH_IO` line that failed to parse: most often a malformed
285/// binding list (bindings are bare streams or `<stream>=pipe:<name>`).
286fn with_io_hint(got: &str, received: &str) -> String {
287    const SYNTAX: &str =
288        "WITH_IO needs `WITH_IO [bindings] <command>` or `WITH_IO [bindings] { <commands> }`";
289    const BINDINGS: &str = "bindings are `stdin`, `stdout`, `stderr`, or `<stream>=pipe:<name>` (e.g. `[stdout=pipe:log]`)";
290    if let Some(after_open) = received.strip_prefix('[') {
291        match after_open.split_once(']') {
292            None => {
293                return format!("{SYNTAX}: missing closing `]` in the binding list; got {got}.");
294            }
295            Some((bindings, _)) => {
296                for part in bindings.split(',') {
297                    let part = part.trim();
298                    if part.is_empty() {
299                        continue;
300                    }
301                    let (stream, binding) = match part.split_once('=') {
302                        Some((stream, binding)) => (stream.trim(), Some(binding.trim())),
303                        None => (part, None),
304                    };
305                    if !matches!(stream, "stdin" | "stdout" | "stderr") {
306                        return format!(
307                            "{SYNTAX}: invalid stream `{stream}`; expected `stdin`, `stdout`, or `stderr`; got {got}."
308                        );
309                    }
310                    let valid = match binding {
311                        None => true,
312                        Some(value) => value
313                            .strip_prefix("pipe:")
314                            .map(|pipe| !pipe.trim().is_empty())
315                            .unwrap_or(false),
316                    };
317                    if !valid {
318                        return format!(
319                            "{SYNTAX}: invalid binding `{part}`; {BINDINGS}; got {got}."
320                        );
321                    }
322                }
323            }
324        }
325    }
326    format!("{SYNTAX}; got {got}. {BINDINGS}.")
327}
328
329/// `echo hi` is almost certainly `ECHO hi`: commands are uppercase.
330fn case_hint(name: &str) -> Option<String> {
331    let upper = name.to_ascii_uppercase();
332    if upper != name
333        && all_metadata()
334            .iter()
335            .any(|meta| meta.name == upper.as_str())
336    {
337        return Some(format!("did you mean `{upper}`? commands are uppercase."));
338    }
339    None
340}
341
342macro_rules! declare_commands {
343    (
344        structural [
345            $( $sname:ident $( { $( $sfname:ident : $sftype:ty ),* $(,)? } )? ),* $(,)?
346        ]
347
348        $(
349            $cmd_ident:ident => [
350                name: $name:expr,
351                variant: $vname:ident $( { $( $vfname:ident : $vftype:ty ),* $(,)? } )? $( ( $( $ttuple:ty ),* $(,)? ) )?,
352                syntax: $syntax:expr,
353                summary: $summary:expr,
354                description: $desc:expr,
355                args: $args:expr,
356                flags: $flags:expr,
357                default_output: $out:expr,
358                examples: $examples:expr,
359                lower: $lower:expr,
360            ]
361        ),* $(,)?
362    ) => {
363        #[derive(Debug, Clone, Eq, PartialEq)]
364        pub enum StepKind {
365            $( $vname $( { $( $vfname : $vftype ),* } )? $( ( $( $ttuple ),* ) )?, )*
366            $( $sname $( { $( $sfname : $sftype ),* } )?, )*
367        }
368
369        pub fn lower_command(name: &str, raw_args: Vec<Arg>) -> Result<StepKind> {
370            match name {
371                $(
372                    s if s == $name => {
373                        let meta = CommandMeta {
374                            name: $name, syntax: $syntax, summary: $summary,
375                            description: $desc, args: $args, flags: $flags,
376                            default_output: $out, examples: $examples,
377                        };
378                        let (flags, positional) = crate::strip_flags(raw_args, &meta)?;
379                        crate::command::validate_positionals_against_meta(
380                            s,
381                            &meta.args,
382                            &positional,
383                        )?;
384                        let lower_fn: fn(Vec<(String, Arg)>, Vec<Arg>) -> Result<StepKind> = $lower;
385                        lower_fn(flags, positional)
386                    }
387                )*
388                _ => {
389                    if is_known_command(name) {
390                        Err(invalid_syntax_error(name, &raw_args))
391                    } else {
392                        Err(unknown_command_error(name, &raw_args))
393                    }
394                }
395            }
396        }
397
398        pub fn all_metadata() -> Vec<CommandMeta> {
399            let mut out = vec![
400                $( CommandMeta {
401                    name: $name, syntax: $syntax, summary: $summary,
402                    description: $desc, args: $args, flags: $flags,
403                    default_output: $out, examples: $examples,
404                }, )*
405            ];
406            // Structural statements are registered separately (see
407            // all_structural_metadata) but documented through the same
408            // pipeline so docs-gen never drifts from the parser.
409            out.extend(all_structural_metadata());
410            out
411        }
412    };
413}
414
415declare_commands! {
416    structural [
417        WithIo { bindings: Vec<IoBinding>, cmd: Box<StepKind> },
418        WithIoBlock { bindings: Vec<IoBinding> },
419        For { key_var: Option<String>, var: String, in_expr: Expr, body: Vec<Step> },
420        If { cond: Box<Expr>, then_body: Vec<Step>, else_ifs: Vec<(Box<Expr>, Vec<Step>)>, else_body: Option<Vec<Step>> },
421        Assign { var: String, expr: Expr },
422        AssignCapture { var: String, cmd: Box<StepKind> },
423        AwaitCapture { out_var: String, task_var: String },
424        AsyncBlock { body: Vec<Step> },
425        AssignAsync { var: String, body: Vec<Step> },
426        Await { var: String },
427        Cancel { var: String },
428        Timeout { duration: Arg, body: Vec<Step> },
429        RunExec { argv: Vec<Arg> },
430    ]
431
432    Workdir => [
433        name: "WORKDIR",
434        variant: Workdir(Arg),
435        syntax: "WORKDIR <path>",
436        summary: "Change the working directory.",
437        description: "Sets the current working directory. Relative paths resolve against the current directory; `/` resets to the workspace root. Paths cannot escape the workspace.",
438        args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Directory to change to", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
439        flags: &[],
440        default_output: None,
441        examples: &[ Example { name: "change working directory", fence_meta: None, code: indoc! {r#"
442            WORKDIR project/src
443            WRITE generated.txt generated-under-workdir
444            ASSERT_FILE generated.txt generated-under-workdir
445        "#} } ],
446        lower: |_flags, args| {
447            let path = args.into_iter().next().ok_or_else(|| anyhow!("WORKDIR requires a path"))?;
448            Ok(StepKind::Workdir(path))
449        },
450    ],
451
452    Workspace => [
453        name: "WORKSPACE",
454        variant: Workspace(WorkspaceTarget),
455        syntax: "WORKSPACE SNAPSHOT|LOCAL",
456        summary: "Switch workspace roots.",
457        description: "SNAPSHOT or LOCAL root.",
458        args: &[ ArgSpec { name: "target", arg_type: ArgType::OneOf(&["SNAPSHOT", "LOCAL"]), description: "Target root", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
459        flags: &[],
460        default_output: None,
461        examples: &[ Example { name: "switch roots", fence_meta: None, code: indoc! {r#"WORKSPACE LOCAL"#} } ],
462        lower: |_flags, args| {
463            let target = args.into_iter().next().ok_or_else(|| anyhow!("WORKSPACE requires a target"))?;
464            match target.as_str() {
465                "SNAPSHOT" | "snapshot" => Ok(StepKind::Workspace(WorkspaceTarget::Snapshot)),
466                "LOCAL" | "local" => Ok(StepKind::Workspace(WorkspaceTarget::Local)),
467                other => bail!("unknown workspace target: {other}"),
468            }
469        },
470    ],
471
472    Env => [
473        name: "ENV",
474        variant: Env { key: String, value: Arg },
475        syntax: "ENV KEY=value",
476        summary: "Set an environment variable.",
477        description: "Inserts or updates an env var. The value uses the unified string-value rules shared by every command: `\"...\"` or `'...'` quotes keep exact bytes (spaces, tabs), a lone `$var` evaluates that variable, `{{ ... }}` placeholders interpolate, unquoted words join with single spaces, and the first `=` splits key from value (`KEY=a=b` stores `a=b`). A `$var` inside larger text stays literal — write `{{ $var }}` to interpolate there.",
478        args: &[ ArgSpec { name: "assignment", arg_type: ArgType::KeyValue, description: "KEY=value pair", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
479        flags: &[],
480        default_output: None,
481        examples: &[
482            Example { name: "set env", fence_meta: None, code: indoc! {r#"ENV APP_MODE=production"#} },
483            Example { name: "quoted value with spaces", fence_meta: None, code: indoc! {r#"
484                # quotes keep the space: SET_FORTH stores `outer scope`
485                ENV SET_FORTH="outer scope"
486                WRITE out.txt "{{ env:SET_FORTH }}"
487                ASSERT_FILE out.txt "outer scope"
488            "#} },
489            Example { name: "variable value", fence_meta: None, code: indoc! {r#"
490                # a lone $var evaluates, like ECHO $var
491                LET $who = "Alice"
492                ENV GREETING=$who
493                WRITE out.txt "{{ env:GREETING }}"
494                ASSERT_FILE out.txt "Alice"
495            "#} },
496            Example { name: "all value forms agree", fence_meta: None, code: indoc! {r#"
497                # a bare variable, a quoted literal, and a template all
498                # store plain strings through the same value rules
499                LET $x = "Ada"
500                ENV A=$x
501                ENV B="hello world"
502                ENV C="{{ $x }} concatenated"
503                WRITE check.txt "{{ env:A }}|{{ env:B }}|{{ env:C }}"
504                ASSERT_FILE check.txt "Ada|hello world|Ada concatenated"
505            "#} },
506            Example { name: "scoped env reverts", fence_meta: None, code: indoc! {r#"
507                # ENV inside a braced block reverts when the block exits
508                ENV MODE=production
509                [bool:true] {
510                    ENV MODE=staging
511                    WRITE inner.txt "{{ env:MODE }}"
512                }
513                WRITE outer.txt "{{ env:MODE }}"
514                ASSERT_FILE inner.txt "staging"
515                ASSERT_FILE outer.txt "production"
516            "#} },
517        ],
518        lower: |_flags, args| lower_env_assignment(args),
519    ],
520
521    InheritEnv => [
522        name: "INHERIT_ENV",
523        variant: InheritEnv { keys: Vec<String> },
524        syntax: "INHERIT_ENV <key>...",
525        summary: "Inherit env vars from host.",
526        description: "Declares which host environment variables to inherit into the script. Must appear before any other commands and at most once. Without this directive, the script starts with an empty environment.",
527        args: &[ ArgSpec { name: "keys", arg_type: ArgType::Rest(&ArgType::String), description: "Host variables to inherit", io: IoDirection::Read, index: 0, required: false, fallback_stream: None } ],
528        flags: &[],
529        default_output: None,
530        examples: &[ Example { name: "inherit env", fence_meta: None, code: indoc! {r#"INHERIT_ENV [PATH, HOME]"#} } ],
531        lower: |_flags, args| {
532            let keys = args.into_iter().map(|a| a.as_str().to_string()).collect();
533            Ok(StepKind::InheritEnv { keys })
534        },
535    ],
536
537    Echo => [
538        name: "ECHO",
539        variant: Echo(Arg),
540        syntax: "ECHO <message>",
541        summary: "Print to stdout.",
542        description: "Outputs message to stdout.",
543        args: &[ ArgSpec { name: "message", arg_type: ArgType::Rest(&ArgType::String), description: "Text", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
544        flags: &[],
545        default_output: Some(Stream::Stdout),
546        examples: &[
547            Example { name: "echo", fence_meta: None, code: indoc! {r#"ECHO build-complete"#} },
548            Example { name: "variables", fence_meta: None, code: indoc! {r#"
549                # a lone $x evaluates; {{ }} interpolates inside text
550                LET $x = "World"
551                ECHO {{ $x }}
552                ECHO $x
553                ASSERT_STDOUT "World"
554            "#} },
555        ],
556        lower: |_flags, args| Ok(StepKind::Echo(join_value(args, "ECHO")?)),
557    ],
558
559    Run => [
560        name: "RUN",
561        variant: Run(Arg),
562        syntax: "RUN <command...> | RUN [\"exe\", \"arg\", ...]",
563        summary: "Execute shell command or direct executable.",
564        description: "Shell form (`RUN <command...>`) runs the joined command string in the system shell (`$SHELL -c` / `COMSPEC /C`). Exec form (`RUN [\"exe\", \"arg\", ...]`) spawns the executable directly with no shell, so there is no shell expansion, globbing, redirection, or pipes; use it for portable commands. Guards and wrappers (`ASYNC`, `TIMEOUT`, `WITH_IO`, `LET`) apply to both forms.",
565        args: &[ ArgSpec { name: "command", arg_type: ArgType::Rest(&ArgType::String), description: "Command", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
566        flags: &[],
567        default_output: None,
568        examples: &[ Example { name: "run", fence_meta: None, code: indoc! {r#"RUN echo hello"#} }, Example { name: "run exec form", fence_meta: None, code: indoc! {r#"RUN ["cargo", "--version"]"#} } ],
569        lower: |_flags, args| match args.as_slice() {
570            [Arg::Expr(Expr::List(elems))] if elems.is_empty() => {
571                bail!("RUN requires at least one argument")
572            }
573            [Arg::Expr(Expr::List(elems))] => Ok(StepKind::RunExec {
574                argv: elems.iter().cloned().map(Arg::Expr).collect(),
575            }),
576            _ => Ok(StepKind::Run(join_value(args, "RUN")?)),
577        },
578    ],
579
580    Copy => [
581        name: "COPY",
582        variant: Copy { from_current_workspace: bool, from: Arg, to: Arg },
583        syntax: "COPY [--from-current-workspace] <from> <to>",
584        summary: "Copy file into workspace.",
585        description: "Copies from host.",
586        args: &[
587            ArgSpec { name: "from", arg_type: ArgType::Path, description: "Source", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
588            ArgSpec { name: "to", arg_type: ArgType::Path, description: "Dest", io: IoDirection::Write, index: 1, required: true, fallback_stream: None },
589        ],
590        flags: &[ FlagSpec { name: "from_current_workspace", long: "--from-current-workspace", value_type: FlagValueType::Flag, required: false, description: "Copy from workspace instead of build context" } ],
591        default_output: None,
592        examples: &[ Example { name: "copy", fence_meta: Some("roots:unified"), code: indoc! {r#"
593            WRITE src.txt content
594            COPY src.txt dst.txt
595            ASSERT_FILE dst.txt content
596        "#} }, Example { name: "copy from workspace", fence_meta: Some("roots:unified"), code: indoc! {r#"
597            WRITE ws-src.txt ws-content
598            COPY --from-current-workspace ws-src.txt ws-copy.txt
599            ASSERT_FILE ws-copy.txt ws-content
600        "#} } ],
601        lower: |flags, args| {
602            let from_current_workspace = flags.iter().any(|(k, _)| k == "from_current_workspace");
603            let mut it = args.into_iter();
604            let from = it.next().ok_or_else(|| anyhow!("COPY requires a source"))?;
605            let to = it.next().ok_or_else(|| anyhow!("COPY requires a destination"))?;
606            Ok(StepKind::Copy { from_current_workspace, from, to })
607        },
608    ],
609
610    CopyGit => [
611        name: "COPY_GIT",
612        variant: CopyGit { rev: Arg, from: Arg, to: Arg, include_dirty: bool },
613        syntax: "COPY_GIT [--include-dirty] <rev> <src> <dst>",
614        summary: "Copy from git revision.",
615        description: "Checkout and copy.",
616        args: &[
617            ArgSpec { name: "rev", arg_type: ArgType::String, description: "Rev", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
618            ArgSpec { name: "src", arg_type: ArgType::Path, description: "Src", io: IoDirection::Read, index: 1, required: true, fallback_stream: None },
619            ArgSpec { name: "dst", arg_type: ArgType::Path, description: "Dst", io: IoDirection::Write, index: 2, required: true, fallback_stream: None },
620        ],
621        flags: &[ FlagSpec { name: "dirty", long: "--include-dirty", value_type: FlagValueType::Flag, required: false, description: "Include dirty" } ],
622        default_output: None,
623        examples: &[ Example { name: "git copy", fence_meta: Some("expect_error:\"COPY source missing\""), code: indoc! {r#"COPY_GIT HEAD src.txt dst.txt"#} } ],
624        lower: |flags, args| {
625            let include_dirty = flags.iter().any(|(k, _)| k == "dirty");
626            let mut it = args.into_iter();
627            let rev = it.next().ok_or_else(|| anyhow!("COPY_GIT requires a revision"))?;
628            let from = it.next().ok_or_else(|| anyhow!("COPY_GIT requires a source"))?;
629            let to = it.next().ok_or_else(|| anyhow!("COPY_GIT requires a destination"))?;
630            Ok(StepKind::CopyGit { rev, from, to, include_dirty })
631        },
632    ],
633
634    Symlink => [
635        name: "SYMLINK",
636        variant: Symlink { from: Arg, to: Arg },
637        syntax: "SYMLINK <from> <to>",
638        summary: "Create symlink.",
639        description: "Creates symlink.",
640        args: &[
641            ArgSpec { name: "from", arg_type: ArgType::Path, description: "Target", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
642            ArgSpec { name: "to", arg_type: ArgType::Path, description: "Link", io: IoDirection::Write, index: 1, required: true, fallback_stream: None },
643        ],
644        flags: &[],
645        default_output: None,
646        examples: &[ Example { name: "symlink", fence_meta: Some("roots:unified"), code: indoc! {r#"
647            WRITE original.txt content
648            SYMLINK original.txt link.txt
649            ASSERT_FILE link.txt content
650        "#} } ],
651        lower: |_flags, args| {
652            let mut it = args.into_iter();
653            let from = it.next().ok_or_else(|| anyhow!("SYMLINK requires a source"))?;
654            let to = it.next().ok_or_else(|| anyhow!("SYMLINK requires a target"))?;
655            Ok(StepKind::Symlink { from, to })
656        },
657    ],
658
659    Mkdir => [
660        name: "MKDIR",
661        variant: Mkdir(Arg),
662        syntax: "MKDIR <path>",
663        summary: "Create directory.",
664        description: "Creates dir with parents.",
665        args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Dir path", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
666        flags: &[],
667        default_output: None,
668        examples: &[ Example { name: "mkdir", fence_meta: None, code: indoc! {r#"MKDIR deeply/nested/tree"#} } ],
669        lower: |_flags, args| Ok(StepKind::Mkdir(args.into_iter().next().ok_or_else(|| anyhow!("MKDIR requires a path"))?)),
670    ],
671
672    Ls => [
673        name: "LS",
674        variant: Ls(Option<Arg>),
675        syntax: "LS [<path>]",
676        summary: "List directory.",
677        description: "Lists entries.",
678        args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Dir", io: IoDirection::Read, index: 0, required: false, fallback_stream: None } ],
679        flags: &[],
680        default_output: Some(Stream::Stdout),
681        examples: &[ Example { name: "ls", fence_meta: None, code: indoc! {r#"
682            MKDIR inventory
683            WRITE inventory/a.txt a
684            LS inventory
685        "#} } ],
686        lower: |_flags, args| Ok(StepKind::Ls(args.into_iter().next())),
687    ],
688
689    Cwd => [
690        name: "CWD",
691        variant: Cwd,
692        syntax: "CWD",
693        summary: "Print working directory.",
694        description: "Outputs cwd.",
695        args: &[],
696        flags: &[],
697        default_output: Some(Stream::Stdout),
698        examples: &[ Example { name: "cwd", fence_meta: None, code: indoc! {r#"CWD"#} } ],
699        lower: |_flags, _args| Ok(StepKind::Cwd),
700    ],
701
702    Read => [
703        name: "READ",
704        variant: Read(Option<Arg>),
705        syntax: "READ [<path>]",
706        summary: "Read file to stdout.",
707        description: "Outputs file contents.",
708        args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Read, index: 0, required: false, fallback_stream: None } ],
709        flags: &[],
710        default_output: Some(Stream::Stdout),
711        examples: &[ Example { name: "read", fence_meta: None, code: indoc! {r#"
712            WRITE note.txt "hello"
713            READ note.txt
714        "#} } ],
715        lower: |_flags, args| Ok(StepKind::Read(args.into_iter().next())),
716    ],
717
718    ReadLine => [
719        name: "READ_LINE",
720        variant: ReadLine { var: String },
721        syntax: "READ_LINE $var",
722        summary: "Read one line from stdin into a variable.",
723        description: "Reads bytes until newline without waiting for EOF, leaving the pipe open. Trailing newline is stripped (shell-read parity). On premature EOF assigns accumulated bytes and returns.",
724        args: &[ ArgSpec { name: "var", arg_type: ArgType::Var, description: "Variable to store the line", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
725        flags: &[],
726        default_output: None,
727        examples: &[ Example { name: "read line", fence_meta: None, code: indoc! {r#"
728            WITH_IO [stdout=pipe:lines] ECHO "first"
729            WITH_IO [stdin=pipe:lines] READ_LINE $reply
730        "#} } ],
731        lower: |_flags, args| {
732            let arg = args.into_iter().next().ok_or_else(|| anyhow!("READ_LINE requires a variable"))?;
733            let var = match arg {
734                Arg::Expr(Expr::Var(name)) => name,
735                Arg::String(s, _) => s.trim_start_matches('$').to_string(),
736                other => bail!("READ_LINE requires a $variable, found {:?}", other),
737            };
738            if var.is_empty() {
739                bail!("READ_LINE requires a variable");
740            }
741            Ok(StepKind::ReadLine { var })
742        },
743    ],
744
745    Write => [
746        name: "WRITE",
747        variant: Write { path: Arg, contents: Option<Arg> },
748        syntax: "WRITE <path> [<contents>]",
749        summary: "Write to file.",
750        description: "Writes contents.",
751        args: &[
752            ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Write, index: 0, required: true, fallback_stream: None },
753            ArgSpec { name: "contents", arg_type: ArgType::Rest(&ArgType::String), description: "Content", io: IoDirection::Write, index: 1, required: false, fallback_stream: Some(Stream::Stdin) },
754        ],
755        flags: &[],
756        default_output: None,
757        examples: &[ Example { name: "write", fence_meta: None, code: indoc! {r#"WRITE output.txt hello-world"#} } ],
758        lower: |_flags, args| {
759            let mut it = args.into_iter();
760            let path = it.next().ok_or_else(|| anyhow!("WRITE requires a path"))?;
761            let remaining: Vec<Arg> = it.collect();
762            let contents = if remaining.is_empty() { None } else { Some(join_value(remaining, "WRITE")?) };
763            Ok(StepKind::Write { path, contents })
764        },
765    ],
766
767    Append => [
768        name: "APPEND",
769        variant: Append { path: Arg, contents: Option<Arg> },
770        syntax: "APPEND <path> [<contents>]",
771        summary: "Append to file.",
772        description: "Appends contents.",
773        args: &[
774            ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Write, index: 0, required: true, fallback_stream: None },
775            ArgSpec { name: "contents", arg_type: ArgType::Rest(&ArgType::String), description: "Content", io: IoDirection::Write, index: 1, required: false, fallback_stream: Some(Stream::Stdin) },
776        ],
777        flags: &[],
778        default_output: None,
779        examples: &[ Example { name: "append", fence_meta: None, code: indoc! {r#"
780            WRITE log.txt line1
781            APPEND log.txt line2
782            ASSERT_FILE log.txt line1line2
783        "#} } ],
784        lower: |_flags, args| {
785            let mut it = args.into_iter();
786            let path = it.next().ok_or_else(|| anyhow!("APPEND requires a path"))?;
787            let remaining: Vec<Arg> = it.collect();
788            let contents = if remaining.is_empty() { None } else { Some(join_value(remaining, "APPEND")?) };
789            Ok(StepKind::Append { path, contents })
790        },
791    ],
792
793    Expand => [
794        name: "EXPAND",
795        variant: Expand { path: Option<Arg>, overrides: Vec<(String, Arg)> },
796        syntax: "EXPAND [<path>] [<KEY=val> ...]",
797        summary: "Expand a template file (or stdin) to stdout.",
798        description: "A template is any text file — or piped stdin when no path is given — containing `{{ ... }}` placeholders. EXPAND replaces each placeholder and prints the result to stdout. Placeholders: `{{ NAME }}` reads a `KEY=val` override passed on this command; `{{ env:NAME }}` reads an override, falling back to the environment; `{{ $var }}` reads a script variable (dotted paths allowed). A missing key is an error, never a silent empty. Substitution runs in a single pass. EXPAND is not recursive and does not expand nested placeholders: a value that itself contains `{{ ... }}` is inserted verbatim and never expanded again. A bare `$var` argument is a template path; `KEY=val` arguments are overrides whose values follow the unified string-value rules (same as `ENV`: quotes keep exact bytes, a lone `$var` evaluates, `{{ ... }}` interpolates). NOTE: `WRITE` interpolates `{{ ... }}` while writing, so escape it (`\\{{ ... }}`) when writing a template file for a later `EXPAND`. With no path, the template arrives on stdin through a pipe. When piping from a shell, single-quote the template (`echo '{{ $x }}'`): double quotes let the shell swallow `$x`, so oxdock receives an empty `{{ }}` placeholder and errors.",
799        args: &[
800            ArgSpec { name: "path", arg_type: ArgType::Path, description: "Template file to expand; omit to expand stdin", io: IoDirection::Read, index: 0, required: false, fallback_stream: None },
801            ArgSpec { name: "overrides", arg_type: ArgType::Rest(&ArgType::KeyValue), description: "Template overrides shadowing that key (unified string values)", io: IoDirection::Read, index: 1, required: false, fallback_stream: None },
802        ],
803        flags: &[],
804        default_output: Some(Stream::Stdout),
805        examples: &[
806            Example { name: "expand", fence_meta: None, code: indoc! {r#"
807                ENV NAME="Alice"
808                WRITE template.md "Hello {{ env:NAME }}!"
809                EXPAND template.md
810                ASSERT_STDOUT "Hello Alice!"
811            "#} },
812            Example { name: "override with spaces", fence_meta: None, code: indoc! {r#"
813                # WRITE would interpolate {{ }} right away, so escape it:
814                # the file must literally contain {{ env:NAME }} for EXPAND
815                WRITE template.md "Hello \{{ env:NAME }}!"
816                EXPAND template.md NAME="Alice Smith"
817                ASSERT_STDOUT "Hello Alice Smith!"
818            "#} },
819            Example { name: "variable override", fence_meta: None, code: indoc! {r#"
820                # same escaping: keep the placeholder literal until EXPAND;
821                # a lone $who evaluates, like ECHO $who
822                LET $who = "Bob"
823                WRITE template.md "Hi \{{ env:WHO }}!"
824                EXPAND template.md WHO=$who
825                ASSERT_STDOUT "Hi Bob!"
826            "#} },
827            Example { name: "override forms agree", fence_meta: None, code: indoc! {r#"
828                # a bare variable and a template-with-tail expand identically
829                LET $x = "Ada"
830                WRITE template.md "Hi \{{ env:NAME }} and \{{ env:NAME2 }}!"
831                EXPAND template.md NAME=$x NAME2="{{ $x }} concatenated"
832                ASSERT_STDOUT "Hi Ada and Ada concatenated!"
833            "#} },
834            Example { name: "expand stdin", fence_meta: None, code: indoc! {r#"
835                # no path: the template arrives on stdin through a pipe
836                WITH_IO [stdout=pipe:tpl] ECHO "Hello \{{ env:NAME }}!"
837                WITH_IO [stdin=pipe:tpl] EXPAND NAME=Alice
838                ASSERT_STDOUT "Hello Alice!"
839            "#} },
840            Example { name: "override does not leak", fence_meta: None, code: indoc! {r#"
841                # KEY=val overrides shadow env for that EXPAND only —
842                # they never update the environment itself
843                ENV NAME="Alice"
844                WRITE template.md "Hi \{{ env:NAME }}!"
845                EXPAND template.md NAME="Bob"
846                ASSERT_STDOUT "Hi Bob!"
847                EXPAND template.md
848                ASSERT_STDOUT "Hi Alice!"
849            "#} },
850        ],
851        lower: |_flags, args| {
852            let mut path = None;
853            let mut overrides = Vec::new();
854            for arg in args {
855                let text = arg.as_str();
856                if let Some((key, value)) = split_assignment(text)? {
857                    overrides.push((key, value));
858                } else if path.is_none() { path = Some(arg); }
859                else { bail!("EXPAND accepts at most one path"); }
860            }
861            Ok(StepKind::Expand { path, overrides })
862        },
863    ],
864
865    AssertFile => [
866        name: "ASSERT_FILE",
867        variant: AssertFile { hash: Option<String>, path: Arg, contents: Option<Arg> },
868        syntax: "ASSERT_FILE [--hash <sha256>] <path> [<expected>]",
869        summary: "Assert file exists.",
870        description: "Checks the path is a file, then optionally compares its bytes (or `--hash` SHA-256 digest) against the expectation. Any mismatch aborts the pipeline with a step-numbered error showing expected vs actual.",
871        args: &[
872            ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
873            ArgSpec { name: "expected", arg_type: ArgType::Rest(&ArgType::String), description: "Expected", io: IoDirection::Read, index: 1, required: false, fallback_stream: None },
874        ],
875        flags: &[ FlagSpec { name: "hash", long: "--hash", value_type: FlagValueType::String, required: false, description: "SHA-256" } ],
876        default_output: None,
877        examples: &[ Example { name: "assert file", fence_meta: None, code: indoc! {r#"
878            WRITE payload.bin stable-content
879            ASSERT_FILE payload.bin stable-content
880        "#} },
881        Example { name: "assert file hash", fence_meta: None, code: indoc! {r#"
882            # --hash compares the SHA-256 digest instead of raw bytes
883            WRITE payload.bin stable-content
884            ASSERT_FILE --hash 08135c1b6349b0e4f894c36221952f0de00e6b4d82f80895abf359755e77103c payload.bin
885        "#} } ],
886        lower: |flags, args| {
887            let hash = flags.iter().find(|(k, _)| k == "hash").map(|(_, v)| v.as_str().to_string());
888            let mut it = args.into_iter();
889            let path = it.next().ok_or_else(|| anyhow!("ASSERT_FILE requires a path"))?;
890            let remaining: Vec<Arg> = it.collect();
891            let contents = if remaining.is_empty() { None } else { Some(join_value(remaining, "ASSERT_FILE")?) };
892            Ok(StepKind::AssertFile { hash, path, contents })
893        },
894    ],
895
896    AssertDir => [
897        name: "ASSERT_DIR",
898        variant: AssertDir(Arg),
899        syntax: "ASSERT_DIR <path>",
900        summary: "Assert dir exists.",
901        description: "Checks the path is a directory, aborting the pipeline with a step-numbered error otherwise.",
902        args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Dir", io: IoDirection::Read, index: 0, required: true, fallback_stream: None } ],
903        flags: &[],
904        default_output: None,
905        examples: &[ Example { name: "assert dir", fence_meta: None, code: indoc! {r#"
906            MKDIR dist/assets
907            ASSERT_DIR dist/assets
908        "#} } ],
909        lower: |_flags, args| Ok(StepKind::AssertDir(args.into_iter().next().ok_or_else(|| anyhow!("ASSERT_DIR requires a path"))?)),
910    ],
911
912    AssertAbsent => [
913        name: "ASSERT_ABSENT",
914        variant: AssertAbsent(Arg),
915        syntax: "ASSERT_ABSENT <path>",
916        summary: "Assert path absent.",
917        description: "Checks nothing exists at the path, aborting the pipeline with a step-numbered error if it does.",
918        args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Path", io: IoDirection::Read, index: 0, required: true, fallback_stream: None } ],
919        flags: &[],
920        default_output: None,
921        examples: &[ Example { name: "assert absent", fence_meta: None, code: indoc! {r#"ASSERT_ABSENT missing.txt"#} } ],
922        lower: |_flags, args| Ok(StepKind::AssertAbsent(args.into_iter().next().ok_or_else(|| anyhow!("ASSERT_ABSENT requires a path"))?)),
923    ],
924
925    AssertStdout => [
926        name: "ASSERT_STDOUT",
927        variant: AssertStdout(Arg),
928        syntax: "ASSERT_STDOUT <substring>",
929        summary: "Assert stdout contains.",
930        description: "Checks the preceding step's stdout contains the substring, aborting the pipeline with a step-numbered error otherwise.",
931        args: &[ ArgSpec { name: "substring", arg_type: ArgType::Rest(&ArgType::String), description: "Substring", io: IoDirection::Read, index: 0, required: true, fallback_stream: None } ],
932        flags: &[],
933        default_output: None,
934        examples: &[ Example { name: "assert stdout", fence_meta: None, code: indoc! {r#"
935            ECHO build-complete
936            ASSERT_STDOUT build-complete
937        "#} } ],
938        lower: |_flags, args| Ok(StepKind::AssertStdout(join_value(args, "ASSERT_STDOUT")?)),
939    ],
940
941    HashSha256 => [
942        name: "HASH_SHA256",
943        variant: HashSha256 { path: Arg },
944        syntax: "HASH_SHA256 <path>",
945        summary: "Print SHA-256.",
946        description: "Computes digest.",
947        args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Read, index: 0, required: true, fallback_stream: None } ],
948        flags: &[],
949        default_output: Some(Stream::Stdout),
950        examples: &[ Example { name: "hash", fence_meta: None, code: indoc! {r#"
951            WRITE payload.txt hello
952            HASH_SHA256 payload.txt
953        "#} } ],
954        lower: |_flags, args| Ok(StepKind::HashSha256 { path: args.into_iter().next().ok_or_else(|| anyhow!("HASH_SHA256 requires a path"))? }),
955    ],
956
957    Exit => [
958        name: "EXIT",
959        variant: Exit(Arg),
960        syntax: "EXIT <code>",
961        summary: "Exit pipeline.",
962        description: "Stops the pipeline immediately with an `EXIT requested with code <code>` error; steps after it never run, at any nesting depth. Enclosing blocks still unwind their LET/ENV/WORKDIR/WORKSPACE state, anonymous background tasks are killed synchronously, and files written before the EXIT persist.",
963        args: &[ ArgSpec { name: "code", arg_type: ArgType::Int, description: "Code", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
964        flags: &[],
965        default_output: None,
966        examples: &[ Example { name: "exit", fence_meta: Some("expect_error:\"EXIT requested with code 0\""), code: indoc! {r#"EXIT 0"#} } ],
967        lower: |_flags, args| {
968            // Static literals were already Int-checked by the central
969            // validator; dynamics resolve (and validate) at runtime.
970            let code = args.into_iter().next().ok_or_else(|| anyhow!("EXIT requires a code"))?;
971            Ok(StepKind::Exit(code))
972        },
973    ],
974
975    Sleep => [
976        name: "SLEEP",
977        variant: Sleep { duration: Arg },
978        syntax: "SLEEP <duration>",
979        summary: "Pause execution for a duration.",
980        description: "Parks the step for the duration (e.g. 500ms, 10s, 2m). Cooperative: checks for cancellation so an enclosing TIMEOUT or task teardown interrupts the sleep. Cross-platform alternative to shell sleep for testing time boundaries.",
981        args: &[ ArgSpec { name: "duration", arg_type: ArgType::Duration, description: "How long to sleep", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
982        flags: &[],
983        default_output: None,
984        examples: &[
985            Example { name: "sleep", fence_meta: None, code: indoc! {r#"SLEEP 100ms"#} },
986            Example {
987                name: "sleep variable duration",
988                fence_meta: None,
989                code: indoc! {r#"
990                # durations resolve at runtime, so variables work too —
991                # quoted or bare, both bind the same string
992                LET $pause = "100ms"
993                SLEEP $pause
994                LET $bare = 100ms
995                SLEEP $bare
996            "#},
997            },
998        ],
999        lower: |_flags, args| {
1000            let mut it = args.into_iter();
1001            let raw = it
1002                .next()
1003                .ok_or_else(|| anyhow!("SLEEP requires a duration (e.g. SLEEP 500ms)"))?;
1004            if it.next().is_some() {
1005                bail!("SLEEP takes exactly one duration argument");
1006            }
1007            // Static literals were Duration-checked by the central
1008            // validator; dynamics ($var, templates) resolve at runtime.
1009            Ok(StepKind::Sleep { duration: raw })
1010        },
1011    ],
1012}
1013
1014// ── Structural metadata ──────────────────────────────────────────────────
1015// Single source of truth for structural-statement documentation (TIMEOUT,
1016// ASYNC, AWAIT, WITH_IO, IF, FOR, ...). These constructs are parsed by PEG
1017// rules rather than `declare_commands!`, so their reference docs live here
1018// instead of `crates/docs-gen/src/command_ref.rs` — adding a structural
1019// StepKind without registering it here fails `structural_metadata_covers_all_structural_kinds`
1020// below, and docs-gen renders these entries dynamically (no hardcoded copy).
1021pub fn all_structural_metadata() -> Vec<CommandMeta> {
1022    vec![
1023        CommandMeta {
1024            name: "WITH_IO",
1025            syntax: "WITH_IO [bindings] <command> | WITH_IO [bindings] { <commands> }",
1026            summary: "Reroute standard streams.",
1027            description: "Reroutes the standard streams of the next command or, in block form, of every enclosed command. Bindings map streams (`stdin`, `stdout`, `stderr`) to named script pipes (`stdout=pipe:name`, `stderr=pipe:name`). Both stdout and stderr pipes capture output the same way. Pipes hold bytes in memory and spill to a temp file above 8 MiB, so a producer can finish before the consumer starts. If WITH_IO wraps an ASYNC block whose body is a single RUN, guarded or not, the pipe is a zero copy OS kernel pipe instead: pair it with a consumer that runs while the producer is alive, since output past the 64 KiB kernel buffer stalls until drained. A second producer or consumer on a live name is an explicit error. A name bound as output can later feed another command's `stdin`, connecting commands without touching the terminal. Binding `stdout` and `stderr` to the same live pipe name fails deterministically. Merge streams in shell via `2>&1` instead. Nested blocks stack defaults; inline bindings override inherited ones for their command only; closing a block restores previous wiring.",
1028            args: &[],
1029            flags: &[],
1030            default_output: None,
1031            examples: &[Example {
1032                name: "with_io block",
1033                fence_meta: None,
1034                code: indoc! {r#"
1035                WITH_IO [stdout=pipe:log] {
1036                  ECHO first
1037                  ECHO second
1038                }
1039                WITH_IO [stdin=pipe:log] WRITE captured.txt
1040            "#},
1041            }],
1042        },
1043        CommandMeta {
1044            name: "FOR",
1045            syntax: "FOR $item IN <expr> { <commands> } | FOR $key, $value IN <expr> { <commands> }",
1046            summary: "Iterate over a list or map.",
1047            description: "The loop variable receives each element (lists) or value (maps); with two variables, the first receives the key. Loop variables are scoped to the loop body and do not leak outward. The body may be a braced block or a single-line `{ ... }` command. `GLOB(\"...\")` patterns must be quoted (`*` is not a bare word, so `GLOB(*)` is a parse error); GLOB returns a root-relative sorted list, empty when nothing matches, and rejects `..` escapes.",
1048            args: &[],
1049            flags: &[],
1050            default_output: None,
1051            examples: &[
1052                Example {
1053                    name: "for loop",
1054                    fence_meta: None,
1055                    code: indoc! {r#"
1056                LET $items = ["a", "b"]
1057                FOR $item IN $items {
1058                  ECHO $item
1059                }
1060
1061                LET $map = {"x": 1}
1062                FOR $k, $v IN $map {
1063                  ECHO "$k=$v"
1064                }
1065            "#},
1066                },
1067                Example {
1068                    name: "expand every match",
1069                    fence_meta: None,
1070                    code: indoc! {r#"
1071                # single-line body; $x is a template path, WHO an override
1072                WRITE a.txt "hi \{{ env:WHO }}!"
1073                FOR $x IN GLOB("*.txt") { EXPAND $x WHO=World }
1074                ASSERT_STDOUT "hi World!"
1075            "#},
1076                },
1077            ],
1078        },
1079        CommandMeta {
1080            name: "IF",
1081            syntax: "IF <expr> { <commands> } [ELSE IF <expr> { <commands> }] [ELSE { <commands> }]",
1082            summary: "Conditional execution.",
1083            description: "The condition is evaluated as a boolean expression. Prefix `!` negates (`IF !false`); only Bool values are accepted as conditions.",
1084            args: &[],
1085            flags: &[],
1086            default_output: None,
1087            examples: &[Example {
1088                name: "if else",
1089                fence_meta: None,
1090                code: indoc! {r#"
1091                IF true {
1092                  ECHO yes
1093                } ELSE {
1094                  ECHO no
1095                }
1096
1097                IF false {
1098                  ECHO skipped
1099                } ELSE IF true {
1100                  ECHO fallback
1101                }
1102
1103                IF !false {
1104                  ECHO inverted
1105                }
1106            "#},
1107            }],
1108        },
1109        CommandMeta {
1110            name: "LET",
1111            syntax: "LET $var = <expr> | LET $var = ASYNC { <commands> } | LET $var = <command> | LET $var = AWAIT $task",
1112            summary: "Bind script-local variables.",
1113            description: "Assigns a value to a script-local variable. Variables are usable in templates (`{{ $var }}`), guards, and expressions. With `ASYNC`, spawns a background task and stores its handle (see ASYNC). The `$` sigil on the name is mandatory. The right-hand side is always an expression — literals, lists, maps, comparisons, `GLOB(\"*.md\")` — never a `{{ ... }}` template; interpolation happens in string values, not here. Bare words need no quotes: `LET $d = 30s` binds the same string as `LET $d = \"30s\"`. When the right-hand side is a synchronous command (`LET $out = ECHO hi`), the command runs to completion and its exact stdout bytes are captured into the variable as a string (no newline stripping; commands with no stdout capture as `\"\"`; non-UTF8 stdout is an error). Combining capture with an explicit `WITH_IO [stdout=pipe:...]` is a parse error. `LET $out = AWAIT $task` captures a background task's stdout the same way; bare `AWAIT $task` forwards it to the parent stdout instead.",
1114            args: &[],
1115            flags: &[],
1116            default_output: None,
1117            examples: &[
1118                Example {
1119                    name: "let",
1120                    fence_meta: None,
1121                    code: indoc! {r#"
1122                LET $name = "world"
1123                ECHO "hello, {{ $name }}"
1124
1125                LET $items = ["a", "b"]
1126                LET $count = 42
1127            "#},
1128                },
1129                Example {
1130                    name: "glob binding",
1131                    fence_meta: None,
1132                    code: indoc! {r#"
1133                # the RHS is an expression: GLOB(...) runs and binds a list
1134                WRITE a.txt "x"
1135                LET $files = GLOB("*.txt")
1136                FOR $f IN $files { ECHO $f }
1137                ASSERT_STDOUT "a.txt"
1138            "#},
1139                },
1140                Example {
1141                    name: "scoped variable reverts",
1142                    fence_meta: None,
1143                    code: indoc! {r#"
1144                # LET inside a braced block reverts when the block exits
1145                LET $a = "outer"
1146                [bool:true] {
1147                    LET $a = "inner"
1148                    WRITE inner.txt "{{ $a }}"
1149                }
1150                WRITE outer.txt "{{ $a }}"
1151                ASSERT_FILE inner.txt "inner"
1152                ASSERT_FILE outer.txt "outer"
1153            "#},
1154                },
1155                Example {
1156                    name: "capture command output",
1157                    fence_meta: None,
1158                    code: indoc! {r#"
1159                LET $out = ECHO hi
1160                WRITE captured.txt "{{ $out }}"
1161                ASSERT_FILE captured.txt "hi\n"
1162            "#},
1163                },
1164            ],
1165        },
1166        CommandMeta {
1167            name: "ASYNC",
1168            syntax: "ASYNC <command...> | ASYNC { <commands> } | LET $var = ASYNC { <commands> }",
1169            summary: "Run steps in a background thread.",
1170            description: "Runs a command or block of commands in a background thread with subshell isolation. Mutations (ENV, WORKDIR) stay within the block. With `LET`, stores a task handle for `AWAIT`.",
1171            args: &[],
1172            flags: &[],
1173            default_output: None,
1174            examples: &[
1175                Example {
1176                    name: "async",
1177                    fence_meta: None,
1178                    code: indoc! {r#"
1179                    ASYNC ECHO "first"
1180
1181                    ASYNC {
1182                        ECHO "first"
1183                        ECHO "second"
1184                    }
1185                "#},
1186                },
1187                Example {
1188                    name: "async task handle",
1189                    fence_meta: None,
1190                    code: indoc! {r#"
1191                    LET $task = ASYNC {
1192                        ECHO "built"
1193                    }
1194                    AWAIT $task
1195                "#},
1196                },
1197            ],
1198        },
1199        CommandMeta {
1200            name: "AWAIT",
1201            syntax: "AWAIT $var | LET $out = AWAIT $var",
1202            summary: "Join a background task.",
1203            description: "Blocks until the named task completes. Propagates errors if the task failed. Bare `AWAIT $var` forwards the task's stdout to the parent stdout; `LET $out = AWAIT $var` captures it into `$out` instead (same UTF-8 and spilling rules as `LET $var = <command>`).",
1204            args: &[],
1205            flags: &[],
1206            default_output: None,
1207            examples: &[
1208                Example {
1209                    name: "await",
1210                    fence_meta: None,
1211                    code: indoc! {r#"
1212                LET $task = ASYNC ECHO "done"
1213                AWAIT $task
1214            "#},
1215                },
1216                Example {
1217                    name: "await capture",
1218                    fence_meta: None,
1219                    code: indoc! {r#"
1220                LET $task = ASYNC ECHO "done"
1221                LET $out = AWAIT $task
1222                WRITE captured.txt "{{ $out }}"
1223                ASSERT_FILE captured.txt "done\n"
1224            "#},
1225                },
1226            ],
1227        },
1228        CommandMeta {
1229            name: "CANCEL",
1230            syntax: "CANCEL $var",
1231            summary: "Synchronously cancel a background task.",
1232            description: "Kills the named background task spawned via LET $var = ASYNC .... Blocking: returns only after the task thread has been joined and its OS process reaped, so no residual filesystem or stream mutation follows. A later AWAIT $var reports cancellation. Only named tasks can be cancelled.",
1233            args: &[],
1234            flags: &[],
1235            default_output: None,
1236            examples: &[Example {
1237                name: "cancel",
1238                fence_meta: None,
1239                code: indoc! {r#"
1240                LET $task = ASYNC SLEEP 30s
1241                CANCEL $task
1242            "#},
1243            }],
1244        },
1245        CommandMeta {
1246            name: "TIMEOUT",
1247            syntax: "TIMEOUT <duration> <command...> | TIMEOUT <duration> { <commands> } | TIMEOUT <duration> AWAIT $var",
1248            summary: "Enforce an execution deadline.",
1249            description: "Aborts the wrapped step or block with a deadline error if it exceeds the duration (e.g. 500ms, 10s, 2m; a bare number means seconds). A blocking foreground process is killed.",
1250            args: &[],
1251            flags: &[],
1252            default_output: None,
1253            examples: &[
1254                Example {
1255                    name: "timeout",
1256                    fence_meta: None,
1257                    code: indoc! {r#"TIMEOUT 30s WRITE heartbeat.txt alive"#},
1258                },
1259                Example {
1260                    name: "timeout block",
1261                    fence_meta: None,
1262                    code: indoc! {r#"
1263                    TIMEOUT 30s {
1264                        WRITE a.txt one
1265                        WRITE b.txt two
1266                    }
1267                "#},
1268                },
1269                Example {
1270                    name: "timeout variable duration",
1271                    fence_meta: None,
1272                    code: indoc! {r#"
1273                    # durations resolve at runtime, so variables work too
1274                    LET $budget = "30s"
1275                    TIMEOUT $budget WRITE heartbeat.txt alive
1276                    ASSERT_FILE heartbeat.txt alive
1277                "#},
1278                },
1279            ],
1280        },
1281    ]
1282}
1283
1284// ── Display ────────────────────────────────────────────────────────────────
1285
1286impl fmt::Display for StepKind {
1287    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1288        match self {
1289            StepKind::InheritEnv { keys } => write!(f, "INHERIT_ENV [{}]", keys.join(", ")),
1290            StepKind::Workdir(a) => write!(f, "WORKDIR {}", fmt_value(a, quote_arg)),
1291            StepKind::Workspace(t) => write!(f, "WORKSPACE {}", t),
1292            StepKind::Env { key, value } => {
1293                write!(f, "ENV {}={}", key, fmt_value(value, quote_arg))
1294            }
1295            StepKind::Run(c) => write!(f, "RUN {}", fmt_value(c, quote_run)),
1296            StepKind::RunExec { argv } => {
1297                let parts: Vec<String> = argv.iter().map(fmt_exec_arg).collect();
1298                write!(f, "RUN [{}]", parts.join(", "))
1299            }
1300            StepKind::Echo(m) => write!(f, "ECHO {}", fmt_value(m, quote_msg)),
1301            StepKind::Copy {
1302                from_current_workspace,
1303                from,
1304                to,
1305            } => {
1306                if *from_current_workspace {
1307                    write!(
1308                        f,
1309                        "COPY --from-current-workspace {} {}",
1310                        fmt_value(from, quote_arg),
1311                        fmt_value(to, quote_arg)
1312                    )
1313                } else {
1314                    write!(
1315                        f,
1316                        "COPY {} {}",
1317                        fmt_value(from, quote_arg),
1318                        fmt_value(to, quote_arg)
1319                    )
1320                }
1321            }
1322            StepKind::Symlink { from, to } => write!(
1323                f,
1324                "SYMLINK {} {}",
1325                fmt_value(from, quote_arg),
1326                fmt_value(to, quote_arg)
1327            ),
1328            StepKind::Mkdir(a) => write!(f, "MKDIR {}", fmt_value(a, quote_arg)),
1329            StepKind::Ls(a) => {
1330                write!(f, "LS")?;
1331                if let Some(x) = a {
1332                    write!(f, " {}", fmt_value(x, quote_arg))?;
1333                }
1334                Ok(())
1335            }
1336            StepKind::Cwd => write!(f, "CWD"),
1337            StepKind::Read(a) => {
1338                write!(f, "READ")?;
1339                if let Some(x) = a {
1340                    write!(f, " {}", fmt_value(x, quote_arg))?;
1341                }
1342                Ok(())
1343            }
1344            StepKind::ReadLine { var } => write!(f, "READ_LINE ${}", var),
1345            StepKind::Write { path, contents } => {
1346                write!(f, "WRITE {}", fmt_value(path, quote_arg))?;
1347                if let Some(b) = contents {
1348                    write!(f, " {}", fmt_value(b, quote_msg))?;
1349                }
1350                Ok(())
1351            }
1352            StepKind::Append { path, contents } => {
1353                write!(f, "APPEND {}", fmt_value(path, quote_arg))?;
1354                if let Some(b) = contents {
1355                    write!(f, " {}", fmt_value(b, quote_msg))?;
1356                }
1357                Ok(())
1358            }
1359            StepKind::Expand { path, overrides } => {
1360                write!(f, "EXPAND")?;
1361                if let Some(p) = path {
1362                    write!(f, " {}", fmt_value(p, quote_arg))?;
1363                }
1364                for (k, v) in overrides {
1365                    write!(f, " {}={}", k, fmt_value(v, quote_arg))?;
1366                }
1367                Ok(())
1368            }
1369            StepKind::AssertFile {
1370                hash,
1371                path,
1372                contents,
1373            } => {
1374                if let Some(d) = hash {
1375                    write!(f, "ASSERT_FILE --hash {} {}", d, fmt_value(path, quote_arg))
1376                } else {
1377                    write!(f, "ASSERT_FILE {}", fmt_value(path, quote_arg))?;
1378                    if let Some(b) = contents {
1379                        write!(f, " {}", fmt_value(b, quote_msg))?;
1380                    }
1381                    Ok(())
1382                }
1383            }
1384            StepKind::AssertDir(a) => write!(f, "ASSERT_DIR {}", fmt_value(a, quote_arg)),
1385            StepKind::AssertAbsent(a) => write!(f, "ASSERT_ABSENT {}", fmt_value(a, quote_arg)),
1386            StepKind::AssertStdout(m) => write!(f, "ASSERT_STDOUT {}", fmt_value(m, quote_msg)),
1387            StepKind::WithIo { bindings, cmd } => {
1388                let p: Vec<String> = bindings.iter().map(fmt_io).collect();
1389                write!(f, "WITH_IO [{}] {}", p.join(", "), cmd)
1390            }
1391            StepKind::WithIoBlock { bindings } => {
1392                let p: Vec<String> = bindings.iter().map(fmt_io).collect();
1393                write!(f, "WITH_IO [{}] {{...}}", p.join(", "))
1394            }
1395            StepKind::CopyGit {
1396                rev,
1397                from,
1398                to,
1399                include_dirty,
1400            } => {
1401                if *include_dirty {
1402                    write!(
1403                        f,
1404                        "COPY_GIT --include-dirty {} {} {}",
1405                        fmt_value(rev, quote_arg),
1406                        fmt_value(from, quote_arg),
1407                        fmt_value(to, quote_arg)
1408                    )
1409                } else {
1410                    write!(
1411                        f,
1412                        "COPY_GIT {} {} {}",
1413                        fmt_value(rev, quote_arg),
1414                        fmt_value(from, quote_arg),
1415                        fmt_value(to, quote_arg)
1416                    )
1417                }
1418            }
1419            StepKind::HashSha256 { path } => {
1420                write!(f, "HASH_SHA256 {}", fmt_value(path, quote_arg))
1421            }
1422            StepKind::Exit(code) => write!(f, "EXIT {}", fmt_raw_arg(code)),
1423            StepKind::Sleep { duration } => write!(f, "SLEEP {}", fmt_raw_arg(duration)),
1424            StepKind::For {
1425                key_var,
1426                var,
1427                in_expr,
1428                body,
1429            } => {
1430                match key_var {
1431                    Some(k) => write!(f, "FOR ${}, ${} IN {} {{", k, var, in_expr)?,
1432                    None => write!(f, "FOR ${} IN {} {{", var, in_expr)?,
1433                }
1434                for s in body {
1435                    write!(f, "\n    {}", s)?;
1436                }
1437                write!(f, "\n}}")
1438            }
1439            StepKind::If {
1440                cond,
1441                then_body,
1442                else_ifs,
1443                else_body,
1444            } => {
1445                write!(f, "IF {} {{", cond)?;
1446                for s in then_body {
1447                    write!(f, "\n    {}", s)?;
1448                }
1449                write!(f, " }}")?;
1450                for (c, b) in else_ifs {
1451                    write!(f, " ELSE IF {} {{", c)?;
1452                    for s in b {
1453                        write!(f, "\n    {}", s)?;
1454                    }
1455                    write!(f, " }}")?;
1456                }
1457                if let Some(b) = else_body {
1458                    write!(f, " ELSE {{")?;
1459                    for s in b {
1460                        write!(f, "\n    {}", s)?;
1461                    }
1462                    write!(f, " }}")?;
1463                }
1464                Ok(())
1465            }
1466            StepKind::Assign { var, expr } => write!(f, "LET ${} = {}", var, expr),
1467            StepKind::AssignCapture { var, cmd } => write!(f, "LET ${} = {}", var, cmd),
1468            StepKind::AsyncBlock { body } => {
1469                write!(f, "ASYNC {{")?;
1470                for s in body {
1471                    write!(f, "\n    {}", s)?;
1472                }
1473                write!(f, "\n}}")
1474            }
1475            StepKind::AssignAsync { var, body } => {
1476                write!(f, "LET ${} = ASYNC {{", var)?;
1477                for s in body {
1478                    write!(f, "\n    {}", s)?;
1479                }
1480                write!(f, "\n}}")
1481            }
1482            StepKind::Await { var } => write!(f, "AWAIT ${}", var),
1483            StepKind::AwaitCapture { out_var, task_var } => {
1484                write!(f, "LET ${} = AWAIT ${}", out_var, task_var)
1485            }
1486            StepKind::Cancel { var } => write!(f, "CANCEL ${}", var),
1487            StepKind::Timeout { duration, body } => {
1488                let budget = fmt_raw_arg(duration);
1489                if body.len() == 1 {
1490                    write!(f, "TIMEOUT {} {}", budget, body[0].kind)
1491                } else {
1492                    write!(f, "TIMEOUT {} {{", budget)?;
1493                    for s in body {
1494                        write!(f, "\n    {}", s)?;
1495                    }
1496                    write!(f, "\n}}")
1497                }
1498            }
1499        }
1500    }
1501}
1502
1503#[cfg(test)]
1504mod tests {
1505    use super::*;
1506    use crate::command::{format_duration, parse_duration};
1507    use crate::parser::parse_script;
1508
1509    fn parse_err(script: &str) -> String {
1510        parse_script(script, lower_command)
1511            .expect_err("script must fail to parse")
1512            .to_string()
1513    }
1514
1515    #[test]
1516    fn malformed_with_io_binding_names_the_bad_binding() {
1517        let err = parse_err("WITH_IO [stdout=discard] ECHO \"test\"\n");
1518        assert!(err.contains("invalid syntax for command WITH_IO"), "{err}");
1519        assert!(!err.contains("unknown command"), "{err}");
1520        assert!(err.contains("stdout=discard"), "{err}");
1521        assert!(err.contains("pipe:<name>"), "{err}");
1522    }
1523
1524    #[test]
1525    fn await_without_task_variable_points_at_syntax() {
1526        let err = parse_err("AWAIT ECHO \"test\"\n");
1527        assert!(err.contains("invalid syntax for command AWAIT"), "{err}");
1528        assert!(!err.contains("unknown command"), "{err}");
1529        assert!(err.contains("AWAIT $t"), "{err}");
1530        assert!(err.contains("ECHO"), "{err}");
1531    }
1532
1533    #[test]
1534    fn structural_fallthrough_commits_per_keyword() {
1535        for (script, cmd) in [
1536            ("CANCEL foo\n", "CANCEL"),
1537            ("TIMEOUT foo\n", "TIMEOUT"),
1538            ("FOR foo\n", "FOR"),
1539            ("IF foo\n", "IF"),
1540            ("LET foo\n", "LET"),
1541            // NOTE: INHERIT_ENV is dual-registered as a leaf command
1542            // (`INHERIT_ENV <key>...`), so `INHERIT_ENV foo` lowers
1543            // successfully instead of erroring — excluded here.
1544            ("ASYNC\n", "ASYNC"),
1545            ("ELSE foo\n", "ELSE"),
1546        ] {
1547            let err = parse_err(script);
1548            assert!(
1549                err.contains(&format!("invalid syntax for command {cmd}")),
1550                "{cmd}: {err}"
1551            );
1552            assert!(!err.contains("unknown command"), "{cmd}: {err}");
1553        }
1554    }
1555
1556    #[test]
1557    fn leaf_arity_errors_carry_invalid_syntax_prefix() {
1558        let err = parse_err("SLEEP 1s 2s\n");
1559        assert!(err.contains("invalid syntax for command SLEEP"), "{err}");
1560        assert!(!err.contains("unknown command"), "{err}");
1561    }
1562
1563    #[test]
1564    fn genuinely_unknown_command_keeps_bare_message() {
1565        let err = parse_err("FROBNICATE hi\n");
1566        assert!(err.contains("unknown command: FROBNICATE"), "{err}");
1567        assert!(!err.contains("did you mean"), "{err}");
1568    }
1569
1570    #[test]
1571    fn lowercase_command_suggests_uppercase() {
1572        // Lowercase never reaches lowering through `parse_script` (the
1573        // grammar rejects it with its own uppercase hint), so exercise the
1574        // public `lower_command` dispatcher directly.
1575        let err = lower_command("echo", vec![Arg::String("hi".to_string(), false)])
1576            .expect_err("must fail")
1577            .to_string();
1578        assert!(err.contains("unknown command: echo"), "{err}");
1579        assert!(err.contains("did you mean `ECHO`"), "{err}");
1580    }
1581
1582    #[test]
1583    fn parse_duration_units() {
1584        use std::time::Duration;
1585        assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
1586        assert_eq!(parse_duration("10s").unwrap(), Duration::from_secs(10));
1587        assert_eq!(parse_duration("2m").unwrap(), Duration::from_secs(120));
1588        assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
1589        assert_eq!(parse_duration("30").unwrap(), Duration::from_secs(30));
1590    }
1591
1592    #[test]
1593    fn parse_duration_rejects_garbage() {
1594        assert!(parse_duration("").is_err());
1595        assert!(parse_duration("banana").is_err());
1596        assert!(parse_duration("10x").is_err());
1597        assert!(parse_duration("0s").is_err());
1598        assert!(parse_duration("0").is_err());
1599        assert!(parse_duration("-5s").is_err());
1600    }
1601
1602    #[test]
1603    fn format_duration_round_trips() {
1604        for text in ["500ms", "10s", "2m", "1h", "90s", "1500ms"] {
1605            let parsed = parse_duration(text).unwrap();
1606            let rendered = format_duration(&parsed);
1607            assert_eq!(
1608                parse_duration(&rendered).unwrap(),
1609                parsed,
1610                "round-trip failed for {text}"
1611            );
1612        }
1613        assert_eq!(format_duration(&parse_duration("90s").unwrap()), "90s");
1614        assert_eq!(format_duration(&parse_duration("2m").unwrap()), "2m");
1615    }
1616
1617    #[test]
1618    fn structural_metadata_covers_all_structural_kinds() {
1619        use crate::ast::Value;
1620
1621        // Tripwire: adding a structural StepKind variant without registering
1622        // documentation fails to compile here (non-exhaustive match). Leaf
1623        // commands map to None; they are covered by declare_commands!.
1624        fn metadata_name(kind: &StepKind) -> Option<&'static str> {
1625            match kind {
1626                StepKind::WithIo { .. } | StepKind::WithIoBlock { .. } => Some("WITH_IO"),
1627                StepKind::For { .. } => Some("FOR"),
1628                StepKind::If { .. } => Some("IF"),
1629                StepKind::Assign { .. } => Some("LET"),
1630                StepKind::AssignCapture { .. } => Some("LET"),
1631                StepKind::AwaitCapture { .. } => Some("AWAIT"),
1632                StepKind::AsyncBlock { .. } | StepKind::AssignAsync { .. } => Some("ASYNC"),
1633                StepKind::Await { .. } => Some("AWAIT"),
1634                StepKind::Cancel { .. } => Some("CANCEL"),
1635                StepKind::Timeout { .. } => Some("TIMEOUT"),
1636                StepKind::RunExec { .. } => None,
1637                StepKind::Workdir(_)
1638                | StepKind::Workspace(_)
1639                | StepKind::Env { .. }
1640                | StepKind::InheritEnv { .. }
1641                | StepKind::Run(_)
1642                | StepKind::Echo(_)
1643                | StepKind::Copy { .. }
1644                | StepKind::Symlink { .. }
1645                | StepKind::Mkdir(_)
1646                | StepKind::Ls(_)
1647                | StepKind::Cwd
1648                | StepKind::Read(_)
1649                | StepKind::ReadLine { .. }
1650                | StepKind::Write { .. }
1651                | StepKind::Append { .. }
1652                | StepKind::Expand { .. }
1653                | StepKind::AssertFile { .. }
1654                | StepKind::AssertDir(_)
1655                | StepKind::AssertAbsent(_)
1656                | StepKind::AssertStdout(_)
1657                | StepKind::CopyGit { .. }
1658                | StepKind::HashSha256 { .. }
1659                | StepKind::Exit(_)
1660                | StepKind::Sleep { .. } => None,
1661            }
1662        }
1663
1664        // Exercise the matcher once per structural variant so the arms cannot
1665        // rot (a new variant breaks compilation above first).
1666        let dummies: Vec<StepKind> = vec![
1667            StepKind::WithIo {
1668                bindings: Vec::new(),
1669                cmd: Box::new(StepKind::Echo(crate::ast::Arg::String(
1670                    "x".to_string(),
1671                    false,
1672                ))),
1673            },
1674            StepKind::For {
1675                key_var: None,
1676                var: "i".to_string(),
1677                in_expr: Expr::Literal(Value::Bool(true)),
1678                body: Vec::new(),
1679            },
1680            StepKind::If {
1681                cond: Box::new(Expr::Literal(Value::Bool(true))),
1682                then_body: Vec::new(),
1683                else_ifs: Vec::new(),
1684                else_body: None,
1685            },
1686            StepKind::Assign {
1687                var: "v".to_string(),
1688                expr: Expr::Literal(Value::Bool(true)),
1689            },
1690            StepKind::AssignCapture {
1691                var: "v".to_string(),
1692                cmd: Box::new(StepKind::Echo(crate::ast::Arg::String(
1693                    "x".to_string(),
1694                    false,
1695                ))),
1696            },
1697            StepKind::AwaitCapture {
1698                out_var: "o".to_string(),
1699                task_var: "t".to_string(),
1700            },
1701            StepKind::AsyncBlock { body: Vec::new() },
1702            StepKind::AssignAsync {
1703                var: "t".to_string(),
1704                body: Vec::new(),
1705            },
1706            StepKind::Await {
1707                var: "t".to_string(),
1708            },
1709            StepKind::Cancel {
1710                var: "t".to_string(),
1711            },
1712            StepKind::Timeout {
1713                duration: Arg::String("1s".to_string(), false),
1714                body: Vec::new(),
1715            },
1716        ];
1717        let registry = all_structural_metadata();
1718        for kind in &dummies {
1719            let name = metadata_name(kind).expect("structural kind must map to metadata");
1720            assert!(
1721                registry.iter().any(|meta| meta.name == name),
1722                "no structural metadata entry for {}",
1723                name
1724            );
1725        }
1726    }
1727
1728    #[test]
1729    fn verify_display_sync_with_metadata() {
1730        let registry = all_metadata();
1731        for meta in registry {
1732            if meta.examples.is_empty() {
1733                continue;
1734            }
1735
1736            let code = meta.examples[0].code;
1737            let ast = parse_script(code, lower_command)
1738                .unwrap_or_else(|e| panic!("Failed to parse example for {}: {}", meta.name, e));
1739
1740            let matching = ast.iter().find(|step| {
1741                let kind = match &step.kind {
1742                    StepKind::WithIo { cmd, .. } => &**cmd,
1743                    other => other,
1744                };
1745                // Full Display covers wrapper kinds themselves (e.g. a
1746                // WithIo step displays as WITH_IO ...); unwrapped covers
1747                // wrapped leaf commands.
1748                kind.to_string().starts_with(meta.name)
1749                    || step.kind.to_string().starts_with(meta.name)
1750            });
1751
1752            assert!(
1753                matching.is_some(),
1754                "No step in example for {} produces Display starting with {}",
1755                meta.name,
1756                meta.name
1757            );
1758        }
1759    }
1760}