1use 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
22fn 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
93pub 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
119pub 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
135fn 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
193fn 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
238fn 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 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
796pub 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
995impl 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 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 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 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 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}