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