Skip to main content

oxdock_parser/
commands.rs

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