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