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::{
18    Arg, ArgPart, Expr, IoBinding, IoStream, PipeTarget, Step, TypeKind, WorkspaceTarget,
19};
20use crate::command::{
21    ArgSpec, ArgType, CommandMeta, Example, FlagSpec, FlagValueType, IoDirection, Stream,
22    split_assignment,
23};
24use anyhow::{Result, anyhow, bail};
25use indoc::indoc;
26
27// ── Helpers ────────────────────────────────────────────────────────────────
28
29// Value-parsing helpers (`strip_surrounding_quotes`,
30// `split_assignment`, `parse_duration`, `format_duration`) live in
31// `crate::command` beside the `ArgType` validators that call them.
32
33/// Join free-text tail arguments into one value. Single args pass through
34/// untouched (preserving `Arg::Expr`); all-`String` tails join exactly like the
35/// historical `join_args`; tails containing expressions become `Arg::Parts`
36/// with single-space separators so `$x` is never silently dropped.
37fn join_value(args: Vec<Arg>, cmd_name: &str) -> Result<Arg> {
38    if args.is_empty() {
39        bail!("{cmd_name} requires at least one argument");
40    }
41    if args.len() == 1 {
42        return Ok(args.into_iter().next().unwrap());
43    }
44    if args.iter().all(|a| matches!(a, Arg::String(..))) {
45        return Ok(Arg::String(
46            args.iter()
47                .map(|a| a.as_str())
48                .collect::<Vec<_>>()
49                .join(" "),
50            false,
51        ));
52    }
53    let mut parts = Vec::new();
54    for (index, arg) in args.into_iter().enumerate() {
55        if index > 0 {
56            parts.push(ArgPart::Text(" ".to_string(), false));
57        }
58        match arg {
59            Arg::String(text, quoted) => parts.push(ArgPart::Text(text, quoted)),
60            Arg::Expr(expr) => parts.push(ArgPart::Expr(expr)),
61            Arg::Parts(inner) => parts.extend(inner),
62        }
63    }
64    Ok(Arg::Parts(parts))
65}
66
67/// Canonical `lower_command` entry for direct callers holding one pre-joined
68/// `KEY=value` token. Script parsing never reaches this — the grammar splits
69/// assignments on raw spans first (see `lower_env_command` in parser.rs).
70pub fn lower_env_assignment(args: Vec<Arg>) -> Result<StepKind> {
71    let arg = args
72        .into_iter()
73        .next()
74        .ok_or_else(|| anyhow!("ENV requires KEY=value"))?;
75    let Some((key, value)) = split_assignment(arg.as_str())? else {
76        bail!("ENV requires KEY=value format")
77    };
78    Ok(StepKind::Env { key, value })
79}
80
81/// Collapse a grammar-classified assignment for commands that take no
82/// assignments (`RUN`, `COPY`, ...): canonical `key=<rendered value>` text.
83/// Runtime semantics survive intact — `{{ }}` templates stay textual for
84/// `expand_string`, and `RUN`'s own post-pass expands bare `$var`.
85pub(crate) fn canonical_assignment_arg(key: &str, value: &Arg) -> Arg {
86    Arg::String(format!("{key}={}", value.render()), false)
87}
88
89/// Render one `Arg` for `Display`: expressions print raw (`$x` must never be
90/// quoted or reparsing would literalize them); mixed values print raw unless
91/// they hold instruction-boundary characters (`;`, `}`, linebreaks), which
92/// force quoting for reparseability.
93fn fmt_value(arg: &Arg, quote: fn(&str) -> String) -> String {
94    match arg {
95        Arg::Expr(_) => arg.render(),
96        Arg::String(text, _) => quote(text),
97        Arg::Parts(_) => {
98            let rendered = arg.render();
99            if rendered.contains(';')
100                || rendered.contains('}')
101                || rendered.contains('\n')
102                || rendered.contains('\r')
103            {
104                quote(&rendered)
105            } else {
106                rendered
107            }
108        }
109    }
110}
111
112fn quote_arg(s: &str) -> String {
113    let is_safe = s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
114        && !s.starts_with(|c: char| c.is_ascii_digit() || c == '-' || c == '/' || c == '.')
115        && crate::Command::parse(s).is_none();
116    if is_safe && !s.is_empty() {
117        s.to_string()
118    } else {
119        format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
120    }
121}
122
123fn quote_msg(s: &str) -> String {
124    let safe = s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
125        && !s.starts_with(|c: char| c.is_ascii_digit())
126        && crate::Command::parse(s).is_none();
127    if safe && !s.is_empty() {
128        s.to_string()
129    } else {
130        format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
131    }
132}
133
134fn quote_run(s: &str) -> String {
135    if s.is_empty() || s.chars().any(|c| c == ';' || c == '\n') || s.contains("//") {
136        return format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""));
137    }
138    s.split(' ')
139        .map(|w| {
140            if w.starts_with(|c: char| c.is_ascii_digit())
141                || w.starts_with(['/', '.', '-', ':', '='])
142            {
143                format!("\"{}\"", w.replace('\\', "\\\\").replace('"', "\\\""))
144            } else {
145                w.to_string()
146            }
147        })
148        .collect::<Vec<_>>()
149        .join(" ")
150}
151
152/// Render one exec-form (`RUN [...]`) argv element for `Display`:
153/// string literals print JSON-quoted; typed expressions (`$var`,
154/// `CALL()`, ints, bools, nested lists) print raw via `render` so
155/// reparsing yields the same typed element; mixed values print raw
156/// unless they hold instruction-boundary characters.
157fn fmt_exec_arg(arg: &Arg) -> String {
158    match arg {
159        Arg::String(text, _) => {
160            format!("\"{}\"", text.replace('\\', "\\\\").replace('"', "\\\""))
161        }
162        Arg::Expr(_) => arg.render(),
163        Arg::Parts(_) => {
164            let rendered = arg.render();
165            if rendered.contains(';')
166                || rendered.contains('}')
167                || rendered.contains('\n')
168                || rendered.contains('\r')
169            {
170                format!(
171                    "\"{}\"",
172                    rendered.replace('\\', "\\\\").replace('"', "\\\"")
173                )
174            } else {
175                rendered
176            }
177        }
178    }
179}
180
181/// Render an [`Arg`] for `Display`: the quoted flag drives quoting (not
182/// content sniffing — digit-leading values like `10s` or `0` must stay
183/// bare to reparse with the same flag).
184fn fmt_raw_arg(arg: &Arg) -> String {
185    match arg {
186        Arg::String(s, true) => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")),
187        _ => arg.render(),
188    }
189}
190
191fn fmt_io(b: &IoBinding) -> String {
192    let s = match b.stream {
193        IoStream::Stdin => "stdin",
194        IoStream::Stdout => "stdout",
195        IoStream::Stderr => "stderr",
196    };
197    match &b.pipe {
198        Some(PipeTarget::Name(p)) => format!("{}=pipe:{}", s, p),
199        Some(PipeTarget::Var(v)) => format!("{}=${}", s, v),
200        None => s.to_string(),
201    }
202}
203
204// ── declare_commands! ──────────────────────────────────────────────────────
205
206// Keywords parsed by PEG rules rather than plain-command lowering (`WITH_IO`,
207// `AWAIT`, ...). When a line starts with one of these but fails to parse as
208// such, lowering falls through here — report a committed syntax error instead
209// of an unknown command.
210pub(crate) fn is_known_command(name: &str) -> bool {
211    if name == "ELSE" {
212        return true;
213    }
214    all_metadata().iter().any(|meta| meta.name == name)
215}
216
217pub(crate) fn invalid_syntax_error(name: &str, raw_args: &[Arg]) -> anyhow::Error {
218    let received = raw_args
219        .iter()
220        .map(Arg::render)
221        .collect::<Vec<_>>()
222        .join(" ");
223    let got = if received.is_empty() {
224        "nothing".to_string()
225    } else {
226        format!("`{received}`")
227    };
228    match structural_hint(name, &received) {
229        Some(hint) => anyhow!("invalid syntax for command {name}: {hint}"),
230        None => anyhow!("invalid syntax for command {name}: got {got}."),
231    }
232}
233
234fn unknown_command_error(name: &str, raw_args: &[Arg]) -> anyhow::Error {
235    let received = raw_args
236        .iter()
237        .map(Arg::render)
238        .collect::<Vec<_>>()
239        .join(" ");
240    let hint = structural_hint(name, &received).or_else(|| case_hint(name));
241    match hint {
242        Some(hint) => anyhow!("unknown command: {name}\n{hint}"),
243        None => anyhow!("unknown command: {name}"),
244    }
245}
246
247fn structural_hint(name: &str, received: &str) -> Option<String> {
248    let got = if received.is_empty() {
249        "nothing".to_string()
250    } else {
251        format!("`{received}`")
252    };
253    match name {
254        "WITH_IO" => Some(with_io_hint(&got, received)),
255        "AWAIT" => Some(format!(
256            "AWAIT waits for a background task variable, e.g. `LET $t: HANDLE = ASYNC ECHO hi` then `AWAIT $t`; got {got}."
257        )),
258        "CANCEL" => Some(format!(
259            "CANCEL stops a background task variable, e.g. `CANCEL $t` (from `LET $t: HANDLE = ASYNC ...`); got {got}."
260        )),
261        "ASYNC" => Some(format!(
262            "ASYNC runs a command in the background, e.g. `ASYNC RUN ...`, `ASYNC {{ ... }}`, or `LET $t: HANDLE = ASYNC ...`; got {got}."
263        )),
264        "FOR" => Some(format!(
265            "FOR loops need `FOR $item: TYPE IN <expr> {{ ... }}` (or `FOR $key: STRING, $value: TYPE IN <expr> {{ ... }}`); got {got}."
266        )),
267        "IF" => Some(format!(
268            "IF needs a condition and a block, e.g. `IF true {{ ECHO yes }}`; got {got}."
269        )),
270        "ELSE" => Some(format!(
271            "ELSE must directly follow an `IF ... {{ ... }}` block, e.g. `IF true {{ ECHO yes }} ELSE {{ ECHO no }}`; got {got}."
272        )),
273        "LET" => Some(format!(
274            "LET assigns a variable, e.g. `LET $name: STRING = <expr>`, `LET $t: HANDLE = ASYNC ...`, `LET $out: STRING = <command>` (capture), or `LET $out: STRING = AWAIT $t`; got {got}."
275        )),
276        "SET" => Some(
277            "`SET` is not a keyword; mutate a declared variable with `$var = <expr>`, e.g. `$count = 2`.".to_string(),
278        ),
279        "TIMEOUT" => Some(format!(
280            "TIMEOUT needs a duration and a command or block, e.g. `TIMEOUT 30s RUN ...`; got {got}."
281        )),
282        "FUNC" => Some(format!(
283            "FUNC defines a function, e.g. `FUNC GREET($name: STRING) {{ RETURN $name }}`; got {got}."
284        )),
285        "CALL" => Some(format!(
286            "CALL invokes a function, e.g. `CALL GREET(\"ada\")` or `LET $r: STRING = CALL GREET(\"ada\")`; got {got}."
287        )),
288        "RETURN" => Some(format!(
289            "RETURN ends a function with a value, e.g. `RETURN $x`; got {got}."
290        )),
291        "WHILE" => Some(format!(
292            "WHILE needs a Bool condition and a block, e.g. `WHILE !$done {{ ... }}`; got {got}."
293        )),
294        "BREAK" => Some(
295            "`BREAK` exits the innermost enclosing FOR/WHILE loop; it must appear inside a loop.".to_string(),
296        ),
297        "CONTINUE" => Some(
298            "`CONTINUE` skips to the next iteration of the innermost enclosing FOR/WHILE loop; it must appear inside a loop.".to_string(),
299        ),
300        "INHERIT_ENV" => Some(format!(
301            "INHERIT_ENV takes a key list, e.g. `INHERIT_ENV [HOME PATH]`; got {got}."
302        )),
303        _ => None,
304    }
305}
306
307/// Diagnose a `WITH_IO` line that failed to parse: most often a malformed
308/// binding list (bindings are bare streams or `<stream>=pipe:<name>`).
309fn with_io_hint(got: &str, received: &str) -> String {
310    const SYNTAX: &str =
311        "WITH_IO needs `WITH_IO [bindings] <command>` or `WITH_IO [bindings] { <commands> }`";
312    const BINDINGS: &str = "bindings are `stdin`, `stdout`, `stderr`, `<stream>=pipe:<name>`, or `<stream>=$var` with a PIPE-typed variable (e.g. `[stdout=pipe:log]`, `[stdin=$p]`)";
313    if let Some(after_open) = received.strip_prefix('[') {
314        match after_open.split_once(']') {
315            None => {
316                return format!("{SYNTAX}: missing closing `]` in the binding list; got {got}.");
317            }
318            Some((bindings, _)) => {
319                for part in bindings.split(',') {
320                    let part = part.trim();
321                    if part.is_empty() {
322                        continue;
323                    }
324                    let (stream, binding) = match part.split_once('=') {
325                        Some((stream, binding)) => (stream.trim(), Some(binding.trim())),
326                        None => (part, None),
327                    };
328                    if !matches!(stream, "stdin" | "stdout" | "stderr") {
329                        return format!(
330                            "{SYNTAX}: invalid stream `{stream}`; expected `stdin`, `stdout`, or `stderr`; got {got}."
331                        );
332                    }
333                    let valid = match binding {
334                        None => true,
335                        Some(value) => value
336                            .strip_prefix("pipe:")
337                            .map(|pipe| !pipe.trim().is_empty())
338                            .unwrap_or(false),
339                    };
340                    if !valid {
341                        return format!(
342                            "{SYNTAX}: invalid binding `{part}`; {BINDINGS}; got {got}."
343                        );
344                    }
345                }
346            }
347        }
348    }
349    format!("{SYNTAX}; got {got}. {BINDINGS}.")
350}
351
352/// `echo hi` is almost certainly `ECHO hi`: commands are uppercase.
353fn case_hint(name: &str) -> Option<String> {
354    let upper = name.to_ascii_uppercase();
355    if upper != name
356        && all_metadata()
357            .iter()
358            .any(|meta| meta.name == upper.as_str())
359    {
360        return Some(format!("did you mean `{upper}`? commands are uppercase."));
361    }
362    None
363}
364
365macro_rules! declare_commands {
366    (
367        structural [
368            $( $sname:ident $( { $( $sfname:ident : $sftype:ty ),* $(,)? } )? ),* $(,)?
369        ]
370
371        $(
372            $cmd_ident:ident => [
373                name: $name:expr,
374                variant: $vname:ident $( { $( $vfname:ident : $vftype:ty ),* $(,)? } )? $( ( $( $ttuple:ty ),* $(,)? ) )?,
375                syntax: $syntax:expr,
376                summary: $summary:expr,
377                description: $desc:expr,
378                args: $args:expr,
379                flags: $flags:expr,
380                default_output: $out:expr,
381                examples: $examples:expr,
382                lower: $lower:expr,
383            ]
384        ),* $(,)?
385    ) => {
386        #[derive(Debug, Clone, PartialEq)]
387        pub enum StepKind {
388            $( $vname $( { $( $vfname : $vftype ),* } )? $( ( $( $ttuple ),* ) )?, )*
389            $( $sname $( { $( $sfname : $sftype ),* } )?, )*
390        }
391
392        pub fn lower_command(name: &str, raw_args: Vec<Arg>) -> Result<StepKind> {
393            match name {
394                $(
395                    s if s == $name => {
396                        let meta = CommandMeta {
397                            name: $name, syntax: $syntax, summary: $summary,
398                            description: $desc, args: $args, flags: $flags,
399                            default_output: $out, examples: $examples,
400                        };
401                        let (flags, positional) = crate::strip_flags(raw_args, &meta)?;
402                        crate::command::validate_positionals_against_meta(
403                            s,
404                            &meta.args,
405                            &positional,
406                        )?;
407                        let lower_fn: fn(Vec<(String, Arg)>, Vec<Arg>) -> Result<StepKind> = $lower;
408                        lower_fn(flags, positional)
409                    }
410                )*
411                _ => {
412                    if is_known_command(name) {
413                        Err(invalid_syntax_error(name, &raw_args))
414                    } else {
415                        Err(unknown_command_error(name, &raw_args))
416                    }
417                }
418            }
419        }
420
421        pub fn all_metadata() -> Vec<CommandMeta> {
422            let mut out = vec![
423                $( CommandMeta {
424                    name: $name, syntax: $syntax, summary: $summary,
425                    description: $desc, args: $args, flags: $flags,
426                    default_output: $out, examples: $examples,
427                }, )*
428            ];
429            // Structural statements are registered separately (see
430            // all_structural_metadata) but documented through the same
431            // pipeline so docs-gen never drifts from the parser.
432            out.extend(all_structural_metadata());
433            out
434        }
435    };
436}
437
438declare_commands! {
439    structural [
440        WithIo { bindings: Vec<IoBinding>, cmd: Box<StepKind> },
441        WithIoBlock { bindings: Vec<IoBinding> },
442        For { key_var: Option<String>, key_type: Option<TypeKind>, var: String, var_type: TypeKind, in_expr: Expr, body: Vec<Step> },
443        If { cond: Box<Expr>, then_body: Vec<Step>, else_ifs: Vec<(Box<Expr>, Vec<Step>)>, else_body: Option<Vec<Step>> },
444        Assign { var: String, decl_type: TypeKind, expr: Expr },
445        Set { var: String, expr: Expr },
446        AssignCapture { var: String, decl_type: TypeKind, cmd: Box<StepKind> },
447        AwaitCapture { out_var: String, out_type: TypeKind, task_var: String },
448        AsyncBlock { body: Vec<Step> },
449        AssignAsync { var: String, decl_type: TypeKind, body: Vec<Step> },
450        Await { var: String },
451        Cancel { var: String },
452        Timeout { duration: Arg, body: Vec<Step> },
453        RunExec { argv: Vec<Arg> },
454        FuncDef { name: String, params: Vec<(String, TypeKind)>, body: Vec<Step> },
455        Call { name: String, args: Vec<Expr> },
456        Return { expr: Box<Expr> },
457        While { cond: Box<Expr>, body: Vec<Step> },
458        Break,
459        Continue,
460    ]
461
462    Workdir => [
463        name: "WORKDIR",
464        variant: Workdir(Arg),
465        syntax: "WORKDIR <path>",
466        summary: "Change the working directory.",
467        description: indoc! {r#"
468            Sets the current working directory.
469
470            Relative paths resolve against the current directory; `/` resets to
471            the workspace root. Paths cannot escape the workspace.
472        "#},
473        args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Directory to change to", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
474        flags: &[],
475        default_output: None,
476        examples: &[ Example { name: "change working directory", fence_meta: None, code: indoc! {r#"
477            WORKDIR project/src
478            WRITE generated.txt generated-under-workdir
479            ASSERT_FILE generated.txt generated-under-workdir
480        "#} } ],
481        lower: |_flags, args| {
482            let path = args.into_iter().next().ok_or_else(|| anyhow!("WORKDIR requires a path"))?;
483            Ok(StepKind::Workdir(path))
484        },
485    ],
486
487    Workspace => [
488        name: "WORKSPACE",
489        variant: Workspace(WorkspaceTarget),
490        syntax: "WORKSPACE SNAPSHOT|LOCAL",
491        summary: "Switch workspace roots.",
492        description: "SNAPSHOT or LOCAL root.",
493        args: &[ ArgSpec { name: "target", arg_type: ArgType::OneOf(&["SNAPSHOT", "LOCAL"]), description: "Target root", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
494        flags: &[],
495        default_output: None,
496        examples: &[ Example { name: "switch roots", fence_meta: None, code: indoc! {r#"WORKSPACE LOCAL"#} } ],
497        lower: |_flags, args| {
498            let target = args.into_iter().next().ok_or_else(|| anyhow!("WORKSPACE requires a target"))?;
499            match target.as_str() {
500                "SNAPSHOT" | "snapshot" => Ok(StepKind::Workspace(WorkspaceTarget::Snapshot)),
501                "LOCAL" | "local" => Ok(StepKind::Workspace(WorkspaceTarget::Local)),
502                other => bail!("unknown workspace target: {other}"),
503            }
504        },
505    ],
506
507    Env => [
508        name: "ENV",
509        variant: Env { key: String, value: Arg },
510        syntax: "ENV KEY=value",
511        summary: "Set an environment variable.",
512        description: indoc! {r#"
513            Inserts or updates an env var.
514
515            The value uses the unified string-value rules shared by every command:
516            `"..."` or `'...'` quotes keep exact bytes (spaces, tabs), a lone `$var`
517            evaluates that variable, `{{ ... }}` placeholders interpolate, unquoted
518            words join with single spaces, and the first `=` splits key from value
519            (`KEY=a=b` stores `a=b`).
520
521            A `$var` inside larger text stays literal — write `{{ $var }}` to
522            interpolate there.
523        "#},
524        args: &[ ArgSpec { name: "assignment", arg_type: ArgType::KeyValue, description: "KEY=value pair; the value resolves as STRING", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
525        flags: &[],
526        default_output: None,
527        examples: &[
528            Example { name: "set env", fence_meta: None, code: indoc! {r#"ENV APP_MODE=production"#} },
529            Example { name: "quoted value with spaces", fence_meta: None, code: indoc! {r#"
530                # quotes keep the space: SET_FORTH stores `outer scope`
531                ENV SET_FORTH="outer scope"
532                WRITE out.txt "{{ env:SET_FORTH }}"
533                ASSERT_FILE out.txt "outer scope"
534            "#} },
535            Example { name: "variable value", fence_meta: None, code: indoc! {r#"
536                # a lone $var evaluates, like ECHO $var
537                LET $who: STRING = "Alice"
538                ENV GREETING=$who
539                WRITE out.txt "{{ env:GREETING }}"
540                ASSERT_FILE out.txt "Alice"
541            "#} },
542            Example { name: "all value forms agree", fence_meta: None, code: indoc! {r#"
543                # a bare variable, a quoted literal, and a template all
544                # store plain strings through the same value rules
545                LET $x: STRING = "Ada"
546                ENV A=$x
547                ENV B="hello world"
548                ENV C="{{ $x }} concatenated"
549                WRITE check.txt "{{ env:A }}|{{ env:B }}|{{ env:C }}"
550                ASSERT_FILE check.txt "Ada|hello world|Ada concatenated"
551            "#} },
552            Example { name: "scoped env reverts", fence_meta: None, code: indoc! {r#"
553                # ENV inside a braced block reverts when the block exits
554                ENV MODE=production
555                [bool:true] {
556                    ENV MODE=staging
557                    WRITE inner.txt "{{ env:MODE }}"
558                }
559                WRITE outer.txt "{{ env:MODE }}"
560                ASSERT_FILE inner.txt "staging"
561                ASSERT_FILE outer.txt "production"
562            "#} },
563        ],
564        lower: |_flags, args| lower_env_assignment(args),
565    ],
566
567    InheritEnv => [
568        name: "INHERIT_ENV",
569        variant: InheritEnv { keys: Vec<String> },
570        syntax: "INHERIT_ENV <key>...",
571        summary: "Inherit env vars from host.",
572        description: indoc! {r#"
573            Declares which host environment variables to inherit into the script.
574
575            Must appear before any other commands and at most once. Without this
576            directive, the script starts with an empty environment.
577        "#},
578        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 } ],
579        flags: &[],
580        default_output: None,
581        examples: &[ Example { name: "inherit env", fence_meta: None, code: indoc! {r#"INHERIT_ENV [PATH, HOME]"#} } ],
582        lower: |_flags, args| {
583            let keys = args.into_iter().map(|a| a.as_str().to_string()).collect();
584            Ok(StepKind::InheritEnv { keys })
585        },
586    ],
587
588    Echo => [
589        name: "ECHO",
590        variant: Echo(Arg),
591        syntax: "ECHO <message>",
592        summary: "Print to stdout.",
593        description: "Outputs message to stdout.",
594        args: &[ ArgSpec { name: "message", arg_type: ArgType::Rest(&ArgType::String), description: "Text", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
595        flags: &[],
596        default_output: Some(Stream::Stdout),
597        examples: &[
598            Example { name: "echo", fence_meta: None, code: indoc! {r#"ECHO build-complete"#} },
599            Example { name: "variables", fence_meta: None, code: indoc! {r#"
600                # a lone $x evaluates; {{ }} interpolates inside text
601                LET $x: STRING = "World"
602                ECHO {{ $x }}
603                ECHO $x
604                ASSERT_STDOUT "World"
605            "#} },
606        ],
607        lower: |_flags, args| Ok(StepKind::Echo(join_value(args, "ECHO")?)),
608    ],
609
610    Run => [
611        name: "RUN",
612        variant: Run(Arg),
613        syntax: "RUN <command...> | RUN [\"exe\", \"arg\", ...]",
614        summary: "Execute shell command or direct executable.",
615        description: indoc! {r#"
616            Shell form (`RUN <command...>`) runs the joined command string in the
617            system shell (`$SHELL -c` / `COMSPEC /C`).
618
619            Exec form (`RUN ["exe", "arg", ...]`) spawns the executable directly
620            with no shell, so there is no shell expansion, globbing, redirection,
621            or pipes; use it for portable commands.
622
623            Guards and wrappers (`ASYNC`, `TIMEOUT`, `WITH_IO`, `LET`) apply to
624            both forms.
625        "#},
626        args: &[ ArgSpec { name: "command", arg_type: ArgType::Rest(&ArgType::String), description: "Command", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
627        flags: &[],
628        default_output: None,
629        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"]"#} } ],
630        lower: |_flags, args| match args.as_slice() {
631            [Arg::Expr(Expr::List(elems))] if elems.is_empty() => {
632                bail!("RUN requires at least one argument")
633            }
634            [Arg::Expr(Expr::List(elems))] => Ok(StepKind::RunExec {
635                argv: elems.iter().cloned().map(Arg::Expr).collect(),
636            }),
637            _ => Ok(StepKind::Run(join_value(args, "RUN")?)),
638        },
639    ],
640
641    Copy => [
642        name: "COPY",
643        variant: Copy { from_current_workspace: bool, from: Arg, to: Arg },
644        syntax: "COPY [--from-current-workspace] <from> <to>",
645        summary: "Copy file into workspace.",
646        description: "Copies from host.",
647        args: &[
648            ArgSpec { name: "from", arg_type: ArgType::Path, description: "Source", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
649            ArgSpec { name: "to", arg_type: ArgType::Path, description: "Dest", io: IoDirection::Write, index: 1, required: true, fallback_stream: None },
650        ],
651        flags: &[ FlagSpec { name: "from_current_workspace", long: "--from-current-workspace", value_type: FlagValueType::Flag, required: false, description: "Copy from workspace instead of build context" } ],
652        default_output: None,
653        examples: &[ Example { name: "copy", fence_meta: Some("roots:unified"), code: indoc! {r#"
654            WRITE src.txt content
655            COPY src.txt dst.txt
656            ASSERT_FILE dst.txt content
657        "#} }, Example { name: "copy from workspace", fence_meta: Some("roots:unified"), code: indoc! {r#"
658            WRITE ws-src.txt ws-content
659            COPY --from-current-workspace ws-src.txt ws-copy.txt
660            ASSERT_FILE ws-copy.txt ws-content
661        "#} } ],
662        lower: |flags, args| {
663            let from_current_workspace = flags.iter().any(|(k, _)| k == "from_current_workspace");
664            let mut it = args.into_iter();
665            let from = it.next().ok_or_else(|| anyhow!("COPY requires a source"))?;
666            let to = it.next().ok_or_else(|| anyhow!("COPY requires a destination"))?;
667            Ok(StepKind::Copy { from_current_workspace, from, to })
668        },
669    ],
670
671    CopyGit => [
672        name: "COPY_GIT",
673        variant: CopyGit { rev: Arg, from: Arg, to: Arg, include_dirty: bool },
674        syntax: "COPY_GIT [--include-dirty] <rev> <src> <dst>",
675        summary: "Copy from git revision.",
676        description: "Checkout and copy.",
677        args: &[
678            ArgSpec { name: "rev", arg_type: ArgType::String, description: "Rev", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
679            ArgSpec { name: "src", arg_type: ArgType::Path, description: "Src", io: IoDirection::Read, index: 1, required: true, fallback_stream: None },
680            ArgSpec { name: "dst", arg_type: ArgType::Path, description: "Dst", io: IoDirection::Write, index: 2, required: true, fallback_stream: None },
681        ],
682        flags: &[ FlagSpec { name: "dirty", long: "--include-dirty", value_type: FlagValueType::Flag, required: false, description: "Include dirty" } ],
683        default_output: None,
684        examples: &[ Example { name: "git copy", fence_meta: Some("expect_error:\"COPY source missing\""), code: indoc! {r#"COPY_GIT HEAD src.txt dst.txt"#} } ],
685        lower: |flags, args| {
686            let include_dirty = flags.iter().any(|(k, _)| k == "dirty");
687            let mut it = args.into_iter();
688            let rev = it.next().ok_or_else(|| anyhow!("COPY_GIT requires a revision"))?;
689            let from = it.next().ok_or_else(|| anyhow!("COPY_GIT requires a source"))?;
690            let to = it.next().ok_or_else(|| anyhow!("COPY_GIT requires a destination"))?;
691            Ok(StepKind::CopyGit { rev, from, to, include_dirty })
692        },
693    ],
694
695    Symlink => [
696        name: "SYMLINK",
697        variant: Symlink { from: Arg, to: Arg },
698        syntax: "SYMLINK <from> <to>",
699        summary: "Create symlink.",
700        description: "Creates symlink.",
701        args: &[
702            ArgSpec { name: "from", arg_type: ArgType::Path, description: "Target", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
703            ArgSpec { name: "to", arg_type: ArgType::Path, description: "Link", io: IoDirection::Write, index: 1, required: true, fallback_stream: None },
704        ],
705        flags: &[],
706        default_output: None,
707        examples: &[ Example { name: "symlink", fence_meta: Some("roots:unified"), code: indoc! {r#"
708            WRITE original.txt content
709            SYMLINK original.txt link.txt
710            ASSERT_FILE link.txt content
711        "#} } ],
712        lower: |_flags, args| {
713            let mut it = args.into_iter();
714            let from = it.next().ok_or_else(|| anyhow!("SYMLINK requires a source"))?;
715            let to = it.next().ok_or_else(|| anyhow!("SYMLINK requires a target"))?;
716            Ok(StepKind::Symlink { from, to })
717        },
718    ],
719
720    Mkdir => [
721        name: "MKDIR",
722        variant: Mkdir(Arg),
723        syntax: "MKDIR <path>",
724        summary: "Create directory.",
725        description: "Creates dir with parents.",
726        args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Dir path", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
727        flags: &[],
728        default_output: None,
729        examples: &[ Example { name: "mkdir", fence_meta: None, code: indoc! {r#"MKDIR deeply/nested/tree"#} } ],
730        lower: |_flags, args| Ok(StepKind::Mkdir(args.into_iter().next().ok_or_else(|| anyhow!("MKDIR requires a path"))?)),
731    ],
732
733    Ls => [
734        name: "LS",
735        variant: Ls(Option<Arg>),
736        syntax: "LS [<path>]",
737        summary: "List directory.",
738        description: "Lists entries.",
739        args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Dir", io: IoDirection::Read, index: 0, required: false, fallback_stream: None } ],
740        flags: &[],
741        default_output: Some(Stream::Stdout),
742        examples: &[ Example { name: "ls", fence_meta: None, code: indoc! {r#"
743            MKDIR inventory
744            WRITE inventory/a.txt a
745            LS inventory
746        "#} } ],
747        lower: |_flags, args| Ok(StepKind::Ls(args.into_iter().next())),
748    ],
749
750    Cwd => [
751        name: "CWD",
752        variant: Cwd,
753        syntax: "CWD",
754        summary: "Print working directory.",
755        description: "Outputs cwd.",
756        args: &[],
757        flags: &[],
758        default_output: Some(Stream::Stdout),
759        examples: &[ Example { name: "cwd", fence_meta: None, code: indoc! {r#"CWD"#} } ],
760        lower: |_flags, _args| Ok(StepKind::Cwd),
761    ],
762
763    Read => [
764        name: "READ",
765        variant: Read(Option<Arg>),
766        syntax: "READ [<path>]",
767        summary: "Read file to stdout.",
768        description: "Outputs file contents.",
769        args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Read, index: 0, required: false, fallback_stream: None } ],
770        flags: &[],
771        default_output: Some(Stream::Stdout),
772        examples: &[ Example { name: "read", fence_meta: None, code: indoc! {r#"
773            WRITE note.txt "hello"
774            READ note.txt
775        "#} } ],
776        lower: |_flags, args| Ok(StepKind::Read(args.into_iter().next())),
777    ],
778
779    ReadLine => [
780        name: "READ_LINE",
781        variant: ReadLine { var: String },
782        syntax: "READ_LINE $var",
783        summary: "Read one line from stdin into a variable.",
784        description: indoc! {r#"
785            Reads bytes until newline without waiting for EOF, leaving the pipe open.
786
787            Trailing newline is stripped (shell-read parity). On premature EOF
788            assigns accumulated bytes and returns.
789        "#},
790        args: &[ ArgSpec { name: "var", arg_type: ArgType::Var, description: "Target variable (`$name`); the line binds as STRING", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
791        flags: &[],
792        default_output: None,
793        examples: &[ Example { name: "read line", fence_meta: None, code: indoc! {r#"
794            WITH_IO [stdout=pipe:lines] ECHO "first"
795            WITH_IO [stdin=pipe:lines] READ_LINE $reply
796        "#} } ],
797        lower: |_flags, args| {
798            let arg = args.into_iter().next().ok_or_else(|| anyhow!("READ_LINE requires a variable"))?;
799            let var = match arg {
800                Arg::Expr(Expr::Var(name)) => name,
801                Arg::String(s, _) => s.trim_start_matches('$').to_string(),
802                other => bail!("READ_LINE requires a $variable, found {:?}", other),
803            };
804            if var.is_empty() {
805                bail!("READ_LINE requires a variable");
806            }
807            Ok(StepKind::ReadLine { var })
808        },
809    ],
810
811    Write => [
812        name: "WRITE",
813        variant: Write { path: Arg, contents: Option<Arg> },
814        syntax: "WRITE <path> [<contents>]",
815        summary: "Write to file.",
816        description: "Writes contents.",
817        args: &[
818            ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Write, index: 0, required: true, fallback_stream: None },
819            ArgSpec { name: "contents", arg_type: ArgType::Rest(&ArgType::String), description: "Content", io: IoDirection::Write, index: 1, required: false, fallback_stream: Some(Stream::Stdin) },
820        ],
821        flags: &[],
822        default_output: None,
823        examples: &[ Example { name: "write", fence_meta: None, code: indoc! {r#"WRITE output.txt hello-world"#} } ],
824        lower: |_flags, args| {
825            let mut it = args.into_iter();
826            let path = it.next().ok_or_else(|| anyhow!("WRITE requires a path"))?;
827            let remaining: Vec<Arg> = it.collect();
828            let contents = if remaining.is_empty() { None } else { Some(join_value(remaining, "WRITE")?) };
829            Ok(StepKind::Write { path, contents })
830        },
831    ],
832
833    Append => [
834        name: "APPEND",
835        variant: Append { path: Arg, contents: Option<Arg> },
836        syntax: "APPEND <path> [<contents>]",
837        summary: "Append to file.",
838        description: "Appends contents.",
839        args: &[
840            ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Write, index: 0, required: true, fallback_stream: None },
841            ArgSpec { name: "contents", arg_type: ArgType::Rest(&ArgType::String), description: "Content", io: IoDirection::Write, index: 1, required: false, fallback_stream: Some(Stream::Stdin) },
842        ],
843        flags: &[],
844        default_output: None,
845        examples: &[ Example { name: "append", fence_meta: None, code: indoc! {r#"
846            WRITE log.txt line1
847            APPEND log.txt line2
848            ASSERT_FILE log.txt line1line2
849        "#} } ],
850        lower: |_flags, args| {
851            let mut it = args.into_iter();
852            let path = it.next().ok_or_else(|| anyhow!("APPEND requires a path"))?;
853            let remaining: Vec<Arg> = it.collect();
854            let contents = if remaining.is_empty() { None } else { Some(join_value(remaining, "APPEND")?) };
855            Ok(StepKind::Append { path, contents })
856        },
857    ],
858
859    Expand => [
860        name: "EXPAND",
861        variant: Expand { path: Option<Arg>, overrides: Vec<(String, Arg)> },
862        syntax: "EXPAND [<path>] [<KEY=val> ...]",
863        summary: "Expand a template file (or stdin) to stdout.",
864        description: indoc! {r#"
865            A template is any text file — or piped stdin when no path is given —
866            containing `{{ ... }}` placeholders. EXPAND replaces each placeholder
867            and prints the result to stdout.
868
869            Placeholders: `{{ NAME }}` reads a `KEY=val` override passed on this
870            command; `{{ env:NAME }}` reads an override, falling back to the
871            environment; `{{ $var }}` reads a script variable (dotted paths allowed).
872            A missing key is an error, never a silent empty.
873
874            Substitution runs in a single pass. EXPAND is not recursive and does not
875            expand nested placeholders: a value that itself contains `{{ ... }}` is
876            inserted verbatim and never expanded again.
877
878            A bare `$var` argument is a template path; `KEY=val` arguments are
879            overrides whose values follow the unified string-value rules (same as
880            `ENV`: quotes keep exact bytes, a lone `$var` evaluates,
881            `{{ ... }}` interpolates).
882
883            NOTE: `WRITE` interpolates `{{ ... }}` while writing, so escape it
884            (`\{{ ... }}`) when writing a template file for a later `EXPAND`.
885
886            With no path, the template arrives on stdin through a pipe. When piping
887            from a shell, single-quote the template (`echo '{{ $x }}'`): double
888            quotes let the shell swallow `$x`, so oxdock receives an empty `{{ }}`
889            placeholder and errors.
890        "#},
891        args: &[
892            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 },
893            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 },
894        ],
895        flags: &[],
896        default_output: Some(Stream::Stdout),
897        examples: &[
898            Example { name: "expand", fence_meta: None, code: indoc! {r#"
899                ENV NAME="Alice"
900                WRITE template.md "Hello {{ env:NAME }}!"
901                EXPAND template.md
902                ASSERT_STDOUT "Hello Alice!"
903            "#} },
904            Example { name: "override with spaces", fence_meta: None, code: indoc! {r#"
905                # WRITE would interpolate {{ }} right away, so escape it:
906                # the file must literally contain {{ env:NAME }} for EXPAND
907                WRITE template.md "Hello \{{ env:NAME }}!"
908                EXPAND template.md NAME="Alice Smith"
909                ASSERT_STDOUT "Hello Alice Smith!"
910            "#} },
911            Example { name: "variable override", fence_meta: None, code: indoc! {r#"
912                # same escaping: keep the placeholder literal until EXPAND;
913                # a lone $who evaluates, like ECHO $who
914                LET $who: STRING = "Bob"
915                WRITE template.md "Hi \{{ env:WHO }}!"
916                EXPAND template.md WHO=$who
917                ASSERT_STDOUT "Hi Bob!"
918            "#} },
919            Example { name: "override forms agree", fence_meta: None, code: indoc! {r#"
920                # a bare variable and a template-with-tail expand identically
921                LET $x: STRING = "Ada"
922                WRITE template.md "Hi \{{ env:NAME }} and \{{ env:NAME2 }}!"
923                EXPAND template.md NAME=$x NAME2="{{ $x }} concatenated"
924                ASSERT_STDOUT "Hi Ada and Ada concatenated!"
925            "#} },
926            Example { name: "expand stdin", fence_meta: None, code: indoc! {r#"
927                # no path: the template arrives on stdin through a pipe
928                WITH_IO [stdout=pipe:tpl] ECHO "Hello \{{ env:NAME }}!"
929                WITH_IO [stdin=pipe:tpl] EXPAND NAME=Alice
930                ASSERT_STDOUT "Hello Alice!"
931            "#} },
932            Example { name: "override does not leak", fence_meta: None, code: indoc! {r#"
933                # KEY=val overrides shadow env for that EXPAND only —
934                # they never update the environment itself
935                ENV NAME="Alice"
936                WRITE template.md "Hi \{{ env:NAME }}!"
937                EXPAND template.md NAME="Bob"
938                ASSERT_STDOUT "Hi Bob!"
939                EXPAND template.md
940                ASSERT_STDOUT "Hi Alice!"
941            "#} },
942        ],
943        lower: |_flags, args| {
944            let mut path = None;
945            let mut overrides = Vec::new();
946            for arg in args {
947                let text = arg.as_str();
948                if let Some((key, value)) = split_assignment(text)? {
949                    overrides.push((key, value));
950                } else if path.is_none() { path = Some(arg); }
951                else { bail!("EXPAND accepts at most one path"); }
952            }
953            Ok(StepKind::Expand { path, overrides })
954        },
955    ],
956
957    AssertFile => [
958        name: "ASSERT_FILE",
959        variant: AssertFile { hash: Option<String>, path: Arg, contents: Option<Arg> },
960        syntax: "ASSERT_FILE [--hash <sha256>] <path> [<expected>]",
961        summary: "Assert file exists.",
962        description: indoc! {r#"
963            Checks the path is a file, then optionally compares its bytes (or
964            `--hash` SHA-256 digest) against the expectation.
965
966            Any mismatch aborts the pipeline with a step-numbered error showing
967            expected vs actual.
968        "#},
969        args: &[
970            ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
971            ArgSpec { name: "expected", arg_type: ArgType::Rest(&ArgType::String), description: "Expected", io: IoDirection::Read, index: 1, required: false, fallback_stream: None },
972        ],
973        flags: &[ FlagSpec { name: "hash", long: "--hash", value_type: FlagValueType::String, required: false, description: "SHA-256" } ],
974        default_output: None,
975        examples: &[ Example { name: "assert file", fence_meta: None, code: indoc! {r#"
976            WRITE payload.bin stable-content
977            ASSERT_FILE payload.bin stable-content
978        "#} },
979        Example { name: "assert file hash", fence_meta: None, code: indoc! {r#"
980            # --hash compares the SHA-256 digest instead of raw bytes
981            WRITE payload.bin stable-content
982            ASSERT_FILE --hash 08135c1b6349b0e4f894c36221952f0de00e6b4d82f80895abf359755e77103c payload.bin
983        "#} } ],
984        lower: |flags, args| {
985            let hash = flags.iter().find(|(k, _)| k == "hash").map(|(_, v)| v.as_str().to_string());
986            let mut it = args.into_iter();
987            let path = it.next().ok_or_else(|| anyhow!("ASSERT_FILE requires a path"))?;
988            let remaining: Vec<Arg> = it.collect();
989            let contents = if remaining.is_empty() { None } else { Some(join_value(remaining, "ASSERT_FILE")?) };
990            Ok(StepKind::AssertFile { hash, path, contents })
991        },
992    ],
993
994    AssertDir => [
995        name: "ASSERT_DIR",
996        variant: AssertDir(Arg),
997        syntax: "ASSERT_DIR <path>",
998        summary: "Assert dir exists.",
999        description: indoc! {r#"
1000            Checks the path is a directory, aborting the pipeline with a
1001            step-numbered error otherwise.
1002        "#},
1003        args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Dir", io: IoDirection::Read, index: 0, required: true, fallback_stream: None } ],
1004        flags: &[],
1005        default_output: None,
1006        examples: &[ Example { name: "assert dir", fence_meta: None, code: indoc! {r#"
1007            MKDIR dist/assets
1008            ASSERT_DIR dist/assets
1009        "#} } ],
1010        lower: |_flags, args| Ok(StepKind::AssertDir(args.into_iter().next().ok_or_else(|| anyhow!("ASSERT_DIR requires a path"))?)),
1011    ],
1012
1013    AssertAbsent => [
1014        name: "ASSERT_ABSENT",
1015        variant: AssertAbsent(Arg),
1016        syntax: "ASSERT_ABSENT <path>",
1017        summary: "Assert path absent.",
1018        description: indoc! {r#"
1019            Checks nothing exists at the path, aborting the pipeline with a
1020            step-numbered error if it does.
1021        "#},
1022        args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Path", io: IoDirection::Read, index: 0, required: true, fallback_stream: None } ],
1023        flags: &[],
1024        default_output: None,
1025        examples: &[ Example { name: "assert absent", fence_meta: None, code: indoc! {r#"ASSERT_ABSENT missing.txt"#} } ],
1026        lower: |_flags, args| Ok(StepKind::AssertAbsent(args.into_iter().next().ok_or_else(|| anyhow!("ASSERT_ABSENT requires a path"))?)),
1027    ],
1028
1029    AssertStdout => [
1030        name: "ASSERT_STDOUT",
1031        variant: AssertStdout(Arg),
1032        syntax: "ASSERT_STDOUT <substring>",
1033        summary: "Assert stdout contains.",
1034        description: indoc! {r#"
1035            Checks the preceding step's stdout contains the substring, aborting the
1036            pipeline with a step-numbered error otherwise.
1037        "#},
1038        args: &[ ArgSpec { name: "substring", arg_type: ArgType::Rest(&ArgType::String), description: "Substring", io: IoDirection::Read, index: 0, required: true, fallback_stream: None } ],
1039        flags: &[],
1040        default_output: None,
1041        examples: &[ Example { name: "assert stdout", fence_meta: None, code: indoc! {r#"
1042            ECHO build-complete
1043            ASSERT_STDOUT build-complete
1044        "#} } ],
1045        lower: |_flags, args| Ok(StepKind::AssertStdout(join_value(args, "ASSERT_STDOUT")?)),
1046    ],
1047
1048    HashSha256 => [
1049        name: "HASH_SHA256",
1050        variant: HashSha256 { path: Arg },
1051        syntax: "HASH_SHA256 <path>",
1052        summary: "Print SHA-256.",
1053        description: "Computes digest.",
1054        args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Read, index: 0, required: true, fallback_stream: None } ],
1055        flags: &[],
1056        default_output: Some(Stream::Stdout),
1057        examples: &[ Example { name: "hash", fence_meta: None, code: indoc! {r#"
1058            WRITE payload.txt hello
1059            HASH_SHA256 payload.txt
1060        "#} } ],
1061        lower: |_flags, args| Ok(StepKind::HashSha256 { path: args.into_iter().next().ok_or_else(|| anyhow!("HASH_SHA256 requires a path"))? }),
1062    ],
1063
1064    Exit => [
1065        name: "EXIT",
1066        variant: Exit(Arg),
1067        syntax: "EXIT <code>",
1068        summary: "Exit pipeline.",
1069        description: indoc! {r#"
1070            Stops the pipeline immediately with an `EXIT requested with code <code>`
1071            error; steps after it never run, at any nesting depth.
1072
1073            Enclosing blocks still unwind their LET/ENV/WORKDIR/WORKSPACE state,
1074            anonymous background tasks are killed synchronously, and files written
1075            before the EXIT persist.
1076        "#},
1077        args: &[ ArgSpec { name: "code", arg_type: ArgType::Int, description: "Code", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1078        flags: &[],
1079        default_output: None,
1080        examples: &[ Example { name: "exit", fence_meta: Some("expect_error:\"EXIT requested with code 0\""), code: indoc! {r#"EXIT 0"#} } ],
1081        lower: |_flags, args| {
1082            // Static literals were already Int-checked by the central
1083            // validator; dynamics resolve (and validate) at runtime.
1084            let code = args.into_iter().next().ok_or_else(|| anyhow!("EXIT requires a code"))?;
1085            Ok(StepKind::Exit(code))
1086        },
1087    ],
1088
1089    Sleep => [
1090        name: "SLEEP",
1091        variant: Sleep { duration: Arg },
1092        syntax: "SLEEP <duration>",
1093        summary: "Pause execution for a duration.",
1094        description: indoc! {r#"
1095            Parks the step for the duration (e.g. 500ms, 10s, 2m).
1096
1097            Cooperative: checks for cancellation so an enclosing TIMEOUT or task
1098            teardown interrupts the sleep. Cross-platform alternative to shell sleep
1099            for testing time boundaries.
1100        "#},
1101        args: &[ ArgSpec { name: "duration", arg_type: ArgType::Duration, description: "How long to sleep", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1102        flags: &[],
1103        default_output: None,
1104        examples: &[
1105            Example { name: "sleep", fence_meta: None, code: indoc! {r#"SLEEP 100ms"#} },
1106            Example {
1107                name: "sleep variable duration",
1108                fence_meta: None,
1109                code: indoc! {r#"
1110                # durations resolve at runtime, so variables work too —
1111                # quoted or bare, both bind the same string
1112                LET $pause: STRING = "100ms"
1113                SLEEP $pause
1114                LET $bare: STRING = 100ms
1115                SLEEP $bare
1116            "#},
1117            },
1118        ],
1119        lower: |_flags, args| {
1120            let mut it = args.into_iter();
1121            let raw = it
1122                .next()
1123                .ok_or_else(|| anyhow!("SLEEP requires a duration (e.g. SLEEP 500ms)"))?;
1124            if it.next().is_some() {
1125                bail!("SLEEP takes exactly one duration argument");
1126            }
1127            // Static literals were Duration-checked by the central
1128            // validator; dynamics ($var, templates) resolve at runtime.
1129            Ok(StepKind::Sleep { duration: raw })
1130        },
1131    ],
1132}
1133
1134// ── Structural metadata ──────────────────────────────────────────────────
1135// Single source of truth for structural-statement documentation (TIMEOUT,
1136// ASYNC, AWAIT, WITH_IO, IF, FOR, ...). These constructs are parsed by PEG
1137// rules rather than `declare_commands!`, so their reference docs live here
1138// instead of `crates/docs-gen/src/command_ref.rs` — adding a structural
1139// StepKind without registering it here fails `structural_metadata_covers_all_structural_kinds`
1140// below, and docs-gen renders these entries dynamically (no hardcoded copy).
1141pub fn all_structural_metadata() -> Vec<CommandMeta> {
1142    vec![
1143        CommandMeta {
1144            name: "WITH_IO",
1145            syntax: "WITH_IO [<stream>[=pipe:<name>|=$var], ...] <command> | WITH_IO [bindings] { <commands> }",
1146            summary: "Reroute standard streams.",
1147            description: indoc! {r#"
1148                Reroutes the standard streams of the next command or, in block form,
1149                of every enclosed command.
1150
1151                Bindings map streams (`stdin`, `stdout`, `stderr`) to named script
1152                pipes (`stdout=pipe:name`, `stderr=pipe:name`) or to a PIPE-typed
1153                variable (`stdin=$p`, resolved against the live pipe registry when
1154                the step runs). Both stdout and stderr pipes capture output the same way.
1155
1156                Pipes hold bytes in memory and spill to a temp file above 8 MiB, so a
1157                producer can finish before the consumer starts.
1158
1159                If WITH_IO wraps an ASYNC block whose body is a single RUN, guarded or
1160                not, the pipe is a zero copy OS kernel pipe instead: pair it with a
1161                consumer that runs while the producer is alive, since output past the
1162                64 KiB kernel buffer stalls until drained. That promotion never crosses
1163                a CALL boundary: pipes created, bound, or passed by variable inside FUNC
1164                bodies are always script pipes, even when the surrounding task would
1165                otherwise promote.
1166
1167                A second producer or consumer on a live name is an explicit error. A name
1168                bound as output can later feed another command's `stdin`, connecting
1169                commands without touching the terminal. Binding `stdout` and `stderr` to
1170                the same live pipe name fails deterministically. Merge streams in shell
1171                via `2>&1` instead.
1172
1173                Nested blocks stack defaults; inline bindings override inherited ones for
1174                their command only; closing a block restores previous wiring.
1175            "#},
1176            args: &[],
1177            flags: &[],
1178            default_output: None,
1179            examples: &[
1180                Example {
1181                    name: "with_io block",
1182                    fence_meta: None,
1183                    code: indoc! {r#"
1184                WITH_IO [stdout=pipe:log] {
1185                  ECHO first
1186                  ECHO second
1187                }
1188                WITH_IO [stdin=pipe:log] WRITE captured.txt
1189            "#},
1190                },
1191                Example {
1192                    name: "variable pipe binding",
1193                    fence_meta: None,
1194                    code: indoc! {r#"
1195                # Declare the pipe first with the explicit handle operator
1196                # (like `env:KEY`): `pipe:log` names a pipe without touching
1197                # a stream. A plain string here would be a TypeMismatch.
1198                # `$p` (not `pipe:$p`) is the variable form; literals stay
1199                # `pipe:name`.
1200                LET $p: PIPE = pipe:log
1201                WITH_IO [stdout=$p] ECHO hello
1202                WITH_IO [stdin=$p] READ_LINE $line
1203                WRITE line.txt "{{ $line }}"
1204                ASSERT_FILE line.txt "hello"
1205            "#},
1206                },
1207            ],
1208        },
1209        CommandMeta {
1210            name: "FOR",
1211            syntax: "FOR $item: TYPE IN <expr> { <commands> } | FOR $key: STRING, $value: TYPE IN <expr> { <commands> }",
1212            summary: "Iterate over a list or map.",
1213            description: indoc! {r#"
1214                The loop variable receives each element (lists) or value (maps); with
1215                two variables, the first receives the key.
1216
1217                Loop variables are declared with explicit types and scoped per iteration
1218                via declare_var; they do not leak outward. The body may be a braced block
1219                or a single-line `{ ... }` command.
1220
1221                `GLOB("...")` patterns must be quoted (`*` is not a bare word, so
1222                `GLOB(*)` is a parse error); GLOB returns a root-relative sorted list,
1223                empty when nothing matches, and rejects `..` escapes.
1224            "#},
1225            args: &[],
1226            flags: &[],
1227            default_output: None,
1228            examples: &[
1229                Example {
1230                    name: "for loop",
1231                    fence_meta: None,
1232                    code: indoc! {r#"
1233                LET $items: LIST = ["a", "b"]
1234                FOR $item: STRING IN $items {
1235                  ECHO $item
1236                }
1237
1238                LET $map: MAP = {"x": 1}
1239                FOR $k: STRING, $v: INT IN $map {
1240                  ECHO "$k=$v"
1241                }
1242            "#},
1243                },
1244                Example {
1245                    name: "expand every match",
1246                    fence_meta: None,
1247                    code: indoc! {r#"
1248                # single-line body; $x is a template path, WHO an override
1249                WRITE a.txt "hi \{{ env:WHO }}!"
1250                FOR $x: STRING IN GLOB("*.txt") { EXPAND $x WHO=World }
1251                ASSERT_STDOUT "hi World!"
1252            "#},
1253                },
1254            ],
1255        },
1256        CommandMeta {
1257            name: "IF",
1258            syntax: "IF <expr> { <commands> } [ELSE IF <expr> { <commands> }] [ELSE { <commands> }]",
1259            summary: "Conditional execution.",
1260            description: indoc! {r#"
1261                The condition is evaluated as a boolean expression.
1262
1263                Prefix `!` negates (`IF !false`); only Bool values are accepted as
1264                conditions.
1265            "#},
1266            args: &[],
1267            flags: &[],
1268            default_output: None,
1269            examples: &[Example {
1270                name: "if else",
1271                fence_meta: None,
1272                code: indoc! {r#"
1273                IF true {
1274                  ECHO yes
1275                } ELSE {
1276                  ECHO no
1277                }
1278
1279                IF false {
1280                  ECHO skipped
1281                } ELSE IF true {
1282                  ECHO fallback
1283                }
1284
1285                IF !false {
1286                  ECHO inverted
1287                }
1288            "#},
1289            }],
1290        },
1291        CommandMeta {
1292            name: "LET",
1293            syntax: "LET $var: TYPE = <expr> | LET $var: TYPE = ASYNC { <commands> } | LET $var: TYPE = <command> | LET $var: TYPE = AWAIT $task",
1294            summary: "Bind script-local variables.",
1295            description: indoc! {r#"
1296                Declares a script-local variable with an explicit type (STRING, INT,
1297                FLOAT, BOOL, PIPE, LIST, MAP, HANDLE, DURATION, PATH). Duplicate LET
1298                in the same scope frame is a redeclaration error; mutate with
1299                `$var = <expr>`.
1300
1301                Variables are usable in templates (`{{ $var }}`), guards, and
1302                expressions. With `ASYNC`, spawns a background task and stores its
1303                handle (see ASYNC). The `$` sigil on the name is mandatory.
1304
1305                The right-hand side is always an expression — literals, lists, maps,
1306                comparisons, `env:KEY` reads, `pipe:NAME` handles, `INSPECT($var)`
1307                snapshots, `GLOB("*.md")` — never a `{{ ... }}` template;
1308                interpolation happens in string values, not here.
1309
1310                Bare words need no quotes: `LET $d: STRING = 30s` binds the same string
1311                as quoted.
1312
1313                When the right-hand side is a synchronous command
1314                (`LET $out: STRING = ECHO hi`), the command runs to completion and its
1315                exact stdout bytes are captured into the variable as a string (no newline
1316                stripping; commands with no stdout capture as `""`; non-UTF8 stdout is
1317                an error). Combining capture with an explicit
1318                `WITH_IO [stdout=pipe:...]` is a parse error.
1319
1320                `LET $out: STRING = AWAIT $var` captures a background task's stdout the
1321                same way; bare `AWAIT $var` forwards it to the parent stdout instead.
1322
1323                `LET $e: STRING = env:FOO` reads the script environment into a plain
1324                string.
1325            "#},
1326            args: &[],
1327            flags: &[],
1328            default_output: None,
1329            examples: &[
1330                Example {
1331                    name: "let",
1332                    fence_meta: None,
1333                    code: indoc! {r#"
1334                LET $name: STRING = "world"
1335                ECHO "hello, {{ $name }}"
1336
1337                LET $items: LIST = ["a", "b"]
1338                LET $count: INT = 42
1339            "#},
1340                },
1341                Example {
1342                    name: "glob binding",
1343                    fence_meta: None,
1344                    code: indoc! {r#"
1345                # the RHS is an expression: GLOB(...) runs and binds a list
1346                WRITE a.txt "x"
1347                LET $files: LIST = GLOB("*.txt")
1348                FOR $f: STRING IN $files { ECHO $f }
1349                ASSERT_STDOUT "a.txt"
1350            "#},
1351                },
1352                Example {
1353                    name: "scoped variable reverts",
1354                    fence_meta: None,
1355                    code: indoc! {r#"
1356                # LET inside a braced block reverts when the block exits
1357                LET $a: STRING = "outer"
1358                [bool:true] {
1359                    LET $a: STRING = "inner"
1360                    WRITE inner.txt "{{ $a }}"
1361                }
1362                WRITE outer.txt "{{ $a }}"
1363                ASSERT_FILE inner.txt "inner"
1364                ASSERT_FILE outer.txt "outer"
1365            "#},
1366                },
1367                Example {
1368                    name: "capture command output",
1369                    fence_meta: None,
1370                    code: indoc! {r#"
1371                LET $out: STRING = ECHO hi
1372                WRITE captured.txt "{{ $out }}"
1373                ASSERT_FILE captured.txt "hi\n"
1374            "#},
1375                },
1376                Example {
1377                    name: "inspect a variable",
1378                    fence_meta: None,
1379                    code: indoc! {r#"
1380                # INSPECT($var) snapshots a variable into a MAP: declared
1381                # type plus live details (pipe backend stats here), so
1382                # scripts can branch on engine state.
1383                LET $p: PIPE = pipe:log
1384                WITH_IO [stdout=$p] ECHO hello
1385                LET $info: MAP = INSPECT($p)
1386                IF $info.is_os_pipe {
1387                    WRITE unexpected.txt "should be a script pipe"
1388                }
1389                WRITE kind.txt "{{ $info.type }}"
1390                ASSERT_FILE kind.txt "PIPE"
1391            "#},
1392                },
1393            ],
1394        },
1395        CommandMeta {
1396            name: "MUTATION",
1397            syntax: "$var = <expr>",
1398            summary: "Mutate a declared variable.",
1399            description: indoc! {r#"
1400                Reassigns an existing variable, validating the new value against the
1401                TypeKind bound at LET time via coerce_value with ExecState context.
1402
1403                The leading `$` distinguishes mutation from `KEY=value` command
1404                assignments. Assigning an undeclared variable or a mismatched type is
1405                an error.
1406            "#},
1407            args: &[],
1408            flags: &[],
1409            default_output: None,
1410            examples: &[Example {
1411                name: "mutate",
1412                fence_meta: None,
1413                code: indoc! {r#"
1414                LET $count: INT = 1
1415                $count = 2
1416            "#},
1417            }],
1418        },
1419        CommandMeta {
1420            name: "ASYNC",
1421            syntax: "ASYNC <command...> | ASYNC { <commands> } | LET $var: HANDLE = ASYNC { <commands> }",
1422            summary: "Run steps in a background thread.",
1423            description: indoc! {r#"
1424                Runs a command or block of commands in a background thread with
1425                subshell isolation.
1426
1427                Mutations (ENV, WORKDIR) stay within the block. With `LET`, stores a
1428                task handle for `AWAIT`.
1429            "#},
1430            args: &[],
1431            flags: &[],
1432            default_output: None,
1433            examples: &[
1434                Example {
1435                    name: "async",
1436                    fence_meta: None,
1437                    code: indoc! {r#"
1438                    ASYNC ECHO "first"
1439
1440                    ASYNC {
1441                        ECHO "first"
1442                        ECHO "second"
1443                    }
1444                "#},
1445                },
1446                Example {
1447                    name: "async task handle",
1448                    fence_meta: None,
1449                    code: indoc! {r#"
1450                    LET $task: HANDLE = ASYNC {
1451                        ECHO "built"
1452                    }
1453                    AWAIT $task
1454                "#},
1455                },
1456            ],
1457        },
1458        CommandMeta {
1459            name: "AWAIT",
1460            syntax: "AWAIT $var | LET $out: STRING = AWAIT $var",
1461            summary: "Join a background task.",
1462            description: indoc! {r#"
1463                Blocks until the named task completes. Propagates errors if the task failed.
1464
1465                Bare `AWAIT $var` forwards the task's stdout to the parent stdout;
1466                `LET $out: STRING = AWAIT $var` captures it into `$out` instead (same
1467                UTF-8 and spilling rules as `LET $var: STRING = <command>`).
1468            "#},
1469            args: &[],
1470            flags: &[],
1471            default_output: None,
1472            examples: &[
1473                Example {
1474                    name: "await",
1475                    fence_meta: None,
1476                    code: indoc! {r#"
1477                LET $task: HANDLE = ASYNC ECHO "done"
1478                AWAIT $task
1479            "#},
1480                },
1481                Example {
1482                    name: "await capture",
1483                    fence_meta: None,
1484                    code: indoc! {r#"
1485                LET $task: HANDLE = ASYNC ECHO "done"
1486                LET $out: STRING = AWAIT $task
1487                WRITE captured.txt "{{ $out }}"
1488                ASSERT_FILE captured.txt "done\n"
1489            "#},
1490                },
1491            ],
1492        },
1493        CommandMeta {
1494            name: "CANCEL",
1495            syntax: "CANCEL $var",
1496            summary: "Synchronously cancel a background task.",
1497            description: indoc! {r#"
1498                Kills the named background task spawned via LET $var: HANDLE = ASYNC ....
1499
1500                Blocking: returns only after the task thread has been joined and its OS
1501                process reaped, so no residual filesystem or stream mutation follows. A
1502                later AWAIT $var reports cancellation. Only named tasks can be cancelled.
1503            "#},
1504            args: &[],
1505            flags: &[],
1506            default_output: None,
1507            examples: &[Example {
1508                name: "cancel",
1509                fence_meta: None,
1510                code: indoc! {r#"
1511                LET $task: HANDLE = ASYNC SLEEP 30s
1512                CANCEL $task
1513            "#},
1514            }],
1515        },
1516        CommandMeta {
1517            name: "TIMEOUT",
1518            syntax: "TIMEOUT <duration> <command...> | TIMEOUT <duration> { <commands> } | TIMEOUT <duration> AWAIT $var",
1519            summary: "Enforce an execution deadline.",
1520            description: indoc! {r#"
1521                Aborts the wrapped step or block with a deadline error if it exceeds the
1522                duration (e.g. 500ms, 10s, 2m; a bare number means seconds).
1523
1524                A blocking foreground process is killed.
1525            "#},
1526            args: &[],
1527            flags: &[],
1528            default_output: None,
1529            examples: &[
1530                Example {
1531                    name: "timeout",
1532                    fence_meta: None,
1533                    code: indoc! {r#"TIMEOUT 30s WRITE heartbeat.txt alive"#},
1534                },
1535                Example {
1536                    name: "timeout block",
1537                    fence_meta: None,
1538                    code: indoc! {r#"
1539                    TIMEOUT 30s {
1540                        WRITE a.txt one
1541                        WRITE b.txt two
1542                    }
1543                "#},
1544                },
1545                Example {
1546                    name: "timeout variable duration",
1547                    fence_meta: None,
1548                    code: indoc! {r#"
1549                    # durations resolve at runtime, so variables work too
1550                    LET $budget: DURATION = "30s"
1551                    TIMEOUT $budget WRITE heartbeat.txt alive
1552                    ASSERT_FILE heartbeat.txt alive
1553                "#},
1554                },
1555            ],
1556        },
1557        CommandMeta {
1558            name: "FUNC",
1559            syntax: "FUNC NAME($param: TYPE, ...) { <commands> }",
1560            summary: "Define a user function.",
1561            description: indoc! {r#"
1562                Defines a user function with UPPERCASE name and explicitly typed
1563                parameters.
1564
1565                Params bind by position with declare_var coercion before the body runs.
1566                Bodies run in a fresh variable scope; LETs inside do not leak. A nested
1567                FUNC definition is scoped to its block and reverts on exit. Names share
1568                one namespace with host-registered functions.
1569            "#},
1570            args: &[],
1571            flags: &[],
1572            default_output: None,
1573            examples: &[Example {
1574                name: "func def call",
1575                fence_meta: None,
1576                code: indoc! {r#"
1577                FUNC GREET($name: STRING) {
1578                  RETURN $name
1579                }
1580                LET $res: STRING = CALL GREET("ada")
1581                WRITE greeting.txt "{{ $res }}"
1582                ASSERT_FILE greeting.txt "ada"
1583            "#},
1584            }],
1585        },
1586        CommandMeta {
1587            name: "CALL",
1588            syntax: "CALL NAME(<expr>, ...) | LET $var: TYPE = CALL NAME(<expr>, ...)",
1589            summary: "Invoke a user or host function.",
1590            description: indoc! {r#"
1591                Invokes a FUNC-defined or host-registered function by UPPERCASE name.
1592
1593                Bare CALL discards the return value and keeps stdout side effects.
1594                LET $var: TYPE = CALL captures the RETURN value (fallthrough without
1595                RETURN captures as ""), coerced to the declared type; stdout inside the
1596                callee stays observable via ASSERT_STDOUT and pipes.
1597
1598                Combining LET-capture with WITH_IO [stdout=pipe:...] is a parse error.
1599            "#},
1600            args: &[],
1601            flags: &[],
1602            default_output: None,
1603            examples: &[
1604                Example {
1605                    name: "call",
1606                    fence_meta: None,
1607                    code: indoc! {r#"
1608                FUNC SHOUT($name: STRING) {
1609                  ECHO "{{ $name }}"
1610                  RETURN $name
1611                }
1612                CALL SHOUT("ada")
1613                ASSERT_STDOUT "ada"
1614            "#},
1615                },
1616                Example {
1617                    name: "call with pipes",
1618                    fence_meta: None,
1619                    code: indoc! {r#"
1620                # A pipe handle travels into a function as a typed argument
1621                # and is usable as a binding target in both directions.
1622                # `pipe:ch` constructs the handle; `$p` passes it on.
1623                FUNC DRAIN($q: PIPE) {
1624                  WITH_IO [stdin=$q] READ_LINE $line
1625                  RETURN $line
1626                }
1627                LET $p: PIPE = pipe:ch
1628                WITH_IO [stdout=$p] ECHO "payload"
1629                LET $got: STRING = CALL DRAIN($p)
1630                WRITE got.txt "{{ $got }}"
1631                ASSERT_FILE got.txt "payload"
1632            "#},
1633                },
1634            ],
1635        },
1636        CommandMeta {
1637            name: "RETURN",
1638            syntax: "RETURN <expr>",
1639            summary: "Return a value from a function.",
1640            description: indoc! {r#"
1641                Ends the nearest enclosing function call with a value.
1642
1643                Falling off the end without RETURN yields "". RETURN outside a function
1644                (including at top level or across an ASYNC boundary) is an error.
1645            "#},
1646            args: &[],
1647            flags: &[],
1648            default_output: None,
1649            examples: &[Example {
1650                name: "return",
1651                fence_meta: None,
1652                code: indoc! {r#"
1653                FUNC PICK($flag: BOOL) {
1654                  IF $flag {
1655                    RETURN "yes"
1656                  }
1657                  RETURN "no"
1658                }
1659                LET $res: STRING = CALL PICK(true)
1660                WRITE picked.txt "{{ $res }}"
1661                ASSERT_FILE picked.txt "yes"
1662            "#},
1663            }],
1664        },
1665        CommandMeta {
1666            name: "WHILE",
1667            syntax: "WHILE <bool-expr> { <commands> }",
1668            summary: "Loop while a condition holds.",
1669            description: indoc! {r#"
1670                Re-evaluates a Bool condition each iteration (same is_truthy rule as IF;
1671                non-Bool is a type error).
1672
1673                Each iteration runs in a fresh scope; mutate outer state with $var = ...
1674                so the next check observes it. BREAK exits the loop; CONTINUE skips to
1675                the next check.
1676            "#},
1677            args: &[],
1678            flags: &[],
1679            default_output: None,
1680            examples: &[Example {
1681                name: "while loop",
1682                fence_meta: None,
1683                code: indoc! {r#"
1684                LET $done: BOOL = false
1685                WHILE !$done {
1686                  WRITE tick.txt "once"
1687                  $done = true
1688                }
1689                ASSERT_FILE tick.txt "once"
1690            "#},
1691            }],
1692        },
1693        CommandMeta {
1694            name: "BREAK",
1695            syntax: "BREAK",
1696            summary: "Exit the innermost loop.",
1697            description: indoc! {r#"
1698                Exits the innermost enclosing FOR or WHILE loop.
1699
1700                BREAK outside a loop, or across a FUNC or ASYNC boundary, is an error.
1701            "#},
1702            args: &[],
1703            flags: &[],
1704            default_output: None,
1705            examples: &[Example {
1706                name: "break",
1707                fence_meta: None,
1708                code: indoc! {r#"
1709                FOR $x: STRING IN ["a", "b"] {
1710                  BREAK
1711                }
1712            "#},
1713            }],
1714        },
1715        CommandMeta {
1716            name: "CONTINUE",
1717            syntax: "CONTINUE",
1718            summary: "Skip to the next loop iteration.",
1719            description: indoc! {r#"
1720                Skips the rest of the innermost enclosing FOR or WHILE body and starts
1721                the next iteration.
1722
1723                CONTINUE outside a loop, or across a FUNC or ASYNC boundary, is an error.
1724            "#},
1725            args: &[],
1726            flags: &[],
1727            default_output: None,
1728            examples: &[Example {
1729                name: "continue",
1730                fence_meta: None,
1731                code: indoc! {r#"
1732                FOR $x: STRING IN ["a", "b"] {
1733                  CONTINUE
1734                }
1735            "#},
1736            }],
1737        },
1738    ]
1739}
1740
1741// ── Display ────────────────────────────────────────────────────────────────
1742
1743impl fmt::Display for StepKind {
1744    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1745        match self {
1746            StepKind::InheritEnv { keys } => write!(f, "INHERIT_ENV [{}]", keys.join(", ")),
1747            StepKind::Workdir(a) => write!(f, "WORKDIR {}", fmt_value(a, quote_arg)),
1748            StepKind::Workspace(t) => write!(f, "WORKSPACE {}", t),
1749            StepKind::Env { key, value } => {
1750                write!(f, "ENV {}={}", key, fmt_value(value, quote_arg))
1751            }
1752            StepKind::Run(c) => write!(f, "RUN {}", fmt_value(c, quote_run)),
1753            StepKind::RunExec { argv } => {
1754                let parts: Vec<String> = argv.iter().map(fmt_exec_arg).collect();
1755                write!(f, "RUN [{}]", parts.join(", "))
1756            }
1757            StepKind::Echo(m) => write!(f, "ECHO {}", fmt_value(m, quote_msg)),
1758            StepKind::Copy {
1759                from_current_workspace,
1760                from,
1761                to,
1762            } => {
1763                if *from_current_workspace {
1764                    write!(
1765                        f,
1766                        "COPY --from-current-workspace {} {}",
1767                        fmt_value(from, quote_arg),
1768                        fmt_value(to, quote_arg)
1769                    )
1770                } else {
1771                    write!(
1772                        f,
1773                        "COPY {} {}",
1774                        fmt_value(from, quote_arg),
1775                        fmt_value(to, quote_arg)
1776                    )
1777                }
1778            }
1779            StepKind::Symlink { from, to } => write!(
1780                f,
1781                "SYMLINK {} {}",
1782                fmt_value(from, quote_arg),
1783                fmt_value(to, quote_arg)
1784            ),
1785            StepKind::Mkdir(a) => write!(f, "MKDIR {}", fmt_value(a, quote_arg)),
1786            StepKind::Ls(a) => {
1787                write!(f, "LS")?;
1788                if let Some(x) = a {
1789                    write!(f, " {}", fmt_value(x, quote_arg))?;
1790                }
1791                Ok(())
1792            }
1793            StepKind::Cwd => write!(f, "CWD"),
1794            StepKind::Read(a) => {
1795                write!(f, "READ")?;
1796                if let Some(x) = a {
1797                    write!(f, " {}", fmt_value(x, quote_arg))?;
1798                }
1799                Ok(())
1800            }
1801            StepKind::ReadLine { var } => write!(f, "READ_LINE ${}", var),
1802            StepKind::Write { path, contents } => {
1803                write!(f, "WRITE {}", fmt_value(path, quote_arg))?;
1804                if let Some(b) = contents {
1805                    write!(f, " {}", fmt_value(b, quote_msg))?;
1806                }
1807                Ok(())
1808            }
1809            StepKind::Append { path, contents } => {
1810                write!(f, "APPEND {}", fmt_value(path, quote_arg))?;
1811                if let Some(b) = contents {
1812                    write!(f, " {}", fmt_value(b, quote_msg))?;
1813                }
1814                Ok(())
1815            }
1816            StepKind::Expand { path, overrides } => {
1817                write!(f, "EXPAND")?;
1818                if let Some(p) = path {
1819                    write!(f, " {}", fmt_value(p, quote_arg))?;
1820                }
1821                for (k, v) in overrides {
1822                    write!(f, " {}={}", k, fmt_value(v, quote_arg))?;
1823                }
1824                Ok(())
1825            }
1826            StepKind::AssertFile {
1827                hash,
1828                path,
1829                contents,
1830            } => {
1831                if let Some(d) = hash {
1832                    write!(f, "ASSERT_FILE --hash {} {}", d, fmt_value(path, quote_arg))
1833                } else {
1834                    write!(f, "ASSERT_FILE {}", fmt_value(path, quote_arg))?;
1835                    if let Some(b) = contents {
1836                        write!(f, " {}", fmt_value(b, quote_msg))?;
1837                    }
1838                    Ok(())
1839                }
1840            }
1841            StepKind::AssertDir(a) => write!(f, "ASSERT_DIR {}", fmt_value(a, quote_arg)),
1842            StepKind::AssertAbsent(a) => write!(f, "ASSERT_ABSENT {}", fmt_value(a, quote_arg)),
1843            StepKind::AssertStdout(m) => write!(f, "ASSERT_STDOUT {}", fmt_value(m, quote_msg)),
1844            StepKind::WithIo { bindings, cmd } => {
1845                let p: Vec<String> = bindings.iter().map(fmt_io).collect();
1846                write!(f, "WITH_IO [{}] {}", p.join(", "), cmd)
1847            }
1848            StepKind::WithIoBlock { bindings } => {
1849                let p: Vec<String> = bindings.iter().map(fmt_io).collect();
1850                write!(f, "WITH_IO [{}] {{...}}", p.join(", "))
1851            }
1852            StepKind::CopyGit {
1853                rev,
1854                from,
1855                to,
1856                include_dirty,
1857            } => {
1858                if *include_dirty {
1859                    write!(
1860                        f,
1861                        "COPY_GIT --include-dirty {} {} {}",
1862                        fmt_value(rev, quote_arg),
1863                        fmt_value(from, quote_arg),
1864                        fmt_value(to, quote_arg)
1865                    )
1866                } else {
1867                    write!(
1868                        f,
1869                        "COPY_GIT {} {} {}",
1870                        fmt_value(rev, quote_arg),
1871                        fmt_value(from, quote_arg),
1872                        fmt_value(to, quote_arg)
1873                    )
1874                }
1875            }
1876            StepKind::HashSha256 { path } => {
1877                write!(f, "HASH_SHA256 {}", fmt_value(path, quote_arg))
1878            }
1879            StepKind::Exit(code) => write!(f, "EXIT {}", fmt_raw_arg(code)),
1880            StepKind::Sleep { duration } => write!(f, "SLEEP {}", fmt_raw_arg(duration)),
1881            StepKind::For {
1882                key_var,
1883                key_type,
1884                var,
1885                var_type,
1886                in_expr,
1887                body,
1888            } => {
1889                match key_var {
1890                    Some(k) => {
1891                        let kt = key_type.as_ref().map(|t| t.label()).unwrap_or("STRING");
1892                        write!(
1893                            f,
1894                            "FOR ${}: {}, ${}: {} IN {} {{",
1895                            k, kt, var, var_type, in_expr
1896                        )?
1897                    }
1898                    None => write!(f, "FOR ${}: {} IN {} {{", var, var_type, in_expr)?,
1899                }
1900                for s in body {
1901                    write!(f, "\n    {}", s)?;
1902                }
1903                write!(f, "\n}}")
1904            }
1905            StepKind::If {
1906                cond,
1907                then_body,
1908                else_ifs,
1909                else_body,
1910            } => {
1911                write!(f, "IF {} {{", cond)?;
1912                for s in then_body {
1913                    write!(f, "\n    {}", s)?;
1914                }
1915                write!(f, " }}")?;
1916                for (c, b) in else_ifs {
1917                    write!(f, " ELSE IF {} {{", c)?;
1918                    for s in b {
1919                        write!(f, "\n    {}", s)?;
1920                    }
1921                    write!(f, " }}")?;
1922                }
1923                if let Some(b) = else_body {
1924                    write!(f, " ELSE {{")?;
1925                    for s in b {
1926                        write!(f, "\n    {}", s)?;
1927                    }
1928                    write!(f, " }}")?;
1929                }
1930                Ok(())
1931            }
1932            StepKind::Assign {
1933                var,
1934                decl_type,
1935                expr,
1936            } => {
1937                write!(f, "LET ${}: {} = {}", var, decl_type, expr)
1938            }
1939            StepKind::Set { var, expr } => write!(f, "${} = {}", var, expr),
1940            StepKind::AssignCapture {
1941                var,
1942                decl_type,
1943                cmd,
1944            } => {
1945                write!(f, "LET ${}: {} = {}", var, decl_type, cmd)
1946            }
1947            StepKind::AsyncBlock { body } => {
1948                write!(f, "ASYNC {{")?;
1949                for s in body {
1950                    write!(f, "\n    {}", s)?;
1951                }
1952                write!(f, "\n}}")
1953            }
1954            StepKind::AssignAsync {
1955                var,
1956                decl_type,
1957                body,
1958            } => {
1959                write!(f, "LET ${}: {} = ASYNC {{", var, decl_type)?;
1960                for s in body {
1961                    write!(f, "\n    {}", s)?;
1962                }
1963                write!(f, "\n}}")
1964            }
1965            StepKind::Await { var } => write!(f, "AWAIT ${}", var),
1966            StepKind::AwaitCapture {
1967                out_var,
1968                out_type,
1969                task_var,
1970            } => {
1971                write!(f, "LET ${}: {} = AWAIT ${}", out_var, out_type, task_var)
1972            }
1973            StepKind::Cancel { var } => write!(f, "CANCEL ${}", var),
1974            StepKind::Timeout { duration, body } => {
1975                let budget = fmt_raw_arg(duration);
1976                if body.len() == 1 {
1977                    write!(f, "TIMEOUT {} {}", budget, body[0].kind)
1978                } else {
1979                    write!(f, "TIMEOUT {} {{", budget)?;
1980                    for s in body {
1981                        write!(f, "\n    {}", s)?;
1982                    }
1983                    write!(f, "\n}}")
1984                }
1985            }
1986            StepKind::FuncDef { name, params, body } => {
1987                let ps: Vec<String> = params
1988                    .iter()
1989                    .map(|(p, t)| format!("${}: {}", p, t))
1990                    .collect();
1991                write!(f, "FUNC {}({}) {{", name, ps.join(", "))?;
1992                for s in body {
1993                    write!(f, "\n    {}", s)?;
1994                }
1995                write!(f, "\n}}")
1996            }
1997            StepKind::Call { name, args } => {
1998                let ps: Vec<String> = args.iter().map(|a| format!("{}", a)).collect();
1999                write!(f, "CALL {}({})", name, ps.join(", "))
2000            }
2001            StepKind::Return { expr } => write!(f, "RETURN {}", expr),
2002            StepKind::While { cond, body } => {
2003                write!(f, "WHILE {} {{", cond)?;
2004                for s in body {
2005                    write!(f, "\n    {}", s)?;
2006                }
2007                write!(f, "\n}}")
2008            }
2009            StepKind::Break => write!(f, "BREAK"),
2010            StepKind::Continue => write!(f, "CONTINUE"),
2011        }
2012    }
2013}
2014
2015#[cfg(test)]
2016mod tests {
2017    use super::*;
2018    use crate::command::{format_duration, parse_duration};
2019    use crate::parser::parse_script;
2020
2021    fn parse_err(script: &str) -> String {
2022        parse_script(script, lower_command)
2023            .expect_err("script must fail to parse")
2024            .to_string()
2025    }
2026
2027    #[test]
2028    fn malformed_with_io_binding_names_the_bad_binding() {
2029        let err = parse_err("WITH_IO [stdout=discard] ECHO \"test\"\n");
2030        assert!(err.contains("invalid syntax for command WITH_IO"), "{err}");
2031        assert!(!err.contains("unknown command"), "{err}");
2032        assert!(err.contains("stdout=discard"), "{err}");
2033        assert!(err.contains("pipe:<name>"), "{err}");
2034    }
2035
2036    #[test]
2037    fn await_without_task_variable_points_at_syntax() {
2038        let err = parse_err("AWAIT ECHO \"test\"\n");
2039        assert!(err.contains("invalid syntax for command AWAIT"), "{err}");
2040        assert!(!err.contains("unknown command"), "{err}");
2041        assert!(err.contains("AWAIT $t"), "{err}");
2042        assert!(err.contains("ECHO"), "{err}");
2043    }
2044
2045    #[test]
2046    fn bare_let_without_type_points_at_typed_syntax() {
2047        let err = parse_err("LET $x = 1\n");
2048        assert!(err.contains("invalid syntax for command LET"), "{err}");
2049        assert!(err.contains("LET $name: STRING = <expr>"), "{err}");
2050    }
2051
2052    #[test]
2053    fn unknown_type_tag_names_valid_inventory() {
2054        let err = parse_err("LET $x: FOO = 1\n");
2055        assert!(err.contains("unknown type `FOO`"), "{err}");
2056        assert!(err.contains("STRING"), "{err}");
2057    }
2058
2059    #[test]
2060    fn bare_for_without_types_is_rejected() {
2061        let err = parse_err("FOR $i IN [1] { ECHO hi }\n");
2062        assert!(err.contains("FOR requires explicit types"), "{err}");
2063    }
2064
2065    #[test]
2066    fn mutate_statement_parses_without_keyword() {
2067        let steps = parse_script("$y = 2\n", lower_command).expect("mutation parses");
2068        assert!(matches!(steps[0].kind, StepKind::Set { .. }));
2069    }
2070
2071    #[test]
2072    fn set_keyword_is_rejected_with_mutation_hint() {
2073        let err = parse_err("SET $y = 2\n");
2074        assert!(err.contains("not a keyword"), "{err}");
2075        assert!(err.contains("$var = <expr>"), "{err}");
2076    }
2077
2078    #[test]
2079    fn structural_fallthrough_commits_per_keyword() {
2080        for (script, cmd) in [
2081            ("CANCEL foo\n", "CANCEL"),
2082            ("TIMEOUT foo\n", "TIMEOUT"),
2083            ("FOR foo\n", "FOR"),
2084            ("IF foo\n", "IF"),
2085            ("LET foo\n", "LET"),
2086            // NOTE: INHERIT_ENV is dual-registered as a leaf command
2087            // (`INHERIT_ENV <key>...`), so `INHERIT_ENV foo` lowers
2088            // successfully instead of erroring — excluded here.
2089            ("ASYNC\n", "ASYNC"),
2090            ("ELSE foo\n", "ELSE"),
2091        ] {
2092            let err = parse_err(script);
2093            assert!(
2094                err.contains(&format!("invalid syntax for command {cmd}")),
2095                "{cmd}: {err}"
2096            );
2097            assert!(!err.contains("unknown command"), "{cmd}: {err}");
2098        }
2099    }
2100
2101    #[test]
2102    fn leaf_arity_errors_carry_invalid_syntax_prefix() {
2103        let err = parse_err("SLEEP 1s 2s\n");
2104        assert!(err.contains("invalid syntax for command SLEEP"), "{err}");
2105        assert!(!err.contains("unknown command"), "{err}");
2106    }
2107
2108    #[test]
2109    fn genuinely_unknown_command_keeps_bare_message() {
2110        let err = parse_err("FROBNICATE hi\n");
2111        assert!(err.contains("unknown command: FROBNICATE"), "{err}");
2112        assert!(!err.contains("did you mean"), "{err}");
2113    }
2114
2115    #[test]
2116    fn lowercase_command_suggests_uppercase() {
2117        // Lowercase never reaches lowering through `parse_script` (the
2118        // grammar rejects it with its own uppercase hint), so exercise the
2119        // public `lower_command` dispatcher directly.
2120        let err = lower_command("echo", vec![Arg::String("hi".to_string(), false)])
2121            .expect_err("must fail")
2122            .to_string();
2123        assert!(err.contains("unknown command: echo"), "{err}");
2124        assert!(err.contains("did you mean `ECHO`"), "{err}");
2125    }
2126
2127    #[test]
2128    fn func_def_requires_typed_uppercase_name() {
2129        let steps = parse_script(
2130            "FUNC GREET($name: STRING) {\n  RETURN $name\n}\n",
2131            lower_command,
2132        )
2133        .expect("func def parses");
2134        let StepKind::FuncDef { name, params, body } = &steps[0].kind else {
2135            panic!("expected FuncDef, got {:?}", steps[0].kind);
2136        };
2137        assert_eq!(name, "GREET");
2138        assert_eq!(
2139            params,
2140            &vec![("name".to_string(), TypeKind::String)],
2141            "{params:?}"
2142        );
2143        assert!(matches!(body[0].kind, StepKind::Return { .. }));
2144    }
2145
2146    #[test]
2147    fn lowercase_func_name_is_rejected() {
2148        let err = parse_err("FUNC greet($x: STRING) {\n  RETURN $x\n}\n");
2149        assert!(err.contains("FUNC"), "{err}");
2150    }
2151
2152    #[test]
2153    fn call_and_while_lower_correctly() {
2154        let steps = parse_script("CALL GREET(\"ada\")\n", lower_command).expect("call parses");
2155        assert!(
2156            matches!(&steps[0].kind, StepKind::Call { name, .. } if name == "GREET"),
2157            "{:?}",
2158            steps[0].kind
2159        );
2160        let steps =
2161            parse_script("WHILE !$done {\n  BREAK\n}\n", lower_command).expect("while parses");
2162        let StepKind::While { body, .. } = &steps[0].kind else {
2163            panic!("expected While, got {:?}", steps[0].kind);
2164        };
2165        assert!(matches!(body[0].kind, StepKind::Break));
2166    }
2167
2168    #[test]
2169    fn let_capture_call_and_async_call_lower() {
2170        let steps = parse_script("LET $r: STRING = CALL GREET(\"ada\")\n", lower_command)
2171            .expect("capture call parses");
2172        let StepKind::AssignCapture { var, cmd, .. } = &steps[0].kind else {
2173            panic!("expected AssignCapture, got {:?}", steps[0].kind);
2174        };
2175        assert_eq!(var, "r");
2176        assert!(matches!(&**cmd, StepKind::Call { .. }), "{cmd:?}");
2177        let steps = parse_script("LET $t: HANDLE = ASYNC CALL GREET(\"a\")\n", lower_command)
2178            .expect("async call parses");
2179        assert!(
2180            matches!(&steps[0].kind, StepKind::AssignAsync { .. }),
2181            "{:?}",
2182            steps[0].kind
2183        );
2184    }
2185
2186    #[test]
2187    fn parse_duration_units() {
2188        use std::time::Duration;
2189        assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
2190        assert_eq!(parse_duration("10s").unwrap(), Duration::from_secs(10));
2191        assert_eq!(parse_duration("2m").unwrap(), Duration::from_secs(120));
2192        assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
2193        assert_eq!(parse_duration("30").unwrap(), Duration::from_secs(30));
2194    }
2195
2196    #[test]
2197    fn parse_duration_rejects_garbage() {
2198        assert!(parse_duration("").is_err());
2199        assert!(parse_duration("banana").is_err());
2200        assert!(parse_duration("10x").is_err());
2201        assert!(parse_duration("0s").is_err());
2202        assert!(parse_duration("0").is_err());
2203        assert!(parse_duration("-5s").is_err());
2204    }
2205
2206    #[test]
2207    fn format_duration_round_trips() {
2208        for text in ["500ms", "10s", "2m", "1h", "90s", "1500ms"] {
2209            let parsed = parse_duration(text).unwrap();
2210            let rendered = format_duration(&parsed);
2211            assert_eq!(
2212                parse_duration(&rendered).unwrap(),
2213                parsed,
2214                "round-trip failed for {text}"
2215            );
2216        }
2217        assert_eq!(format_duration(&parse_duration("90s").unwrap()), "90s");
2218        assert_eq!(format_duration(&parse_duration("2m").unwrap()), "2m");
2219    }
2220
2221    #[test]
2222    fn structural_metadata_covers_all_structural_kinds() {
2223        use crate::ast::Value;
2224
2225        // Tripwire: adding a structural StepKind variant without registering
2226        // documentation fails to compile here (non-exhaustive match). Leaf
2227        // commands map to None; they are covered by declare_commands!.
2228        fn metadata_name(kind: &StepKind) -> Option<&'static str> {
2229            match kind {
2230                StepKind::WithIo { .. } | StepKind::WithIoBlock { .. } => Some("WITH_IO"),
2231                StepKind::For { .. } => Some("FOR"),
2232                StepKind::If { .. } => Some("IF"),
2233                StepKind::Assign { .. } => Some("LET"),
2234                StepKind::Set { .. } => Some("MUTATION"),
2235                StepKind::AssignCapture { .. } => Some("LET"),
2236                StepKind::AwaitCapture { .. } => Some("AWAIT"),
2237                StepKind::AsyncBlock { .. } | StepKind::AssignAsync { .. } => Some("ASYNC"),
2238                StepKind::Await { .. } => Some("AWAIT"),
2239                StepKind::Cancel { .. } => Some("CANCEL"),
2240                StepKind::Timeout { .. } => Some("TIMEOUT"),
2241                StepKind::FuncDef { .. } => Some("FUNC"),
2242                StepKind::Call { .. } => Some("CALL"),
2243                StepKind::Return { .. } => Some("RETURN"),
2244                StepKind::While { .. } => Some("WHILE"),
2245                StepKind::Break => Some("BREAK"),
2246                StepKind::Continue => Some("CONTINUE"),
2247                StepKind::RunExec { .. } => None,
2248                StepKind::Workdir(_)
2249                | StepKind::Workspace(_)
2250                | StepKind::Env { .. }
2251                | StepKind::InheritEnv { .. }
2252                | StepKind::Run(_)
2253                | StepKind::Echo(_)
2254                | StepKind::Copy { .. }
2255                | StepKind::Symlink { .. }
2256                | StepKind::Mkdir(_)
2257                | StepKind::Ls(_)
2258                | StepKind::Cwd
2259                | StepKind::Read(_)
2260                | StepKind::ReadLine { .. }
2261                | StepKind::Write { .. }
2262                | StepKind::Append { .. }
2263                | StepKind::Expand { .. }
2264                | StepKind::AssertFile { .. }
2265                | StepKind::AssertDir(_)
2266                | StepKind::AssertAbsent(_)
2267                | StepKind::AssertStdout(_)
2268                | StepKind::CopyGit { .. }
2269                | StepKind::HashSha256 { .. }
2270                | StepKind::Exit(_)
2271                | StepKind::Sleep { .. } => None,
2272            }
2273        }
2274
2275        // Exercise the matcher once per structural variant so the arms cannot
2276        // rot (a new variant breaks compilation above first).
2277        let dummies: Vec<StepKind> = vec![
2278            StepKind::WithIo {
2279                bindings: Vec::new(),
2280                cmd: Box::new(StepKind::Echo(crate::ast::Arg::String(
2281                    "x".to_string(),
2282                    false,
2283                ))),
2284            },
2285            StepKind::For {
2286                key_var: None,
2287                key_type: None,
2288                var: "i".to_string(),
2289                var_type: TypeKind::String,
2290                in_expr: Expr::Literal(Value::Bool(true)),
2291                body: Vec::new(),
2292            },
2293            StepKind::If {
2294                cond: Box::new(Expr::Literal(Value::Bool(true))),
2295                then_body: Vec::new(),
2296                else_ifs: Vec::new(),
2297                else_body: None,
2298            },
2299            StepKind::Assign {
2300                var: "v".to_string(),
2301                decl_type: TypeKind::Bool,
2302                expr: Expr::Literal(Value::Bool(true)),
2303            },
2304            StepKind::Set {
2305                var: "v".to_string(),
2306                expr: Expr::Literal(Value::Bool(true)),
2307            },
2308            StepKind::AssignCapture {
2309                var: "v".to_string(),
2310                decl_type: TypeKind::String,
2311                cmd: Box::new(StepKind::Echo(crate::ast::Arg::String(
2312                    "x".to_string(),
2313                    false,
2314                ))),
2315            },
2316            StepKind::AwaitCapture {
2317                out_var: "o".to_string(),
2318                out_type: TypeKind::String,
2319                task_var: "t".to_string(),
2320            },
2321            StepKind::AsyncBlock { body: Vec::new() },
2322            StepKind::AssignAsync {
2323                var: "t".to_string(),
2324                decl_type: TypeKind::Handle,
2325                body: Vec::new(),
2326            },
2327            StepKind::Await {
2328                var: "t".to_string(),
2329            },
2330            StepKind::Cancel {
2331                var: "t".to_string(),
2332            },
2333            StepKind::Timeout {
2334                duration: Arg::String("1s".to_string(), false),
2335                body: Vec::new(),
2336            },
2337            StepKind::FuncDef {
2338                name: "F".to_string(),
2339                params: Vec::new(),
2340                body: Vec::new(),
2341            },
2342            StepKind::Call {
2343                name: "F".to_string(),
2344                args: Vec::new(),
2345            },
2346            StepKind::Return {
2347                expr: Box::new(Expr::Literal(Value::Bool(true))),
2348            },
2349            StepKind::While {
2350                cond: Box::new(Expr::Literal(Value::Bool(true))),
2351                body: Vec::new(),
2352            },
2353            StepKind::Break,
2354            StepKind::Continue,
2355        ];
2356        let registry = all_structural_metadata();
2357        for kind in &dummies {
2358            let name = metadata_name(kind).expect("structural kind must map to metadata");
2359            assert!(
2360                registry.iter().any(|meta| meta.name == name),
2361                "no structural metadata entry for {}",
2362                name
2363            );
2364        }
2365    }
2366
2367    #[test]
2368    fn verify_display_sync_with_metadata() {
2369        fn step_contains_kind(kind: &StepKind, name: &str) -> bool {
2370            if kind.to_string().starts_with(name) {
2371                return true;
2372            }
2373            let bodies: Vec<&Vec<Step>> = match kind {
2374                StepKind::For { body, .. }
2375                | StepKind::While { body, .. }
2376                | StepKind::FuncDef { body, .. }
2377                | StepKind::Timeout { body, .. }
2378                | StepKind::AssignAsync { body, .. }
2379                | StepKind::AsyncBlock { body } => vec![body],
2380                StepKind::If {
2381                    then_body,
2382                    else_ifs,
2383                    else_body,
2384                    ..
2385                } => {
2386                    let mut out = vec![then_body];
2387                    out.extend(else_ifs.iter().map(|(_, b)| b));
2388                    out.extend(else_body.iter());
2389                    out
2390                }
2391                _ => {
2392                    if let StepKind::WithIo { cmd, .. } = kind {
2393                        return step_contains_kind(cmd, name);
2394                    }
2395                    if let StepKind::AssignCapture { cmd, .. } = kind {
2396                        return step_contains_kind(cmd, name);
2397                    }
2398                    return false;
2399                }
2400            };
2401            bodies
2402                .iter()
2403                .any(|body| body.iter().any(|s| step_contains_kind(&s.kind, name)))
2404        }
2405
2406        let registry = all_metadata();
2407        for meta in registry {
2408            if meta.examples.is_empty() {
2409                continue;
2410            }
2411
2412            let code = meta.examples[0].code;
2413            let ast = parse_script(code, lower_command)
2414                .unwrap_or_else(|e| panic!("Failed to parse example for {}: {}", meta.name, e));
2415
2416            let matching = ast.iter().find(|step| {
2417                // Mutation has no keyword: its Display (`$var = ...`) cannot
2418                // start with the metadata name, so match the variant directly.
2419                if meta.name == "MUTATION" {
2420                    return matches!(step.kind, StepKind::Set { .. });
2421                }
2422                // Control-flow leaves (RETURN/BREAK/CONTINUE) only occur
2423                // nested inside bodies, so search recursively; everything
2424                // else must appear at top level with a matching Display.
2425                if matches!(meta.name, "RETURN" | "BREAK" | "CONTINUE") {
2426                    return step_contains_kind(&step.kind, meta.name);
2427                }
2428                let kind = match &step.kind {
2429                    StepKind::WithIo { cmd, .. } => &**cmd,
2430                    other => other,
2431                };
2432                // Full Display covers wrapper kinds themselves (e.g. a
2433                // WithIo step displays as WITH_IO ...); unwrapped covers
2434                // wrapped leaf commands.
2435                kind.to_string().starts_with(meta.name)
2436                    || step.kind.to_string().starts_with(meta.name)
2437            });
2438
2439            assert!(
2440                matching.is_some(),
2441                "No step in example for {} produces Display starting with {}",
2442                meta.name,
2443                meta.name
2444            );
2445        }
2446    }
2447}