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                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`); `&&` binds tighter than
1264                `||`, and both short-circuit, so `IF true || $missing`
1265                never evaluates the right side. Only Bool values are
1266                accepted as conditions.
1267            "#},
1268            args: &[],
1269            flags: &[],
1270            default_output: None,
1271            examples: &[
1272                Example {
1273                    name: "if else",
1274                    fence_meta: None,
1275                    code: indoc! {r#"
1276                IF true {
1277                  WRITE yes.txt taken
1278                } ELSE {
1279                  WRITE yes.txt skipped
1280                }
1281
1282                IF false {
1283                  WRITE skipped.txt no
1284                } ELSE IF true {
1285                  WRITE fallback.txt taken
1286                }
1287
1288                # !false evaluates to true, so this branch runs.
1289                IF !false {
1290                  WRITE negated.txt taken
1291                }
1292                ASSERT_FILE yes.txt "taken"
1293                ASSERT_FILE fallback.txt "taken"
1294                ASSERT_FILE negated.txt "taken"
1295                ASSERT_ABSENT skipped.txt
1296            "#},
1297                },
1298                Example {
1299                    name: "logical condition composition",
1300                    fence_meta: None,
1301                    code: indoc! {r#"
1302                LET $role: STRING = "admin"
1303                LET $level: INT = 3
1304                # || is true when either side holds; && needs both.
1305                IF $role == "owner" || $level >= 5 {
1306                    WRITE unexpected.txt no
1307                } ELSE {
1308                    WRITE fallback.txt or-false
1309                }
1310                IF $role == "admin" || $level >= 5 {
1311                    WRITE chosen.txt or-true
1312                }
1313                IF $role == "admin" && $level >= 5 {
1314                    WRITE unexpected-too.txt no
1315                } ELSE {
1316                    WRITE and.txt and-false
1317                }
1318                ASSERT_FILE fallback.txt "or-false"
1319                ASSERT_FILE chosen.txt "or-true"
1320                ASSERT_FILE and.txt "and-false"
1321                ASSERT_ABSENT unexpected.txt
1322                ASSERT_ABSENT unexpected-too.txt
1323            "#},
1324                },
1325            ],
1326        },
1327        CommandMeta {
1328            name: "LET",
1329            syntax: "LET $var: TYPE = <expr> | LET $var: TYPE = ASYNC { <commands> } | LET $var: TYPE = <command> | LET $var: TYPE = AWAIT $task",
1330            summary: "Bind script-local variables.",
1331            description: indoc! {r#"
1332                Declares a script-local variable with an explicit type (STRING, INT,
1333                FLOAT, BOOL, PIPE, LIST, MAP, HANDLE, DURATION, PATH). Duplicate LET
1334                in the same scope frame is a redeclaration error; mutate with
1335                `$var = <expr>`.
1336
1337                Variables are usable in templates (`{{ $var }}`), guards, and
1338                expressions. With `ASYNC`, spawns a background task and stores its
1339                handle (see ASYNC). The `$` sigil on the name is mandatory.
1340
1341                The right-hand side is always an expression — literals, lists, maps,
1342                arithmetic (`+ - * /` with `*`/`/` binding tighter, unary `-`,
1343                parentheses), comparisons (`< <= > >=` binding tighter than
1344                `== !=`), logical `&&` (tighter) and `||` with short-circuit,
1345                `!` negation, `env:KEY` reads, `pipe:NAME` handles,
1346                `INSPECT($var)` snapshots, `GLOB("*.md")`, `INT(x)` /
1347                `FLOAT(x)` conversions — never a `{{ ... }}` template;
1348                interpolation happens in string values, not here.
1349
1350                Numbers are numeric literals: `42` binds `INT`, `3.14` binds
1351                `FLOAT`. `Int x Int` stays `INT` (checked, integer division,
1352                so `7 / 2` is `3`); any `Float` operand promotes to `FLOAT`.
1353                Division by zero, overflow, and non-finite results are errors.
1354                Both numeric sides compare numerically (`1 == 1.0` is true);
1355                otherwise `==`/`!=` compare rendered strings and ordering on
1356                non-numerics is a Type Error. Constant subtrees fold at parse
1357                time and dynamic arithmetic compiles to flat RPN with
1358                identical semantics.
1359
1360                Float equality is exact with no epsilon. Floats store decimals
1361                in binary, so a value is exact only when its reduced fraction
1362                has a power-of-2 denominator: 0.5 (1/2), 0.25 (1/4), 0.75
1363                (3/4) are exact, while 0.1 (1/10), 0.2 (1/5), 0.3 (3/10)
1364                repeat forever in binary (like 1/3 in decimal) and truncate,
1365                so `0.1 + 0.2 == 0.3` is false (the sum is
1366                `0.30000000000000004`). Rule of thumb: endings .5, .25, .75,
1367                .125, .625, .875 are exact; .1, .2, .3 and similar are
1368                approximations. Bound approximations instead of comparing
1369                them: `IF $sum > 0.299999 && $sum < 0.300001`.
1370
1371                Comparisons do not chain: `a < b < c` is a parse error, not
1372                `(a < b) < c`. Chaining would compare a `BOOL` against a
1373                number (a runtime Type Error in C-style parsing) or evaluate
1374                the middle term twice (Python-style chaining), so the grammar
1375                accepts exactly one comparison operator per level. Write the
1376                conjunction explicitly: `$a < $b && $b < $c`. The same holds
1377                for equality (`$a == $b == $c` is rejected).
1378
1379                Captured command output is a string, so convert before math:
1380                `LET $total: INT = $total + INT($size_str)` (`INT` trims ASCII
1381                whitespace; `FLOAT` accepts int strings and rejects
1382                non-finite).
1383
1384                Bare words need no quotes: `LET $d: STRING = 30s` binds the same string
1385                as quoted.
1386
1387                When the right-hand side is a synchronous command
1388                (`LET $out: STRING = ECHO hi`), the command runs to completion and its
1389                exact stdout bytes are captured into the variable as a string (no newline
1390                stripping; commands with no stdout capture as `""`; non-UTF8 stdout is
1391                an error). Combining capture with an explicit
1392                `WITH_IO [stdout=pipe:...]` is a parse error.
1393
1394                Coming from Bash, the capture line looks familiar but behaves
1395                strictly:
1396
1397                | | Bash `output=$(...)` | OxDock `LET $out: STRING = ...` |
1398                | --- | --- | --- |
1399                | Trailing newlines | Stripped (all of them) | Preserved byte-exact |
1400                | Variable type | Always an untyped string | Declared: STRING, INT, FLOAT, ... |
1401                | Math on output | Implicit: `$((var + 1))` | Explicit: `INT($out) + 1` |
1402                | Failing command | Continues with empty output unless `set -e` | Step fails immediately, binds nothing |
1403
1404                `LET $out: STRING = AWAIT $var` captures a background task's stdout the
1405                same way; bare `AWAIT $var` forwards it to the parent stdout instead.
1406
1407                `LET $e: STRING = env:FOO` reads the script environment into a plain
1408                string.
1409            "#},
1410            args: &[],
1411            flags: &[],
1412            default_output: None,
1413            examples: &[
1414                Example {
1415                    name: "let",
1416                    fence_meta: None,
1417                    code: indoc! {r#"
1418                LET $name: STRING = "world"
1419                ECHO "hello, {{ $name }}"
1420
1421                LET $items: LIST = ["a", "b"]
1422                LET $count: INT = 42
1423            "#},
1424                },
1425                Example {
1426                    name: "glob binding",
1427                    fence_meta: None,
1428                    code: indoc! {r#"
1429                # the RHS is an expression: GLOB(...) runs and binds a list
1430                WRITE a.txt "x"
1431                LET $files: LIST = GLOB("*.txt")
1432                FOR $f: STRING IN $files { ECHO $f }
1433                ASSERT_STDOUT "a.txt"
1434            "#},
1435                },
1436                Example {
1437                    name: "scoped variable reverts",
1438                    fence_meta: None,
1439                    code: indoc! {r#"
1440                # LET inside a braced block reverts when the block exits
1441                LET $a: STRING = "outer"
1442                [bool:true] {
1443                    LET $a: STRING = "inner"
1444                    WRITE inner.txt "{{ $a }}"
1445                }
1446                WRITE outer.txt "{{ $a }}"
1447                ASSERT_FILE inner.txt "inner"
1448                ASSERT_FILE outer.txt "outer"
1449            "#},
1450                },
1451                Example {
1452                    name: "capture command output",
1453                    fence_meta: None,
1454                    code: indoc! {r#"
1455                LET $out: STRING = ECHO hi
1456                WRITE captured.txt "{{ $out }}"
1457                ASSERT_FILE captured.txt "hi\n"
1458            "#},
1459                },
1460                Example {
1461                    name: "arithmetic over captured output",
1462                    fence_meta: None,
1463                    code: indoc! {r#"
1464                LET $size_str: STRING = ECHO 41
1465                LET $total: INT = INT($size_str) + 1
1466                LET $ratio: FLOAT = 1 + 2.5
1467                # Int x Int stays INT: integer division truncates.
1468                LET $half: INT = 7 / 2
1469                WRITE total.txt "{{ $total }}"
1470                WRITE ratio.txt "{{ $ratio }}"
1471                WRITE half.txt "{{ $half }}"
1472                ASSERT_FILE total.txt "42"
1473                ASSERT_FILE ratio.txt "3.5"
1474                ASSERT_FILE half.txt "3"
1475            "#},
1476                },
1477                Example {
1478                    name: "float equality is exact",
1479                    fence_meta: None,
1480                    code: indoc! {r#"
1481                # Binary fractions compare cleanly; decimal fractions may not:
1482                # 0.1 + 0.2 is 0.30000000000000004, so == is false.
1483                LET $exact: BOOL = 0.5 + 0.25 == 0.75
1484                LET $decimal: BOOL = 0.1 + 0.2 == 0.3
1485                IF $exact {
1486                    WRITE exact.txt yes
1487                }
1488                IF $decimal {
1489                    WRITE unexpected.txt no
1490                }
1491                ASSERT_FILE exact.txt "yes"
1492                ASSERT_ABSENT unexpected.txt
1493            "#},
1494                },
1495                Example {
1496                    name: "bound inexact decimals",
1497                    fence_meta: None,
1498                    code: indoc! {r#"
1499                # Never test inexact decimals for equality; bound them.
1500                LET $sum: FLOAT = 0.1 + 0.2
1501                IF $sum > 0.299999 && $sum < 0.300001 {
1502                    WRITE bounded.txt yes
1503                }
1504                ASSERT_FILE bounded.txt "yes"
1505            "#},
1506                },
1507                Example {
1508                    name: "inspect a variable",
1509                    fence_meta: None,
1510                    code: indoc! {r#"
1511                # INSPECT($var) snapshots a variable into a MAP: declared
1512                # type plus live details (pipe backend stats here), so
1513                # scripts can branch on engine state.
1514                LET $p: PIPE = pipe:log
1515                WITH_IO [stdout=$p] ECHO hello
1516                LET $info: MAP = INSPECT($p)
1517                IF $info.is_os_pipe {
1518                    WRITE unexpected.txt "should be a script pipe"
1519                }
1520                WRITE kind.txt "{{ $info.type }}"
1521                ASSERT_FILE kind.txt "PIPE"
1522            "#},
1523                },
1524            ],
1525        },
1526        CommandMeta {
1527            name: "MUTATION",
1528            syntax: "$var = <expr>",
1529            summary: "Mutate a declared variable.",
1530            description: indoc! {r#"
1531                Reassigns an existing variable, converting the new value to
1532                the type declared at LET time. The explicit annotation is
1533                what authorizes string-to-number conversion here (`$n = "42"`
1534                binds 42 for an INT); a non-numeric string is an error.
1535                Expressions never convert: `"100" + 1` is a Type Error, use
1536                `INT()` / `FLOAT()` to cross that boundary explicitly.
1537
1538                The leading `$` distinguishes mutation from `KEY=value` command
1539                assignments. Assigning an undeclared variable or a mismatched type is
1540                an error.
1541
1542                Mutation writes through to the scope where the variable was
1543                declared, so it survives block exit: `LET $x` outside a block
1544                followed by `$x = ...` inside still reads back the new value
1545                afterwards, for every type. This is the counterpart to LET
1546                shadowing, where `LET $x` *inside* the block declares a
1547                separate inner variable that reverts on exit.
1548            "#},
1549            args: &[],
1550            flags: &[],
1551            default_output: None,
1552            examples: &[
1553                Example {
1554                    name: "mutate",
1555                    fence_meta: None,
1556                    code: indoc! {r#"
1557                LET $count: INT = 1
1558                $count = 2
1559                WRITE count.txt "{{ $count }}"
1560                ASSERT_FILE count.txt "2"
1561            "#},
1562                },
1563                Example {
1564                    name: "convert before math",
1565                    fence_meta: None,
1566                    code: indoc! {r#"
1567                # Captured output is a string: `"100" + 1` is a Type Error.
1568                # Convert explicitly, then mutate with arithmetic.
1569                LET $raw: STRING = ECHO 100
1570                LET $n: INT = INT($raw)
1571                $n = $n + 1
1572                # The declared type also converts plain strings on assignment.
1573                $n = "42"
1574                # Same crossing for decimals via FLOAT().
1575                LET $frac_str: STRING = ECHO 2.5
1576                LET $f: FLOAT = FLOAT($frac_str) + 0.25
1577                WRITE n.txt "{{ $n }}"
1578                WRITE f.txt "{{ $f }}"
1579                ASSERT_FILE n.txt "42"
1580                ASSERT_FILE f.txt "2.75"
1581            "#},
1582                },
1583            ],
1584        },
1585        CommandMeta {
1586            name: "ASYNC",
1587            syntax: "ASYNC <command...> | ASYNC { <commands> } | LET $var: HANDLE = ASYNC { <commands> }",
1588            summary: "Run steps in a background thread.",
1589            description: indoc! {r#"
1590                Runs a command or block of commands in a background thread with
1591                subshell isolation.
1592
1593                Mutations (ENV, WORKDIR) stay within the block. With `LET`, stores a
1594                task handle for `AWAIT`.
1595            "#},
1596            args: &[],
1597            flags: &[],
1598            default_output: None,
1599            examples: &[
1600                Example {
1601                    name: "async",
1602                    fence_meta: None,
1603                    code: indoc! {r#"
1604                    ASYNC ECHO "first"
1605
1606                    ASYNC {
1607                        ECHO "first"
1608                        ECHO "second"
1609                    }
1610                "#},
1611                },
1612                Example {
1613                    name: "async task handle",
1614                    fence_meta: None,
1615                    code: indoc! {r#"
1616                    LET $task: HANDLE = ASYNC {
1617                        ECHO "built"
1618                    }
1619                    AWAIT $task
1620                "#},
1621                },
1622            ],
1623        },
1624        CommandMeta {
1625            name: "AWAIT",
1626            syntax: "AWAIT $var | LET $out: STRING = AWAIT $var",
1627            summary: "Join a background task.",
1628            description: indoc! {r#"
1629                Blocks until the named task completes. Propagates errors if the task failed.
1630
1631                Bare `AWAIT $var` forwards the task's stdout to the parent stdout;
1632                `LET $out: STRING = AWAIT $var` captures it into `$out` instead (same
1633                UTF-8 and spilling rules as `LET $var: STRING = <command>`).
1634            "#},
1635            args: &[],
1636            flags: &[],
1637            default_output: None,
1638            examples: &[
1639                Example {
1640                    name: "await",
1641                    fence_meta: None,
1642                    code: indoc! {r#"
1643                LET $task: HANDLE = ASYNC ECHO "done"
1644                AWAIT $task
1645            "#},
1646                },
1647                Example {
1648                    name: "await capture",
1649                    fence_meta: None,
1650                    code: indoc! {r#"
1651                LET $task: HANDLE = ASYNC ECHO "done"
1652                LET $out: STRING = AWAIT $task
1653                WRITE captured.txt "{{ $out }}"
1654                ASSERT_FILE captured.txt "done\n"
1655            "#},
1656                },
1657            ],
1658        },
1659        CommandMeta {
1660            name: "CANCEL",
1661            syntax: "CANCEL $var",
1662            summary: "Synchronously cancel a background task.",
1663            description: indoc! {r#"
1664                Kills the named background task spawned via LET $var: HANDLE = ASYNC ....
1665
1666                Blocking: returns only after the task thread has been joined and its OS
1667                process reaped, so no residual filesystem or stream mutation follows. A
1668                later AWAIT $var reports cancellation. Only named tasks can be cancelled.
1669            "#},
1670            args: &[],
1671            flags: &[],
1672            default_output: None,
1673            examples: &[Example {
1674                name: "cancel",
1675                fence_meta: None,
1676                code: indoc! {r#"
1677                LET $task: HANDLE = ASYNC SLEEP 30s
1678                CANCEL $task
1679            "#},
1680            }],
1681        },
1682        CommandMeta {
1683            name: "TIMEOUT",
1684            syntax: "TIMEOUT <duration> <command...> | TIMEOUT <duration> { <commands> } | TIMEOUT <duration> AWAIT $var",
1685            summary: "Enforce an execution deadline.",
1686            description: indoc! {r#"
1687                Aborts the wrapped step or block with a deadline error if it exceeds the
1688                duration (e.g. 500ms, 10s, 2m; a bare number means seconds).
1689
1690                A blocking foreground process is killed.
1691            "#},
1692            args: &[],
1693            flags: &[],
1694            default_output: None,
1695            examples: &[
1696                Example {
1697                    name: "timeout",
1698                    fence_meta: None,
1699                    code: indoc! {r#"TIMEOUT 30s WRITE heartbeat.txt alive"#},
1700                },
1701                Example {
1702                    name: "timeout block",
1703                    fence_meta: None,
1704                    code: indoc! {r#"
1705                    TIMEOUT 30s {
1706                        WRITE a.txt one
1707                        WRITE b.txt two
1708                    }
1709                "#},
1710                },
1711                Example {
1712                    name: "timeout variable duration",
1713                    fence_meta: None,
1714                    code: indoc! {r#"
1715                    # durations resolve at runtime, so variables work too
1716                    LET $budget: DURATION = "30s"
1717                    TIMEOUT $budget WRITE heartbeat.txt alive
1718                    ASSERT_FILE heartbeat.txt alive
1719                "#},
1720                },
1721            ],
1722        },
1723        CommandMeta {
1724            name: "FUNC",
1725            syntax: "FUNC NAME($param: TYPE, ...) { <commands> }",
1726            summary: "Define a user function.",
1727            description: indoc! {r#"
1728                Defines a user function with UPPERCASE name and explicitly typed
1729                parameters.
1730
1731                Params bind by position, converting each argument to its
1732                declared parameter type before the body runs.
1733                Bodies run in a fresh variable scope; LETs inside do not leak. A nested
1734                FUNC definition is scoped to its block and reverts on exit. Names share
1735                one namespace with host-registered functions.
1736            "#},
1737            args: &[],
1738            flags: &[],
1739            default_output: None,
1740            examples: &[Example {
1741                name: "func def call",
1742                fence_meta: None,
1743                code: indoc! {r#"
1744                FUNC GREET($name: STRING) {
1745                  RETURN $name
1746                }
1747                LET $res: STRING = CALL GREET("ada")
1748                WRITE greeting.txt "{{ $res }}"
1749                ASSERT_FILE greeting.txt "ada"
1750            "#},
1751            }],
1752        },
1753        CommandMeta {
1754            name: "CALL",
1755            syntax: "CALL NAME(<expr>, ...) | LET $var: TYPE = CALL NAME(<expr>, ...)",
1756            summary: "Invoke a user or host function.",
1757            description: indoc! {r#"
1758                Invokes a FUNC-defined or host-registered function by UPPERCASE name.
1759
1760                Bare CALL discards the return value and keeps stdout side effects.
1761                LET $var: TYPE = CALL captures the RETURN value (fallthrough without
1762                RETURN captures as ""), coerced to the declared type; stdout inside the
1763                callee stays observable via ASSERT_STDOUT and pipes.
1764
1765                Combining LET-capture with WITH_IO [stdout=pipe:...] is a parse error.
1766            "#},
1767            args: &[],
1768            flags: &[],
1769            default_output: None,
1770            examples: &[
1771                Example {
1772                    name: "call",
1773                    fence_meta: None,
1774                    code: indoc! {r#"
1775                FUNC SHOUT($name: STRING) {
1776                  ECHO "{{ $name }}"
1777                  RETURN $name
1778                }
1779                CALL SHOUT("ada")
1780                ASSERT_STDOUT "ada"
1781            "#},
1782                },
1783                Example {
1784                    name: "call with pipes",
1785                    fence_meta: None,
1786                    code: indoc! {r#"
1787                # A pipe handle travels into a function as a typed argument
1788                # and is usable as a binding target in both directions.
1789                # `pipe:ch` constructs the handle; `$p` passes it on.
1790                FUNC DRAIN($q: PIPE) {
1791                  WITH_IO [stdin=$q] READ_LINE $line
1792                  RETURN $line
1793                }
1794                LET $p: PIPE = pipe:ch
1795                WITH_IO [stdout=$p] ECHO "payload"
1796                LET $got: STRING = CALL DRAIN($p)
1797                WRITE got.txt "{{ $got }}"
1798                ASSERT_FILE got.txt "payload"
1799            "#},
1800                },
1801            ],
1802        },
1803        CommandMeta {
1804            name: "RETURN",
1805            syntax: "RETURN <expr>",
1806            summary: "Return a value from a function.",
1807            description: indoc! {r#"
1808                Ends the nearest enclosing function call with a value.
1809
1810                Falling off the end without RETURN yields "". RETURN outside a function
1811                (including at top level or across an ASYNC boundary) is an error.
1812            "#},
1813            args: &[],
1814            flags: &[],
1815            default_output: None,
1816            examples: &[Example {
1817                name: "return",
1818                fence_meta: None,
1819                code: indoc! {r#"
1820                FUNC PICK($flag: BOOL) {
1821                  IF $flag {
1822                    RETURN "yes"
1823                  }
1824                  RETURN "no"
1825                }
1826                LET $res: STRING = CALL PICK(true)
1827                WRITE picked.txt "{{ $res }}"
1828                ASSERT_FILE picked.txt "yes"
1829            "#},
1830            }],
1831        },
1832        CommandMeta {
1833            name: "WHILE",
1834            syntax: "WHILE <bool-expr> { <commands> }",
1835            summary: "Loop while a condition holds.",
1836            description: indoc! {r#"
1837                Re-evaluates a Bool condition each iteration (same is_truthy rule as IF;
1838                non-Bool is a type error).
1839
1840                Each iteration runs in a fresh scope; mutate outer state with $var = ...
1841                so the next check observes it. BREAK exits the loop; CONTINUE skips to
1842                the next check.
1843            "#},
1844            args: &[],
1845            flags: &[],
1846            default_output: None,
1847            examples: &[Example {
1848                name: "while loop",
1849                fence_meta: None,
1850                code: indoc! {r#"
1851                LET $done: BOOL = false
1852                WHILE !$done {
1853                  WRITE tick.txt "once"
1854                  $done = true
1855                }
1856                ASSERT_FILE tick.txt "once"
1857            "#},
1858            }],
1859        },
1860        CommandMeta {
1861            name: "BREAK",
1862            syntax: "BREAK",
1863            summary: "Exit the innermost loop.",
1864            description: indoc! {r#"
1865                Exits the innermost enclosing FOR or WHILE loop.
1866
1867                BREAK outside a loop, or across a FUNC or ASYNC boundary, is an error.
1868            "#},
1869            args: &[],
1870            flags: &[],
1871            default_output: None,
1872            examples: &[Example {
1873                name: "break",
1874                fence_meta: None,
1875                code: indoc! {r#"
1876                FOR $x: STRING IN ["a", "b"] {
1877                  BREAK
1878                }
1879            "#},
1880            }],
1881        },
1882        CommandMeta {
1883            name: "CONTINUE",
1884            syntax: "CONTINUE",
1885            summary: "Skip to the next loop iteration.",
1886            description: indoc! {r#"
1887                Skips the rest of the innermost enclosing FOR or WHILE body and starts
1888                the next iteration.
1889
1890                CONTINUE outside a loop, or across a FUNC or ASYNC boundary, is an error.
1891            "#},
1892            args: &[],
1893            flags: &[],
1894            default_output: None,
1895            examples: &[Example {
1896                name: "continue",
1897                fence_meta: None,
1898                code: indoc! {r#"
1899                FOR $x: STRING IN ["a", "b"] {
1900                  CONTINUE
1901                }
1902            "#},
1903            }],
1904        },
1905    ]
1906}
1907
1908// ── Display ────────────────────────────────────────────────────────────────
1909
1910impl fmt::Display for StepKind {
1911    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1912        match self {
1913            StepKind::InheritEnv { keys } => write!(f, "INHERIT_ENV [{}]", keys.join(", ")),
1914            StepKind::Workdir(a) => write!(f, "WORKDIR {}", fmt_value(a, quote_arg)),
1915            StepKind::Workspace(t) => write!(f, "WORKSPACE {}", t),
1916            StepKind::Env { key, value } => {
1917                write!(f, "ENV {}={}", key, fmt_value(value, quote_arg))
1918            }
1919            StepKind::Run(c) => write!(f, "RUN {}", fmt_value(c, quote_run)),
1920            StepKind::RunExec { argv } => {
1921                let parts: Vec<String> = argv.iter().map(fmt_exec_arg).collect();
1922                write!(f, "RUN [{}]", parts.join(", "))
1923            }
1924            StepKind::Echo(m) => write!(f, "ECHO {}", fmt_value(m, quote_msg)),
1925            StepKind::Copy {
1926                from_current_workspace,
1927                from,
1928                to,
1929            } => {
1930                if *from_current_workspace {
1931                    write!(
1932                        f,
1933                        "COPY --from-current-workspace {} {}",
1934                        fmt_value(from, quote_arg),
1935                        fmt_value(to, quote_arg)
1936                    )
1937                } else {
1938                    write!(
1939                        f,
1940                        "COPY {} {}",
1941                        fmt_value(from, quote_arg),
1942                        fmt_value(to, quote_arg)
1943                    )
1944                }
1945            }
1946            StepKind::Symlink { from, to } => write!(
1947                f,
1948                "SYMLINK {} {}",
1949                fmt_value(from, quote_arg),
1950                fmt_value(to, quote_arg)
1951            ),
1952            StepKind::Mkdir(a) => write!(f, "MKDIR {}", fmt_value(a, quote_arg)),
1953            StepKind::Ls(a) => {
1954                write!(f, "LS")?;
1955                if let Some(x) = a {
1956                    write!(f, " {}", fmt_value(x, quote_arg))?;
1957                }
1958                Ok(())
1959            }
1960            StepKind::Cwd => write!(f, "CWD"),
1961            StepKind::Read(a) => {
1962                write!(f, "READ")?;
1963                if let Some(x) = a {
1964                    write!(f, " {}", fmt_value(x, quote_arg))?;
1965                }
1966                Ok(())
1967            }
1968            StepKind::ReadLine { var } => write!(f, "READ_LINE ${}", var),
1969            StepKind::Write { path, contents } => {
1970                write!(f, "WRITE {}", fmt_value(path, quote_arg))?;
1971                if let Some(b) = contents {
1972                    write!(f, " {}", fmt_value(b, quote_msg))?;
1973                }
1974                Ok(())
1975            }
1976            StepKind::Append { path, contents } => {
1977                write!(f, "APPEND {}", fmt_value(path, quote_arg))?;
1978                if let Some(b) = contents {
1979                    write!(f, " {}", fmt_value(b, quote_msg))?;
1980                }
1981                Ok(())
1982            }
1983            StepKind::Expand { path, overrides } => {
1984                write!(f, "EXPAND")?;
1985                if let Some(p) = path {
1986                    write!(f, " {}", fmt_value(p, quote_arg))?;
1987                }
1988                for (k, v) in overrides {
1989                    write!(f, " {}={}", k, fmt_value(v, quote_arg))?;
1990                }
1991                Ok(())
1992            }
1993            StepKind::AssertFile {
1994                hash,
1995                path,
1996                contents,
1997            } => {
1998                if let Some(d) = hash {
1999                    write!(f, "ASSERT_FILE --hash {} {}", d, fmt_value(path, quote_arg))
2000                } else {
2001                    write!(f, "ASSERT_FILE {}", fmt_value(path, quote_arg))?;
2002                    if let Some(b) = contents {
2003                        write!(f, " {}", fmt_value(b, quote_msg))?;
2004                    }
2005                    Ok(())
2006                }
2007            }
2008            StepKind::AssertDir(a) => write!(f, "ASSERT_DIR {}", fmt_value(a, quote_arg)),
2009            StepKind::AssertAbsent(a) => write!(f, "ASSERT_ABSENT {}", fmt_value(a, quote_arg)),
2010            StepKind::AssertStdout(m) => write!(f, "ASSERT_STDOUT {}", fmt_value(m, quote_msg)),
2011            StepKind::WithIo { bindings, cmd } => {
2012                let p: Vec<String> = bindings.iter().map(fmt_io).collect();
2013                write!(f, "WITH_IO [{}] {}", p.join(", "), cmd)
2014            }
2015            StepKind::WithIoBlock { bindings } => {
2016                let p: Vec<String> = bindings.iter().map(fmt_io).collect();
2017                write!(f, "WITH_IO [{}] {{...}}", p.join(", "))
2018            }
2019            StepKind::CopyGit {
2020                rev,
2021                from,
2022                to,
2023                include_dirty,
2024            } => {
2025                if *include_dirty {
2026                    write!(
2027                        f,
2028                        "COPY_GIT --include-dirty {} {} {}",
2029                        fmt_value(rev, quote_arg),
2030                        fmt_value(from, quote_arg),
2031                        fmt_value(to, quote_arg)
2032                    )
2033                } else {
2034                    write!(
2035                        f,
2036                        "COPY_GIT {} {} {}",
2037                        fmt_value(rev, quote_arg),
2038                        fmt_value(from, quote_arg),
2039                        fmt_value(to, quote_arg)
2040                    )
2041                }
2042            }
2043            StepKind::HashSha256 { path } => {
2044                write!(f, "HASH_SHA256 {}", fmt_value(path, quote_arg))
2045            }
2046            StepKind::Exit(code) => write!(f, "EXIT {}", fmt_raw_arg(code)),
2047            StepKind::Sleep { duration } => write!(f, "SLEEP {}", fmt_raw_arg(duration)),
2048            StepKind::For {
2049                key_var,
2050                key_type,
2051                var,
2052                var_type,
2053                in_expr,
2054                body,
2055            } => {
2056                match key_var {
2057                    Some(k) => {
2058                        let kt = key_type.as_ref().map(|t| t.label()).unwrap_or("STRING");
2059                        write!(
2060                            f,
2061                            "FOR ${}: {}, ${}: {} IN {} {{",
2062                            k, kt, var, var_type, in_expr
2063                        )?
2064                    }
2065                    None => write!(f, "FOR ${}: {} IN {} {{", var, var_type, in_expr)?,
2066                }
2067                for s in body {
2068                    write!(f, "\n    {}", s)?;
2069                }
2070                write!(f, "\n}}")
2071            }
2072            StepKind::If {
2073                cond,
2074                then_body,
2075                else_ifs,
2076                else_body,
2077            } => {
2078                write!(f, "IF {} {{", cond)?;
2079                for s in then_body {
2080                    write!(f, "\n    {}", s)?;
2081                }
2082                write!(f, " }}")?;
2083                for (c, b) in else_ifs {
2084                    write!(f, " ELSE IF {} {{", c)?;
2085                    for s in b {
2086                        write!(f, "\n    {}", s)?;
2087                    }
2088                    write!(f, " }}")?;
2089                }
2090                if let Some(b) = else_body {
2091                    write!(f, " ELSE {{")?;
2092                    for s in b {
2093                        write!(f, "\n    {}", s)?;
2094                    }
2095                    write!(f, " }}")?;
2096                }
2097                Ok(())
2098            }
2099            StepKind::Assign {
2100                var,
2101                decl_type,
2102                expr,
2103            } => {
2104                write!(f, "LET ${}: {} = {}", var, decl_type, expr)
2105            }
2106            StepKind::Set { var, expr } => write!(f, "${} = {}", var, expr),
2107            StepKind::AssignCapture {
2108                var,
2109                decl_type,
2110                cmd,
2111            } => {
2112                write!(f, "LET ${}: {} = {}", var, decl_type, cmd)
2113            }
2114            StepKind::AsyncBlock { body } => {
2115                write!(f, "ASYNC {{")?;
2116                for s in body {
2117                    write!(f, "\n    {}", s)?;
2118                }
2119                write!(f, "\n}}")
2120            }
2121            StepKind::AssignAsync {
2122                var,
2123                decl_type,
2124                body,
2125            } => {
2126                write!(f, "LET ${}: {} = ASYNC {{", var, decl_type)?;
2127                for s in body {
2128                    write!(f, "\n    {}", s)?;
2129                }
2130                write!(f, "\n}}")
2131            }
2132            StepKind::Await { var } => write!(f, "AWAIT ${}", var),
2133            StepKind::AwaitCapture {
2134                out_var,
2135                out_type,
2136                task_var,
2137            } => {
2138                write!(f, "LET ${}: {} = AWAIT ${}", out_var, out_type, task_var)
2139            }
2140            StepKind::Cancel { var } => write!(f, "CANCEL ${}", var),
2141            StepKind::Timeout { duration, body } => {
2142                let budget = fmt_raw_arg(duration);
2143                if body.len() == 1 {
2144                    write!(f, "TIMEOUT {} {}", budget, body[0].kind)
2145                } else {
2146                    write!(f, "TIMEOUT {} {{", budget)?;
2147                    for s in body {
2148                        write!(f, "\n    {}", s)?;
2149                    }
2150                    write!(f, "\n}}")
2151                }
2152            }
2153            StepKind::FuncDef { name, params, body } => {
2154                let ps: Vec<String> = params
2155                    .iter()
2156                    .map(|(p, t)| format!("${}: {}", p, t))
2157                    .collect();
2158                write!(f, "FUNC {}({}) {{", name, ps.join(", "))?;
2159                for s in body {
2160                    write!(f, "\n    {}", s)?;
2161                }
2162                write!(f, "\n}}")
2163            }
2164            StepKind::Call { name, args } => {
2165                let ps: Vec<String> = args.iter().map(|a| format!("{}", a)).collect();
2166                write!(f, "CALL {}({})", name, ps.join(", "))
2167            }
2168            StepKind::Return { expr } => write!(f, "RETURN {}", expr),
2169            StepKind::While { cond, body } => {
2170                write!(f, "WHILE {} {{", cond)?;
2171                for s in body {
2172                    write!(f, "\n    {}", s)?;
2173                }
2174                write!(f, "\n}}")
2175            }
2176            StepKind::Break => write!(f, "BREAK"),
2177            StepKind::Continue => write!(f, "CONTINUE"),
2178        }
2179    }
2180}
2181
2182#[cfg(test)]
2183mod tests {
2184    use super::*;
2185    use crate::command::{format_duration, parse_duration};
2186    use crate::parser::parse_script;
2187
2188    fn parse_err(script: &str) -> String {
2189        parse_script(script, lower_command)
2190            .expect_err("script must fail to parse")
2191            .to_string()
2192    }
2193
2194    #[test]
2195    fn malformed_with_io_binding_names_the_bad_binding() {
2196        let err = parse_err("WITH_IO [stdout=discard] ECHO \"test\"\n");
2197        assert!(err.contains("invalid syntax for command WITH_IO"), "{err}");
2198        assert!(!err.contains("unknown command"), "{err}");
2199        assert!(err.contains("stdout=discard"), "{err}");
2200        assert!(err.contains("pipe:<name>"), "{err}");
2201    }
2202
2203    #[test]
2204    fn await_without_task_variable_points_at_syntax() {
2205        let err = parse_err("AWAIT ECHO \"test\"\n");
2206        assert!(err.contains("invalid syntax for command AWAIT"), "{err}");
2207        assert!(!err.contains("unknown command"), "{err}");
2208        assert!(err.contains("AWAIT $t"), "{err}");
2209        assert!(err.contains("ECHO"), "{err}");
2210    }
2211
2212    #[test]
2213    fn bare_let_without_type_points_at_typed_syntax() {
2214        let err = parse_err("LET $x = 1\n");
2215        assert!(err.contains("invalid syntax for command LET"), "{err}");
2216        assert!(err.contains("LET $name: STRING = <expr>"), "{err}");
2217    }
2218
2219    #[test]
2220    fn unknown_type_tag_names_valid_inventory() {
2221        let err = parse_err("LET $x: FOO = 1\n");
2222        assert!(err.contains("unknown type `FOO`"), "{err}");
2223        assert!(err.contains("STRING"), "{err}");
2224    }
2225
2226    #[test]
2227    fn bare_for_without_types_is_rejected() {
2228        let err = parse_err("FOR $i IN [1] { ECHO hi }\n");
2229        assert!(err.contains("FOR requires explicit types"), "{err}");
2230    }
2231
2232    #[test]
2233    fn mutate_statement_parses_without_keyword() {
2234        let steps = parse_script("$y = 2\n", lower_command).expect("mutation parses");
2235        assert!(matches!(steps[0].kind, StepKind::Set { .. }));
2236    }
2237
2238    #[test]
2239    fn set_keyword_is_rejected_with_mutation_hint() {
2240        let err = parse_err("SET $y = 2\n");
2241        assert!(err.contains("not a keyword"), "{err}");
2242        assert!(err.contains("$var = <expr>"), "{err}");
2243    }
2244
2245    #[test]
2246    fn structural_fallthrough_commits_per_keyword() {
2247        for (script, cmd) in [
2248            ("CANCEL foo\n", "CANCEL"),
2249            ("TIMEOUT foo\n", "TIMEOUT"),
2250            ("FOR foo\n", "FOR"),
2251            ("IF foo\n", "IF"),
2252            ("LET foo\n", "LET"),
2253            // NOTE: INHERIT_ENV is dual-registered as a leaf command
2254            // (`INHERIT_ENV <key>...`), so `INHERIT_ENV foo` lowers
2255            // successfully instead of erroring — excluded here.
2256            ("ASYNC\n", "ASYNC"),
2257            ("ELSE foo\n", "ELSE"),
2258        ] {
2259            let err = parse_err(script);
2260            assert!(
2261                err.contains(&format!("invalid syntax for command {cmd}")),
2262                "{cmd}: {err}"
2263            );
2264            assert!(!err.contains("unknown command"), "{cmd}: {err}");
2265        }
2266    }
2267
2268    #[test]
2269    fn leaf_arity_errors_carry_invalid_syntax_prefix() {
2270        let err = parse_err("SLEEP 1s 2s\n");
2271        assert!(err.contains("invalid syntax for command SLEEP"), "{err}");
2272        assert!(!err.contains("unknown command"), "{err}");
2273    }
2274
2275    #[test]
2276    fn genuinely_unknown_command_keeps_bare_message() {
2277        let err = parse_err("FROBNICATE hi\n");
2278        assert!(err.contains("unknown command: FROBNICATE"), "{err}");
2279        assert!(!err.contains("did you mean"), "{err}");
2280    }
2281
2282    #[test]
2283    fn lowercase_command_suggests_uppercase() {
2284        // Lowercase never reaches lowering through `parse_script` (the
2285        // grammar rejects it with its own uppercase hint), so exercise the
2286        // public `lower_command` dispatcher directly.
2287        let err = lower_command("echo", vec![Arg::String("hi".to_string(), false)])
2288            .expect_err("must fail")
2289            .to_string();
2290        assert!(err.contains("unknown command: echo"), "{err}");
2291        assert!(err.contains("did you mean `ECHO`"), "{err}");
2292    }
2293
2294    #[test]
2295    fn func_def_requires_typed_uppercase_name() {
2296        let steps = parse_script(
2297            "FUNC GREET($name: STRING) {\n  RETURN $name\n}\n",
2298            lower_command,
2299        )
2300        .expect("func def parses");
2301        let StepKind::FuncDef { name, params, body } = &steps[0].kind else {
2302            panic!("expected FuncDef, got {:?}", steps[0].kind);
2303        };
2304        assert_eq!(name, "GREET");
2305        assert_eq!(
2306            params,
2307            &vec![("name".to_string(), TypeKind::String)],
2308            "{params:?}"
2309        );
2310        assert!(matches!(body[0].kind, StepKind::Return { .. }));
2311    }
2312
2313    #[test]
2314    fn lowercase_func_name_is_rejected() {
2315        let err = parse_err("FUNC greet($x: STRING) {\n  RETURN $x\n}\n");
2316        assert!(err.contains("FUNC"), "{err}");
2317    }
2318
2319    #[test]
2320    fn call_and_while_lower_correctly() {
2321        let steps = parse_script("CALL GREET(\"ada\")\n", lower_command).expect("call parses");
2322        assert!(
2323            matches!(&steps[0].kind, StepKind::Call { name, .. } if name == "GREET"),
2324            "{:?}",
2325            steps[0].kind
2326        );
2327        let steps =
2328            parse_script("WHILE !$done {\n  BREAK\n}\n", lower_command).expect("while parses");
2329        let StepKind::While { body, .. } = &steps[0].kind else {
2330            panic!("expected While, got {:?}", steps[0].kind);
2331        };
2332        assert!(matches!(body[0].kind, StepKind::Break));
2333    }
2334
2335    #[test]
2336    fn let_capture_call_and_async_call_lower() {
2337        let steps = parse_script("LET $r: STRING = CALL GREET(\"ada\")\n", lower_command)
2338            .expect("capture call parses");
2339        let StepKind::AssignCapture { var, cmd, .. } = &steps[0].kind else {
2340            panic!("expected AssignCapture, got {:?}", steps[0].kind);
2341        };
2342        assert_eq!(var, "r");
2343        assert!(matches!(&**cmd, StepKind::Call { .. }), "{cmd:?}");
2344        let steps = parse_script("LET $t: HANDLE = ASYNC CALL GREET(\"a\")\n", lower_command)
2345            .expect("async call parses");
2346        assert!(
2347            matches!(&steps[0].kind, StepKind::AssignAsync { .. }),
2348            "{:?}",
2349            steps[0].kind
2350        );
2351    }
2352
2353    #[test]
2354    fn parse_duration_units() {
2355        use std::time::Duration;
2356        assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
2357        assert_eq!(parse_duration("10s").unwrap(), Duration::from_secs(10));
2358        assert_eq!(parse_duration("2m").unwrap(), Duration::from_secs(120));
2359        assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
2360        assert_eq!(parse_duration("30").unwrap(), Duration::from_secs(30));
2361    }
2362
2363    #[test]
2364    fn parse_duration_rejects_garbage() {
2365        assert!(parse_duration("").is_err());
2366        assert!(parse_duration("banana").is_err());
2367        assert!(parse_duration("10x").is_err());
2368        assert!(parse_duration("0s").is_err());
2369        assert!(parse_duration("0").is_err());
2370        assert!(parse_duration("-5s").is_err());
2371    }
2372
2373    #[test]
2374    fn format_duration_round_trips() {
2375        for text in ["500ms", "10s", "2m", "1h", "90s", "1500ms"] {
2376            let parsed = parse_duration(text).unwrap();
2377            let rendered = format_duration(&parsed);
2378            assert_eq!(
2379                parse_duration(&rendered).unwrap(),
2380                parsed,
2381                "round-trip failed for {text}"
2382            );
2383        }
2384        assert_eq!(format_duration(&parse_duration("90s").unwrap()), "90s");
2385        assert_eq!(format_duration(&parse_duration("2m").unwrap()), "2m");
2386    }
2387
2388    #[test]
2389    fn structural_metadata_covers_all_structural_kinds() {
2390        use crate::ast::Value;
2391
2392        // Tripwire: adding a structural StepKind variant without registering
2393        // documentation fails to compile here (non-exhaustive match). Leaf
2394        // commands map to None; they are covered by declare_commands!.
2395        fn metadata_name(kind: &StepKind) -> Option<&'static str> {
2396            match kind {
2397                StepKind::WithIo { .. } | StepKind::WithIoBlock { .. } => Some("WITH_IO"),
2398                StepKind::For { .. } => Some("FOR"),
2399                StepKind::If { .. } => Some("IF"),
2400                StepKind::Assign { .. } => Some("LET"),
2401                StepKind::Set { .. } => Some("MUTATION"),
2402                StepKind::AssignCapture { .. } => Some("LET"),
2403                StepKind::AwaitCapture { .. } => Some("AWAIT"),
2404                StepKind::AsyncBlock { .. } | StepKind::AssignAsync { .. } => Some("ASYNC"),
2405                StepKind::Await { .. } => Some("AWAIT"),
2406                StepKind::Cancel { .. } => Some("CANCEL"),
2407                StepKind::Timeout { .. } => Some("TIMEOUT"),
2408                StepKind::FuncDef { .. } => Some("FUNC"),
2409                StepKind::Call { .. } => Some("CALL"),
2410                StepKind::Return { .. } => Some("RETURN"),
2411                StepKind::While { .. } => Some("WHILE"),
2412                StepKind::Break => Some("BREAK"),
2413                StepKind::Continue => Some("CONTINUE"),
2414                StepKind::RunExec { .. } => None,
2415                StepKind::Workdir(_)
2416                | StepKind::Workspace(_)
2417                | StepKind::Env { .. }
2418                | StepKind::InheritEnv { .. }
2419                | StepKind::Run(_)
2420                | StepKind::Echo(_)
2421                | StepKind::Copy { .. }
2422                | StepKind::Symlink { .. }
2423                | StepKind::Mkdir(_)
2424                | StepKind::Ls(_)
2425                | StepKind::Cwd
2426                | StepKind::Read(_)
2427                | StepKind::ReadLine { .. }
2428                | StepKind::Write { .. }
2429                | StepKind::Append { .. }
2430                | StepKind::Expand { .. }
2431                | StepKind::AssertFile { .. }
2432                | StepKind::AssertDir(_)
2433                | StepKind::AssertAbsent(_)
2434                | StepKind::AssertStdout(_)
2435                | StepKind::CopyGit { .. }
2436                | StepKind::HashSha256 { .. }
2437                | StepKind::Exit(_)
2438                | StepKind::Sleep { .. } => None,
2439            }
2440        }
2441
2442        // Exercise the matcher once per structural variant so the arms cannot
2443        // rot (a new variant breaks compilation above first).
2444        let dummies: Vec<StepKind> = vec![
2445            StepKind::WithIo {
2446                bindings: Vec::new(),
2447                cmd: Box::new(StepKind::Echo(crate::ast::Arg::String(
2448                    "x".to_string(),
2449                    false,
2450                ))),
2451            },
2452            StepKind::For {
2453                key_var: None,
2454                key_type: None,
2455                var: "i".to_string(),
2456                var_type: TypeKind::String,
2457                in_expr: Expr::Literal(Value::Bool(true)),
2458                body: Vec::new(),
2459            },
2460            StepKind::If {
2461                cond: Box::new(Expr::Literal(Value::Bool(true))),
2462                then_body: Vec::new(),
2463                else_ifs: Vec::new(),
2464                else_body: None,
2465            },
2466            StepKind::Assign {
2467                var: "v".to_string(),
2468                decl_type: TypeKind::Bool,
2469                expr: Expr::Literal(Value::Bool(true)),
2470            },
2471            StepKind::Set {
2472                var: "v".to_string(),
2473                expr: Expr::Literal(Value::Bool(true)),
2474            },
2475            StepKind::AssignCapture {
2476                var: "v".to_string(),
2477                decl_type: TypeKind::String,
2478                cmd: Box::new(StepKind::Echo(crate::ast::Arg::String(
2479                    "x".to_string(),
2480                    false,
2481                ))),
2482            },
2483            StepKind::AwaitCapture {
2484                out_var: "o".to_string(),
2485                out_type: TypeKind::String,
2486                task_var: "t".to_string(),
2487            },
2488            StepKind::AsyncBlock { body: Vec::new() },
2489            StepKind::AssignAsync {
2490                var: "t".to_string(),
2491                decl_type: TypeKind::Handle,
2492                body: Vec::new(),
2493            },
2494            StepKind::Await {
2495                var: "t".to_string(),
2496            },
2497            StepKind::Cancel {
2498                var: "t".to_string(),
2499            },
2500            StepKind::Timeout {
2501                duration: Arg::String("1s".to_string(), false),
2502                body: Vec::new(),
2503            },
2504            StepKind::FuncDef {
2505                name: "F".to_string(),
2506                params: Vec::new(),
2507                body: Vec::new(),
2508            },
2509            StepKind::Call {
2510                name: "F".to_string(),
2511                args: Vec::new(),
2512            },
2513            StepKind::Return {
2514                expr: Box::new(Expr::Literal(Value::Bool(true))),
2515            },
2516            StepKind::While {
2517                cond: Box::new(Expr::Literal(Value::Bool(true))),
2518                body: Vec::new(),
2519            },
2520            StepKind::Break,
2521            StepKind::Continue,
2522        ];
2523        let registry = all_structural_metadata();
2524        for kind in &dummies {
2525            let name = metadata_name(kind).expect("structural kind must map to metadata");
2526            assert!(
2527                registry.iter().any(|meta| meta.name == name),
2528                "no structural metadata entry for {}",
2529                name
2530            );
2531        }
2532    }
2533
2534    #[test]
2535    fn verify_display_sync_with_metadata() {
2536        fn step_contains_kind(kind: &StepKind, name: &str) -> bool {
2537            if kind.to_string().starts_with(name) {
2538                return true;
2539            }
2540            let bodies: Vec<&Vec<Step>> = match kind {
2541                StepKind::For { body, .. }
2542                | StepKind::While { body, .. }
2543                | StepKind::FuncDef { body, .. }
2544                | StepKind::Timeout { body, .. }
2545                | StepKind::AssignAsync { body, .. }
2546                | StepKind::AsyncBlock { body } => vec![body],
2547                StepKind::If {
2548                    then_body,
2549                    else_ifs,
2550                    else_body,
2551                    ..
2552                } => {
2553                    let mut out = vec![then_body];
2554                    out.extend(else_ifs.iter().map(|(_, b)| b));
2555                    out.extend(else_body.iter());
2556                    out
2557                }
2558                _ => {
2559                    if let StepKind::WithIo { cmd, .. } = kind {
2560                        return step_contains_kind(cmd, name);
2561                    }
2562                    if let StepKind::AssignCapture { cmd, .. } = kind {
2563                        return step_contains_kind(cmd, name);
2564                    }
2565                    return false;
2566                }
2567            };
2568            bodies
2569                .iter()
2570                .any(|body| body.iter().any(|s| step_contains_kind(&s.kind, name)))
2571        }
2572
2573        let registry = all_metadata();
2574        for meta in registry {
2575            if meta.examples.is_empty() {
2576                continue;
2577            }
2578
2579            let code = meta.examples[0].code;
2580            let ast = parse_script(code, lower_command)
2581                .unwrap_or_else(|e| panic!("Failed to parse example for {}: {}", meta.name, e));
2582
2583            let matching = ast.iter().find(|step| {
2584                // Mutation has no keyword: its Display (`$var = ...`) cannot
2585                // start with the metadata name, so match the variant directly.
2586                if meta.name == "MUTATION" {
2587                    return matches!(step.kind, StepKind::Set { .. });
2588                }
2589                // Control-flow leaves (RETURN/BREAK/CONTINUE) only occur
2590                // nested inside bodies, so search recursively; everything
2591                // else must appear at top level with a matching Display.
2592                if matches!(meta.name, "RETURN" | "BREAK" | "CONTINUE") {
2593                    return step_contains_kind(&step.kind, meta.name);
2594                }
2595                let kind = match &step.kind {
2596                    StepKind::WithIo { cmd, .. } => &**cmd,
2597                    other => other,
2598                };
2599                // Full Display covers wrapper kinds themselves (e.g. a
2600                // WithIo step displays as WITH_IO ...); unwrapped covers
2601                // wrapped leaf commands.
2602                kind.to_string().starts_with(meta.name)
2603                    || step.kind.to_string().starts_with(meta.name)
2604            });
2605
2606            assert!(
2607                matching.is_some(),
2608                "No step in example for {} produces Display starting with {}",
2609                meta.name,
2610                meta.name
2611            );
2612        }
2613    }
2614}