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