1use std::fmt;
16
17use crate::ast::{
18 Arg, ArgPart, Expr, IoBinding, IoStream, PipeTarget, Step, TypeKind, Value, WorkspaceTarget,
19};
20use crate::command::{
21 ArgSpec, ArgType, CommandMeta, Example, FlagSpec, FlagValueType, IoDirection, Stream,
22 split_assignment,
23};
24use anyhow::{Result, anyhow, bail};
25use indoc::indoc;
26
27fn join_value(args: Vec<Arg>, cmd_name: &str) -> Result<Arg> {
38 if args.is_empty() {
39 bail!("{cmd_name} requires at least one argument");
40 }
41 if args.len() == 1 {
42 return Ok(args.into_iter().next().unwrap());
43 }
44 if args.iter().all(|a| matches!(a, Arg::String(..))) {
45 return Ok(Arg::String(
46 args.iter()
47 .map(|a| a.as_str())
48 .collect::<Vec<_>>()
49 .join(" "),
50 false,
51 ));
52 }
53 let mut parts = Vec::new();
54 for (index, arg) in args.into_iter().enumerate() {
55 if index > 0 {
56 parts.push(ArgPart::Text(" ".to_string(), false));
57 }
58 match arg {
59 Arg::String(text, quoted) => parts.push(ArgPart::Text(text, quoted)),
60 Arg::Expr(expr) => parts.push(ArgPart::Expr(expr)),
61 Arg::Parts(inner) => parts.extend(inner),
62 }
63 }
64 Ok(Arg::Parts(parts))
65}
66
67pub fn lower_env_assignment(args: Vec<Arg>) -> Result<StepKind> {
71 let arg = args
72 .into_iter()
73 .next()
74 .ok_or_else(|| anyhow!("ENV requires KEY=value"))?;
75 let Some((key, value)) = split_assignment(arg.as_str())? else {
76 bail!("ENV requires KEY=value format")
77 };
78 Ok(StepKind::Env { key, value })
79}
80
81pub(crate) fn canonical_assignment_arg(key: &str, value: &Arg) -> Arg {
86 Arg::String(format!("{key}={}", value.render()), false)
87}
88
89fn fmt_assert_target(target: &AssertTarget) -> String {
92 match target {
93 AssertTarget::Value(arg) => fmt_value(arg, quote_msg),
94 _ => target.render(),
95 }
96}
97
98fn fmt_value(arg: &Arg, quote: fn(&str) -> String) -> String {
103 match arg {
104 Arg::Expr(_) => arg.render(),
105 Arg::String(text, _) => quote(text),
106 Arg::Parts(_) => {
107 let rendered = arg.render();
108 if rendered.contains(';')
109 || rendered.contains('}')
110 || rendered.contains('\n')
111 || rendered.contains('\r')
112 {
113 quote(&rendered)
114 } else {
115 rendered
116 }
117 }
118 }
119}
120
121fn quote_arg(s: &str) -> String {
122 let is_safe = s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
123 && !s.starts_with(|c: char| c.is_ascii_digit() || c == '-' || c == '/' || c == '.')
124 && !crate::Command::is_statement_keyword(s);
125 if is_safe && !s.is_empty() {
126 s.to_string()
127 } else {
128 format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
129 }
130}
131
132fn quote_msg(s: &str) -> String {
133 let safe = s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
134 && !s.starts_with(|c: char| c.is_ascii_digit())
135 && !crate::Command::is_statement_keyword(s);
136 if safe && !s.is_empty() {
137 s.to_string()
138 } else {
139 format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
140 }
141}
142
143fn quote_run(s: &str) -> String {
144 if s.is_empty() || s.chars().any(|c| c == ';' || c == '\n') || s.contains("//") {
145 return format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""));
146 }
147 s.split(' ')
148 .map(|w| {
149 if w.starts_with(|c: char| c.is_ascii_digit())
150 || w.starts_with(['/', '.', '-', ':', '='])
151 {
152 format!("\"{}\"", w.replace('\\', "\\\\").replace('"', "\\\""))
153 } else {
154 w.to_string()
155 }
156 })
157 .collect::<Vec<_>>()
158 .join(" ")
159}
160
161fn fmt_exec_arg(arg: &Arg) -> String {
167 match arg {
168 Arg::String(text, _) => {
169 format!("\"{}\"", text.replace('\\', "\\\\").replace('"', "\\\""))
170 }
171 Arg::Expr(_) => arg.render(),
172 Arg::Parts(_) => {
173 let rendered = arg.render();
174 if rendered.contains(';')
175 || rendered.contains('}')
176 || rendered.contains('\n')
177 || rendered.contains('\r')
178 {
179 format!(
180 "\"{}\"",
181 rendered.replace('\\', "\\\\").replace('"', "\\\"")
182 )
183 } else {
184 rendered
185 }
186 }
187 }
188}
189
190fn fmt_raw_arg(arg: &Arg) -> String {
194 match arg {
195 Arg::String(s, true) => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")),
196 _ => arg.render(),
197 }
198}
199
200fn fmt_io(b: &IoBinding) -> String {
201 let s = match b.stream {
202 IoStream::Stdin => "stdin",
203 IoStream::Stdout => "stdout",
204 IoStream::Stderr => "stderr",
205 };
206 match &b.pipe {
207 Some(PipeTarget::Name(p)) => format!("{}=pipe:{}", s, p),
208 Some(PipeTarget::Var(v)) => format!("{}=${}", s, v),
209 None => s.to_string(),
210 }
211}
212
213pub(crate) fn is_known_command(name: &str) -> bool {
220 if name == "ELSE" {
221 return true;
222 }
223 all_metadata().iter().any(|meta| meta.name == name)
224}
225
226pub(crate) fn invalid_syntax_error(name: &str, raw_args: &[Arg]) -> anyhow::Error {
227 let received = raw_args
228 .iter()
229 .map(Arg::render)
230 .collect::<Vec<_>>()
231 .join(" ");
232 let got = if received.is_empty() {
233 "nothing".to_string()
234 } else {
235 format!("`{received}`")
236 };
237 match structural_hint(name, &received) {
238 Some(hint) => anyhow!("invalid syntax for command {name}: {hint}"),
239 None => anyhow!("invalid syntax for command {name}: got {got}."),
240 }
241}
242
243fn unknown_command_error(name: &str, raw_args: &[Arg]) -> anyhow::Error {
244 let received = raw_args
245 .iter()
246 .map(Arg::render)
247 .collect::<Vec<_>>()
248 .join(" ");
249 let hint = structural_hint(name, &received).or_else(|| case_hint(name));
250 match hint {
251 Some(hint) => anyhow!("unknown command: {name}\n{hint}"),
252 None => anyhow!("unknown command: {name}"),
253 }
254}
255
256fn structural_hint(name: &str, received: &str) -> Option<String> {
257 let got = if received.is_empty() {
258 "nothing".to_string()
259 } else {
260 format!("`{received}`")
261 };
262 match name {
263 "WITH_IO" => Some(with_io_hint(&got, received)),
264 "AWAIT" => Some(format!(
265 "AWAIT waits for a background task variable, e.g. `LET $t: HANDLE = ASYNC ECHO hi` then `AWAIT $t`; got {got}."
266 )),
267 "CANCEL" => Some(format!(
268 "CANCEL stops a background task variable, e.g. `CANCEL $t` (from `LET $t: HANDLE = ASYNC ...`); got {got}."
269 )),
270 "ASYNC" => Some(format!(
271 "ASYNC runs a command in the background, e.g. `ASYNC RUN ...`, `ASYNC {{ ... }}`, or `LET $t: HANDLE = ASYNC ...`; got {got}."
272 )),
273 "FOR" => Some(format!(
274 "FOR loops need `FOR $item: TYPE IN <expr> {{ ... }}` (or `FOR $key: STRING, $value: TYPE IN <expr> {{ ... }}`); got {got}."
275 )),
276 "IF" => Some(format!(
277 "IF needs a condition and a block, e.g. `IF true {{ ECHO yes }}`; got {got}."
278 )),
279 "ELSE" => Some(format!(
280 "ELSE must directly follow an `IF ... {{ ... }}` block, e.g. `IF true {{ ECHO yes }} ELSE {{ ECHO no }}`; got {got}."
281 )),
282 "LET" => Some(format!(
283 "LET assigns a variable, e.g. `LET $name: STRING = <expr>`, `LET $t: HANDLE = ASYNC ...`, `LET $out: STRING = <command>` (capture), or `LET $out: STRING = AWAIT $t`; got {got}."
284 )),
285 "SET" => Some(
286 "`SET` is not a keyword; mutate a declared variable with `$var = <expr>`, e.g. `$count = 2`.".to_string(),
287 ),
288 "TIMEOUT" => Some(format!(
289 "TIMEOUT needs a duration and a command or block, e.g. `TIMEOUT 30s RUN ...`; got {got}."
290 )),
291 "FUNC" => Some(format!(
292 "FUNC defines a function, e.g. `FUNC GREET($name: STRING) {{ RETURN $name }}`; got {got}."
293 )),
294 "CALL" => Some(format!(
295 "CALL invokes a function, e.g. `CALL GREET(\"ada\")` or `LET $r: STRING = CALL GREET(\"ada\")`; got {got}."
296 )),
297 "RETURN" => Some(format!(
298 "RETURN ends a function with a value, e.g. `RETURN $x`; got {got}."
299 )),
300 "WHILE" => Some(format!(
301 "WHILE needs a Bool condition and a block, e.g. `WHILE !$done {{ ... }}`; got {got}."
302 )),
303 "BREAK" => Some(
304 "`BREAK` exits the innermost enclosing FOR/WHILE loop; it must appear inside a loop.".to_string(),
305 ),
306 "CONTINUE" => Some(
307 "`CONTINUE` skips to the next iteration of the innermost enclosing FOR/WHILE loop; it must appear inside a loop.".to_string(),
308 ),
309 "INHERIT_ENV" => Some(format!(
310 "INHERIT_ENV takes a key list, e.g. `INHERIT_ENV [HOME PATH]`; got {got}."
311 )),
312 _ => None,
313 }
314}
315
316fn with_io_hint(got: &str, received: &str) -> String {
319 const SYNTAX: &str =
320 "WITH_IO needs `WITH_IO [bindings] <command>` or `WITH_IO [bindings] { <commands> }`";
321 const BINDINGS: &str = "bindings are `stdin`, `stdout`, `stderr`, `<stream>=pipe:<name>`, or `<stream>=$var` with a PIPE-typed variable (e.g. `[stdout=pipe:log]`, `[stdin=$p]`)";
322 if let Some(after_open) = received.strip_prefix('[') {
323 match after_open.split_once(']') {
324 None => {
325 return format!("{SYNTAX}: missing closing `]` in the binding list; got {got}.");
326 }
327 Some((bindings, _)) => {
328 for part in bindings.split(',') {
329 let part = part.trim();
330 if part.is_empty() {
331 continue;
332 }
333 let (stream, binding) = match part.split_once('=') {
334 Some((stream, binding)) => (stream.trim(), Some(binding.trim())),
335 None => (part, None),
336 };
337 if !matches!(stream, "stdin" | "stdout" | "stderr") {
338 return format!(
339 "{SYNTAX}: invalid stream `{stream}`; expected `stdin`, `stdout`, or `stderr`; got {got}."
340 );
341 }
342 let valid = match binding {
343 None => true,
344 Some(value) => value
345 .strip_prefix("pipe:")
346 .map(|pipe| !pipe.trim().is_empty())
347 .unwrap_or(false),
348 };
349 if !valid {
350 return format!(
351 "{SYNTAX}: invalid binding `{part}`; {BINDINGS}; got {got}."
352 );
353 }
354 }
355 }
356 }
357 }
358 format!("{SYNTAX}; got {got}. {BINDINGS}.")
359}
360
361fn case_hint(name: &str) -> Option<String> {
363 let upper = name.to_ascii_uppercase();
364 if upper != name
365 && all_metadata()
366 .iter()
367 .any(|meta| meta.name == upper.as_str())
368 {
369 return Some(format!("did you mean `{upper}`? commands are uppercase."));
370 }
371 None
372}
373
374macro_rules! declare_commands {
375 (
376 structural [
377 $( $sname:ident $( { $( $sfname:ident : $sftype:ty ),* $(,)? } )? ),* $(,)?
378 ]
379
380 $(
381 $cmd_ident:ident => [
382 name: $name:expr,
383 variant: $vname:ident $( { $( $vfname:ident : $vftype:ty ),* $(,)? } )? $( ( $( $ttuple:ty ),* $(,)? ) )?,
384 syntax: $syntax:expr,
385 summary: $summary:expr,
386 description: $desc:expr,
387 args: $args:expr,
388 flags: $flags:expr,
389 default_output: $out:expr,
390 examples: $examples:expr,
391 lower: $lower:expr,
392 ]
393 ),* $(,)?
394 ) => {
395 #[derive(Debug, Clone, PartialEq)]
396 pub enum StepKind {
397 $( $vname $( { $( $vfname : $vftype ),* } )? $( ( $( $ttuple ),* ) )?, )*
398 $( $sname $( { $( $sfname : $sftype ),* } )?, )*
399 }
400
401 pub fn lower_command(name: &str, raw_args: Vec<Arg>) -> Result<StepKind> {
402 match name {
403 $(
404 s if s == $name => {
405 let meta = CommandMeta {
406 name: $name, syntax: $syntax, summary: $summary,
407 description: $desc, args: $args, flags: $flags,
408 default_output: $out, examples: $examples,
409 };
410 let (flags, positional) = crate::strip_flags(raw_args, &meta)?;
411 crate::command::validate_positionals_against_meta(
412 s,
413 &meta.args,
414 &positional,
415 )?;
416 let lower_fn: fn(Vec<(String, Arg)>, Vec<Arg>) -> Result<StepKind> = $lower;
417 lower_fn(flags, positional)
418 }
419 )*
420 _ => {
421 if is_known_command(name) {
422 Err(invalid_syntax_error(name, &raw_args))
423 } else {
424 Err(unknown_command_error(name, &raw_args))
425 }
426 }
427 }
428 }
429
430 pub fn all_metadata() -> Vec<CommandMeta> {
431 let mut out = vec![
432 $( CommandMeta {
433 name: $name, syntax: $syntax, summary: $summary,
434 description: $desc, args: $args, flags: $flags,
435 default_output: $out, examples: $examples,
436 }, )*
437 ];
438 out.extend(all_structural_metadata());
442 out
443 }
444 };
445}
446
447#[derive(Debug, Clone, PartialEq)]
454pub enum AssertTarget {
455 Value(Arg),
456 Stdout,
457 Stderr,
458 Pipe(String),
459}
460
461impl AssertTarget {
462 pub fn render(&self) -> String {
463 match self {
464 AssertTarget::Value(arg) => arg.render(),
465 AssertTarget::Stdout => "stdout".to_string(),
466 AssertTarget::Stderr => "stderr".to_string(),
467 AssertTarget::Pipe(name) => format!("pipe:{name}"),
468 }
469 }
470}
471
472fn lower_assert_target(arg: Arg, cmd_name: &str) -> Result<AssertTarget> {
480 match arg {
481 Arg::Expr(_) => Ok(AssertTarget::Value(arg)),
482 Arg::String(text, quoted) if !quoted => match text.as_str() {
483 "stdout" => Ok(AssertTarget::Stdout),
484 "stderr" => Ok(AssertTarget::Stderr),
485 _ => match text.strip_prefix("pipe:") {
486 Some(name) if !name.is_empty() => Ok(AssertTarget::Pipe(name.to_string())),
487 Some(_) => bail!("{cmd_name} pipe target needs a name, got {text:?}"),
488 None => Ok(AssertTarget::Value(lower_assert_operand(Arg::String(
489 text, false,
490 )))),
491 },
492 },
493 other => Ok(AssertTarget::Value(lower_assert_operand(other))),
494 }
495}
496
497fn lower_assert_operand(arg: Arg) -> Arg {
506 match arg {
507 Arg::String(text, false) => {
508 if let Ok(i) = text.parse::<i64>() {
509 Arg::Expr(Expr::Literal(Value::Int(i)))
510 } else if text.contains('.') && text.parse::<f64>().is_ok() {
511 Arg::Expr(Expr::Literal(Value::Float(
512 text.parse::<f64>().unwrap_or(f64::NAN),
513 )))
514 } else if text == "true" {
515 Arg::Expr(Expr::Literal(Value::Bool(true)))
516 } else if text == "false" {
517 Arg::Expr(Expr::Literal(Value::Bool(false)))
518 } else {
519 Arg::String(text, false)
520 }
521 }
522 other => other,
523 }
524}
525
526declare_commands! {
527 structural [
528 WithIo { bindings: Vec<IoBinding>, cmd: Box<StepKind> },
529 WithIoBlock { bindings: Vec<IoBinding> },
530 For { key_var: Option<String>, key_type: Option<TypeKind>, var: String, var_type: TypeKind, in_expr: Expr, body: Vec<Step> },
531 If { cond: Box<Expr>, then_body: Vec<Step>, else_ifs: Vec<(Box<Expr>, Vec<Step>)>, else_body: Option<Vec<Step>> },
532 Assign { var: String, decl_type: TypeKind, expr: Expr },
533 Set { var: String, expr: Expr },
534 AssignCapture { var: String, decl_type: TypeKind, cmd: Box<StepKind> },
535 AwaitCapture { out_var: String, out_type: TypeKind, task_var: String },
536 AsyncBlock { body: Vec<Step> },
537 AssignAsync { var: String, decl_type: TypeKind, body: Vec<Step> },
538 Await { var: String },
539 Cancel { var: String },
540 Timeout { duration: Arg, body: Vec<Step> },
541 RunExec { argv: Vec<Arg> },
542 FuncDef { name: String, params: Vec<(String, TypeKind)>, body: Vec<Step> },
543 Call { name: String, args: Vec<Expr> },
544 Return { expr: Box<Expr> },
545 While { cond: Box<Expr>, body: Vec<Step> },
546 Break,
547 Continue,
548 ]
549
550 Workdir => [
551 name: "WORKDIR",
552 variant: Workdir(Arg),
553 syntax: "WORKDIR <path>",
554 summary: "Change the working directory.",
555 description: indoc! {r#"
556 Sets the current working directory.
557
558 Relative paths resolve against the current directory; `/` resets to
559 the workspace root. Paths cannot escape the workspace.
560 "#},
561 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Directory to change to", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
562 flags: &[],
563 default_output: None,
564 examples: &[ Example { name: "change working directory", fence_meta: None, code: indoc! {r#"
565 WORKDIR project/src
566 WRITE generated.txt generated-under-workdir
567 LET $body: STRING = READ generated.txt
568 ASSERT_EQ $body "generated-under-workdir"
569 "#} } ],
570 lower: |_flags, args| {
571 let path = args.into_iter().next().ok_or_else(|| anyhow!("WORKDIR requires a path"))?;
572 Ok(StepKind::Workdir(path))
573 },
574 ],
575
576 Workspace => [
577 name: "WORKSPACE",
578 variant: Workspace(WorkspaceTarget),
579 syntax: "WORKSPACE SNAPSHOT|LOCAL",
580 summary: "Switch workspace roots.",
581 description: "SNAPSHOT or LOCAL root.",
582 args: &[ ArgSpec { name: "target", arg_type: ArgType::OneOf(&["SNAPSHOT", "LOCAL"]), description: "Target root", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
583 flags: &[],
584 default_output: None,
585 examples: &[ Example { name: "switch roots", fence_meta: None, code: indoc! {r#"WORKSPACE LOCAL"#} } ],
586 lower: |_flags, args| {
587 let target = args.into_iter().next().ok_or_else(|| anyhow!("WORKSPACE requires a target"))?;
588 match target.as_str() {
589 "SNAPSHOT" | "snapshot" => Ok(StepKind::Workspace(WorkspaceTarget::Snapshot)),
590 "LOCAL" | "local" => Ok(StepKind::Workspace(WorkspaceTarget::Local)),
591 other => bail!("unknown workspace target: {other}"),
592 }
593 },
594 ],
595
596 Env => [
597 name: "ENV",
598 variant: Env { key: String, value: Arg },
599 syntax: "ENV KEY=value",
600 summary: "Set an environment variable.",
601 description: indoc! {r#"
602 Inserts or updates an env var.
603
604 The value uses the unified string-value rules shared by every command:
605 `"..."` or `'...'` quotes keep exact bytes (spaces, tabs), a lone `$var`
606 evaluates that variable, `{{ ... }}` placeholders interpolate, unquoted
607 words join with single spaces, and the first `=` splits key from value
608 (`KEY=a=b` stores `a=b`).
609
610 A `$var` inside larger text stays literal — write `{{ $var }}` to
611 interpolate there.
612 "#},
613 args: &[ ArgSpec { name: "assignment", arg_type: ArgType::KeyValue, description: "KEY=value pair; the value resolves as STRING", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
614 flags: &[],
615 default_output: None,
616 examples: &[
617 Example { name: "set env", fence_meta: None, code: indoc! {r#"ENV APP_MODE=production"#} },
618 Example { name: "quoted value with spaces", fence_meta: None, code: indoc! {r#"
619 # quotes keep the space: SET_FORTH stores `outer scope`
620 ENV SET_FORTH="outer scope"
621 WRITE out.txt "{{ env:SET_FORTH }}"
622 LET $body: STRING = READ out.txt
623 ASSERT_EQ $body "outer scope"
624 "#} },
625 Example { name: "variable value", fence_meta: None, code: indoc! {r#"
626 # a lone $var evaluates, like ECHO $var
627 LET $who: STRING = "Alice"
628 ENV GREETING=$who
629 WRITE out.txt "{{ env:GREETING }}"
630 LET $body: STRING = READ out.txt
631 ASSERT_EQ $body "Alice"
632 "#} },
633 Example { name: "all value forms agree", fence_meta: None, code: indoc! {r#"
634 # a bare variable, a quoted literal, and a template all
635 # store plain strings through the same value rules
636 LET $x: STRING = "Ada"
637 ENV A=$x
638 ENV B="hello world"
639 ENV C="{{ $x }} concatenated"
640 WRITE check.txt "{{ env:A }}|{{ env:B }}|{{ env:C }}"
641 LET $body: STRING = READ check.txt
642 ASSERT_EQ $body "Ada|hello world|Ada concatenated"
643 "#} },
644 Example { name: "scoped env reverts", fence_meta: None, code: indoc! {r#"
645 # ENV inside a braced block reverts when the block exits
646 ENV MODE=production
647 [bool:true] {
648 ENV MODE=staging
649 WRITE inner.txt "{{ env:MODE }}"
650 }
651 WRITE outer.txt "{{ env:MODE }}"
652 LET $inner_body: STRING = READ inner.txt
653 LET $outer_body: STRING = READ outer.txt
654 ASSERT_EQ $inner_body "staging"
655 ASSERT_EQ $outer_body "production"
656 "#} },
657 ],
658 lower: |_flags, args| lower_env_assignment(args),
659 ],
660
661 InheritEnv => [
662 name: "INHERIT_ENV",
663 variant: InheritEnv { keys: Vec<String> },
664 syntax: "INHERIT_ENV <key>...",
665 summary: "Inherit env vars from host.",
666 description: indoc! {r#"
667 Declares which host environment variables to inherit into the script.
668
669 Must appear before any other commands and at most once. Without this
670 directive, the script starts with an empty environment.
671 "#},
672 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 } ],
673 flags: &[],
674 default_output: None,
675 examples: &[ Example { name: "inherit env", fence_meta: None, code: indoc! {r#"INHERIT_ENV [PATH, HOME]"#} } ],
676 lower: |_flags, args| {
677 let keys = args.into_iter().map(|a| a.as_str().to_string()).collect();
678 Ok(StepKind::InheritEnv { keys })
679 },
680 ],
681
682 Echo => [
683 name: "ECHO",
684 variant: Echo(Arg),
685 syntax: "ECHO <message>",
686 summary: "Print to stdout.",
687 description: "Outputs message to stdout.",
688 args: &[ ArgSpec { name: "message", arg_type: ArgType::Rest(&ArgType::String), description: "Text", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
689 flags: &[],
690 default_output: Some(Stream::Stdout),
691 examples: &[
692 Example { name: "echo", fence_meta: None, code: indoc! {r#"ECHO build-complete"#} },
693 Example { name: "variables", fence_meta: None, code: indoc! {r#"
694 # a lone $x evaluates; {{ }} interpolates inside text
695 LET $x: STRING = "World"
696 ECHO {{ $x }}
697 ECHO $x
698 ASSERT_CONTAINS stdout "World"
699 "#} },
700 ],
701 lower: |_flags, args| Ok(StepKind::Echo(join_value(args, "ECHO")?)),
702 ],
703
704 Run => [
705 name: "RUN",
706 variant: Run(Arg),
707 syntax: "RUN <command...> | RUN [\"exe\", \"arg\", ...]",
708 summary: "Execute shell command or direct executable.",
709 description: indoc! {r#"
710 Shell form (`RUN <command...>`) runs the joined command string in the
711 system shell (`$SHELL -c` / `COMSPEC /C`).
712
713 Exec form (`RUN ["exe", "arg", ...]`) spawns the executable directly
714 with no shell, so there is no shell expansion, globbing, redirection,
715 or pipes; use it for portable commands.
716
717 Guards and wrappers (`ASYNC`, `TIMEOUT`, `WITH_IO`, `LET`) apply to
718 both forms.
719 "#},
720 args: &[ ArgSpec { name: "command", arg_type: ArgType::Rest(&ArgType::String), description: "Command", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
721 flags: &[],
722 default_output: None,
723 examples: &[ Example { name: "run", fence_meta: None, code: indoc! {r#"RUN echo hello"#} }, Example { name: "run exec form", fence_meta: None, code: indoc! {r#"RUN ["cargo", "--version"]"#} } ],
724 lower: |_flags, args| match args.as_slice() {
725 [Arg::Expr(Expr::List(elems))] if elems.is_empty() => {
726 bail!("RUN requires at least one argument")
727 }
728 [Arg::Expr(Expr::List(elems))] => Ok(StepKind::RunExec {
729 argv: elems.iter().cloned().map(Arg::Expr).collect(),
730 }),
731 _ => Ok(StepKind::Run(join_value(args, "RUN")?)),
732 },
733 ],
734
735 Copy => [
736 name: "COPY",
737 variant: Copy { from_current_workspace: bool, from: Arg, to: Arg },
738 syntax: "COPY [--from-current-workspace] <from> <to>",
739 summary: "Copy file into workspace.",
740 description: "Copies from host.",
741 args: &[
742 ArgSpec { name: "from", arg_type: ArgType::Path, description: "Source", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
743 ArgSpec { name: "to", arg_type: ArgType::Path, description: "Dest", io: IoDirection::Write, index: 1, required: true, fallback_stream: None },
744 ],
745 flags: &[ FlagSpec { name: "from_current_workspace", long: "--from-current-workspace", value_type: FlagValueType::Flag, required: false, description: "Copy from workspace instead of build context" } ],
746 default_output: None,
747 examples: &[ Example { name: "copy", fence_meta: Some("roots:unified"), code: indoc! {r#"
748 WRITE src.txt content
749 COPY src.txt dst.txt
750 LET $body: STRING = READ dst.txt
751 ASSERT_EQ $body "content"
752 "#} }, Example { name: "copy from workspace", fence_meta: Some("roots:unified"), code: indoc! {r#"
753 WRITE ws-src.txt ws-content
754 COPY --from-current-workspace ws-src.txt ws-copy.txt
755 LET $body: STRING = READ ws-copy.txt
756 ASSERT_EQ $body "ws-content"
757 "#} } ],
758 lower: |flags, args| {
759 let from_current_workspace = flags.iter().any(|(k, _)| k == "from_current_workspace");
760 let mut it = args.into_iter();
761 let from = it.next().ok_or_else(|| anyhow!("COPY requires a source"))?;
762 let to = it.next().ok_or_else(|| anyhow!("COPY requires a destination"))?;
763 Ok(StepKind::Copy { from_current_workspace, from, to })
764 },
765 ],
766
767 CopyGit => [
768 name: "COPY_GIT",
769 variant: CopyGit { rev: Arg, from: Arg, to: Arg, include_dirty: bool },
770 syntax: "COPY_GIT [--include-dirty] <rev> <src> <dst>",
771 summary: "Copy from git revision.",
772 description: "Checkout and copy.",
773 args: &[
774 ArgSpec { name: "rev", arg_type: ArgType::String, description: "Rev", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
775 ArgSpec { name: "src", arg_type: ArgType::Path, description: "Src", io: IoDirection::Read, index: 1, required: true, fallback_stream: None },
776 ArgSpec { name: "dst", arg_type: ArgType::Path, description: "Dst", io: IoDirection::Write, index: 2, required: true, fallback_stream: None },
777 ],
778 flags: &[ FlagSpec { name: "dirty", long: "--include-dirty", value_type: FlagValueType::Flag, required: false, description: "Include dirty" } ],
779 default_output: None,
780 examples: &[ Example { name: "git copy", fence_meta: Some("expect_error:\"COPY source missing\""), code: indoc! {r#"COPY_GIT HEAD src.txt dst.txt"#} } ],
781 lower: |flags, args| {
782 let include_dirty = flags.iter().any(|(k, _)| k == "dirty");
783 let mut it = args.into_iter();
784 let rev = it.next().ok_or_else(|| anyhow!("COPY_GIT requires a revision"))?;
785 let from = it.next().ok_or_else(|| anyhow!("COPY_GIT requires a source"))?;
786 let to = it.next().ok_or_else(|| anyhow!("COPY_GIT requires a destination"))?;
787 Ok(StepKind::CopyGit { rev, from, to, include_dirty })
788 },
789 ],
790
791 Symlink => [
792 name: "SYMLINK",
793 variant: Symlink { from: Arg, to: Arg },
794 syntax: "SYMLINK <from> <to>",
795 summary: "Create symlink.",
796 description: "Creates symlink.",
797 args: &[
798 ArgSpec { name: "from", arg_type: ArgType::Path, description: "Target", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
799 ArgSpec { name: "to", arg_type: ArgType::Path, description: "Link", io: IoDirection::Write, index: 1, required: true, fallback_stream: None },
800 ],
801 flags: &[],
802 default_output: None,
803 examples: &[ Example { name: "symlink", fence_meta: Some("roots:unified"), code: indoc! {r#"
804 WRITE original.txt content
805 SYMLINK original.txt link.txt
806 LET $body: STRING = READ link.txt
807 ASSERT_EQ $body "content"
808 "#} } ],
809 lower: |_flags, args| {
810 let mut it = args.into_iter();
811 let from = it.next().ok_or_else(|| anyhow!("SYMLINK requires a source"))?;
812 let to = it.next().ok_or_else(|| anyhow!("SYMLINK requires a target"))?;
813 Ok(StepKind::Symlink { from, to })
814 },
815 ],
816
817 Mkdir => [
818 name: "MKDIR",
819 variant: Mkdir(Arg),
820 syntax: "MKDIR <path>",
821 summary: "Create directory.",
822 description: "Creates dir with parents.",
823 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Dir path", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
824 flags: &[],
825 default_output: None,
826 examples: &[ Example { name: "mkdir", fence_meta: None, code: indoc! {r#"MKDIR deeply/nested/tree"#} } ],
827 lower: |_flags, args| Ok(StepKind::Mkdir(args.into_iter().next().ok_or_else(|| anyhow!("MKDIR requires a path"))?)),
828 ],
829
830 Ls => [
831 name: "LS",
832 variant: Ls(Option<Arg>),
833 syntax: "LS [<path>]",
834 summary: "List directory.",
835 description: "Lists entries.",
836 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Dir", io: IoDirection::Read, index: 0, required: false, fallback_stream: None } ],
837 flags: &[],
838 default_output: Some(Stream::Stdout),
839 examples: &[ Example { name: "ls", fence_meta: None, code: indoc! {r#"
840 MKDIR inventory
841 WRITE inventory/a.txt a
842 LS inventory
843 "#} } ],
844 lower: |_flags, args| Ok(StepKind::Ls(args.into_iter().next())),
845 ],
846
847 Cwd => [
848 name: "CWD",
849 variant: Cwd,
850 syntax: "CWD",
851 summary: "Print working directory.",
852 description: "Outputs cwd.",
853 args: &[],
854 flags: &[],
855 default_output: Some(Stream::Stdout),
856 examples: &[ Example { name: "cwd", fence_meta: None, code: indoc! {r#"CWD"#} } ],
857 lower: |_flags, _args| Ok(StepKind::Cwd),
858 ],
859
860 Read => [
861 name: "READ",
862 variant: Read(Option<Arg>),
863 syntax: "READ [<path>]",
864 summary: "Read file to stdout.",
865 description: "Outputs file contents.",
866 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Read, index: 0, required: false, fallback_stream: None } ],
867 flags: &[],
868 default_output: Some(Stream::Stdout),
869 examples: &[ Example { name: "read", fence_meta: None, code: indoc! {r#"
870 WRITE note.txt "hello"
871 READ note.txt
872 "#} } ],
873 lower: |_flags, args| Ok(StepKind::Read(args.into_iter().next())),
874 ],
875
876 ReadLine => [
877 name: "READ_LINE",
878 variant: ReadLine { var: String },
879 syntax: "READ_LINE $var",
880 summary: "Read one line from stdin into a variable.",
881 description: indoc! {r#"
882 Reads bytes until newline without waiting for EOF, leaving the pipe open.
883
884 Trailing newline is stripped (shell-read parity). On premature EOF
885 assigns accumulated bytes and returns.
886 "#},
887 args: &[ ArgSpec { name: "var", arg_type: ArgType::Var, description: "Target variable (`$name`); the line binds as STRING", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
888 flags: &[],
889 default_output: None,
890 examples: &[ Example { name: "read line", fence_meta: None, code: indoc! {r#"
891 WITH_IO [stdout=pipe:lines] ECHO "first"
892 WITH_IO [stdin=pipe:lines] READ_LINE $reply
893 "#} } ],
894 lower: |_flags, args| {
895 let arg = args.into_iter().next().ok_or_else(|| anyhow!("READ_LINE requires a variable"))?;
896 let var = match arg {
897 Arg::Expr(Expr::Var(name)) => name,
898 Arg::String(s, _) => s.trim_start_matches('$').to_string(),
899 other => bail!("READ_LINE requires a $variable, found {:?}", other),
900 };
901 if var.is_empty() {
902 bail!("READ_LINE requires a variable");
903 }
904 Ok(StepKind::ReadLine { var })
905 },
906 ],
907
908 Write => [
909 name: "WRITE",
910 variant: Write { path: Arg, contents: Option<Arg> },
911 syntax: "WRITE <path> [<contents>]",
912 summary: "Write to file.",
913 description: "Writes contents.",
914 args: &[
915 ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Write, index: 0, required: true, fallback_stream: None },
916 ArgSpec { name: "contents", arg_type: ArgType::Rest(&ArgType::String), description: "Content", io: IoDirection::Write, index: 1, required: false, fallback_stream: Some(Stream::Stdin) },
917 ],
918 flags: &[],
919 default_output: None,
920 examples: &[ Example { name: "write", fence_meta: None, code: indoc! {r#"WRITE output.txt hello-world"#} } ],
921 lower: |_flags, args| {
922 let mut it = args.into_iter();
923 let path = it.next().ok_or_else(|| anyhow!("WRITE requires a path"))?;
924 let remaining: Vec<Arg> = it.collect();
925 let contents = if remaining.is_empty() { None } else { Some(join_value(remaining, "WRITE")?) };
926 Ok(StepKind::Write { path, contents })
927 },
928 ],
929
930 Append => [
931 name: "APPEND",
932 variant: Append { path: Arg, contents: Option<Arg> },
933 syntax: "APPEND <path> [<contents>]",
934 summary: "Append to file.",
935 description: "Appends contents.",
936 args: &[
937 ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Write, index: 0, required: true, fallback_stream: None },
938 ArgSpec { name: "contents", arg_type: ArgType::Rest(&ArgType::String), description: "Content", io: IoDirection::Write, index: 1, required: false, fallback_stream: Some(Stream::Stdin) },
939 ],
940 flags: &[],
941 default_output: None,
942 examples: &[ Example { name: "append", fence_meta: None, code: indoc! {r#"
943 WRITE log.txt line1
944 APPEND log.txt line2
945 LET $all: STRING = READ log.txt
946 ASSERT_EQ $all "line1line2"
947 "#} } ],
948 lower: |_flags, args| {
949 let mut it = args.into_iter();
950 let path = it.next().ok_or_else(|| anyhow!("APPEND requires a path"))?;
951 let remaining: Vec<Arg> = it.collect();
952 let contents = if remaining.is_empty() { None } else { Some(join_value(remaining, "APPEND")?) };
953 Ok(StepKind::Append { path, contents })
954 },
955 ],
956
957 Expand => [
958 name: "EXPAND",
959 variant: Expand { path: Option<Arg>, overrides: Vec<(String, Arg)> },
960 syntax: "EXPAND [<path>] [<KEY=val> ...]",
961 summary: "Expand a template file (or stdin) to stdout.",
962 description: indoc! {r#"
963 A template is any text file — or piped stdin when no path is given —
964 containing `{{ ... }}` placeholders. EXPAND replaces each placeholder
965 and prints the result to stdout.
966
967 Placeholders: `{{ NAME }}` reads a `KEY=val` override passed on this
968 command; `{{ env:NAME }}` reads an override, falling back to the
969 environment; `{{ $var }}` reads a script variable (dotted paths allowed).
970 A missing key is an error, never a silent empty.
971
972 Substitution runs in a single pass. EXPAND is not recursive and does not
973 expand nested placeholders: a value that itself contains `{{ ... }}` is
974 inserted verbatim and never expanded again.
975
976 A bare `$var` argument is a template path; `KEY=val` arguments are
977 overrides whose values follow the unified string-value rules (same as
978 `ENV`: quotes keep exact bytes, a lone `$var` evaluates,
979 `{{ ... }}` interpolates).
980
981 NOTE: `WRITE` interpolates `{{ ... }}` while writing, so escape it
982 (`\{{ ... }}`) when writing a template file for a later `EXPAND`.
983
984 With no path, the template arrives on stdin through a pipe. When piping
985 from a shell, single-quote the template (`echo '{{ $x }}'`): double
986 quotes let the shell swallow `$x`, so oxdock receives an empty `{{ }}`
987 placeholder and errors.
988 "#},
989 args: &[
990 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 },
991 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 },
992 ],
993 flags: &[],
994 default_output: Some(Stream::Stdout),
995 examples: &[
996 Example { name: "expand", fence_meta: None, code: indoc! {r#"
997 ENV NAME="Alice"
998 WRITE template.md "Hello {{ env:NAME }}!"
999 EXPAND template.md
1000 ASSERT_CONTAINS stdout "Hello Alice!"
1001 "#} },
1002 Example { name: "override with spaces", fence_meta: None, code: indoc! {r#"
1003 # WRITE would interpolate {{ }} right away, so escape it:
1004 # the file must literally contain {{ env:NAME }} for EXPAND
1005 WRITE template.md "Hello \{{ env:NAME }}!"
1006 EXPAND template.md NAME="Alice Smith"
1007 ASSERT_CONTAINS stdout "Hello Alice Smith!"
1008 "#} },
1009 Example { name: "variable override", fence_meta: None, code: indoc! {r#"
1010 # same escaping: keep the placeholder literal until EXPAND;
1011 # a lone $who evaluates, like ECHO $who
1012 LET $who: STRING = "Bob"
1013 WRITE template.md "Hi \{{ env:WHO }}!"
1014 EXPAND template.md WHO=$who
1015 ASSERT_CONTAINS stdout "Hi Bob!"
1016 "#} },
1017 Example { name: "override forms agree", fence_meta: None, code: indoc! {r#"
1018 # a bare variable and a template-with-tail expand identically
1019 LET $x: STRING = "Ada"
1020 WRITE template.md "Hi \{{ env:NAME }} and \{{ env:NAME2 }}!"
1021 EXPAND template.md NAME=$x NAME2="{{ $x }} concatenated"
1022 ASSERT_CONTAINS stdout "Hi Ada and Ada concatenated!"
1023 "#} },
1024 Example { name: "expand stdin", fence_meta: None, code: indoc! {r#"
1025 # no path: the template arrives on stdin through a pipe
1026 WITH_IO [stdout=pipe:tpl] ECHO "Hello \{{ env:NAME }}!"
1027 WITH_IO [stdin=pipe:tpl] EXPAND NAME=Alice
1028 ASSERT_CONTAINS stdout "Hello Alice!"
1029 "#} },
1030 Example { name: "override does not leak", fence_meta: None, code: indoc! {r#"
1031 # KEY=val overrides shadow env for that EXPAND only —
1032 # they never update the environment itself
1033 ENV NAME="Alice"
1034 WRITE template.md "Hi \{{ env:NAME }}!"
1035 EXPAND template.md NAME="Bob"
1036 ASSERT_CONTAINS stdout "Hi Bob!"
1037 EXPAND template.md
1038 ASSERT_CONTAINS stdout "Hi Alice!"
1039 "#} },
1040 ],
1041 lower: |_flags, args| {
1042 let mut path = None;
1043 let mut overrides = Vec::new();
1044 for arg in args {
1045 let text = arg.as_str();
1046 if let Some((key, value)) = split_assignment(text)? {
1047 overrides.push((key, value));
1048 } else if path.is_none() { path = Some(arg); }
1049 else { bail!("EXPAND accepts at most one path"); }
1050 }
1051 Ok(StepKind::Expand { path, overrides })
1052 },
1053 ],
1054
1055 AssertEq => [
1056 name: "ASSERT_EQ",
1057 variant: AssertEq { hash: Option<String>, actual: AssertTarget, expected: Option<Arg> },
1058 syntax: "ASSERT_EQ [--hash <sha256>] <actual> <expected>",
1059 summary: "Assert strict equality.",
1060 description: indoc! {r#"
1061 Compares two evaluated values with typed equality (no coercion:
1062 `Int(42)` never equals `String("42")`), aborting the pipeline
1063 with a step-numbered error showing expected vs actual otherwise.
1064
1065 Both sides are values: `$var`, literals, templates, and calls
1066 evaluate in memory and never touch disk. Read files explicitly
1067 first (`LET $text: STRING = READ "out.txt"`, then
1068 `ASSERT_EQ $text ...`).
1069 Bare `stdout` / `stderr` observe stream buffers; `pipe:NAME`
1070 observes a pipe buffer. `--hash` compares the SHA-256 of the
1071 actual's string bytes instead of the bytes themselves.
1072 "#},
1073 args: &[
1074 ArgSpec { name: "actual", arg_type: ArgType::Any, description: "Value, stdout, stderr, or pipe:NAME", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
1075 ArgSpec { name: "expected", arg_type: ArgType::Rest(&ArgType::Any), description: "Expected (required unless --hash)", io: IoDirection::Read, index: 1, required: false, fallback_stream: None },
1076 ],
1077 flags: &[ FlagSpec { name: "hash", long: "--hash", value_type: FlagValueType::String, required: false, description: "SHA-256" } ],
1078 default_output: None,
1079 examples: &[ Example { name: "assert eq", fence_meta: None, code: indoc! {r#"
1080 LET $status: INT = 200
1081 ASSERT_EQ $status 200
1082 "#} },
1083 Example { name: "assert eq file", fence_meta: None, code: indoc! {r#"
1084 WRITE payload.bin stable-content
1085 LET $body: STRING = READ payload.bin
1086 ASSERT_EQ $body "stable-content"
1087 "#} },
1088 Example { name: "assert eq hash", fence_meta: None, code: indoc! {r#"
1089 # --hash compares the SHA-256 digest instead of raw bytes
1090 WRITE payload.bin stable-content
1091 LET $body: STRING = READ payload.bin
1092 ASSERT_EQ --hash 08135c1b6349b0e4f894c36221952f0de00e6b4d82f80895abf359755e77103c $body
1093 "#} } ],
1094 lower: |flags, args| {
1095 let hash = flags.iter().find(|(k, _)| k == "hash").map(|(_, v)| v.as_str().to_string());
1096 let mut it = args.into_iter();
1097 let actual = lower_assert_target(it.next().ok_or_else(|| anyhow!("ASSERT_EQ requires a value"))?, "ASSERT_EQ")?;
1098 let remaining: Vec<Arg> = it
1099 .map(lower_assert_operand)
1100 .collect::<Vec<Arg>>();
1101 let expected = if remaining.is_empty() {
1104 if hash.is_some() {
1105 None
1106 } else {
1107 bail!("ASSERT_EQ requires an expected value");
1108 }
1109 } else {
1110 Some(join_value(remaining, "ASSERT_EQ")?)
1111 };
1112 Ok(StepKind::AssertEq { hash, actual, expected })
1113 },
1114 ],
1115
1116 AssertContains => [
1117 name: "ASSERT_CONTAINS",
1118 variant: AssertContains { haystack: AssertTarget, needle: Arg },
1119 syntax: "ASSERT_CONTAINS <haystack> <needle>",
1120 summary: "Assert containment.",
1121 description: indoc! {r#"
1122 Checks containment and aborts the pipeline with a step-numbered
1123 error otherwise: substring for strings, element match for lists,
1124 key presence for maps, substring over stream and pipe buffers.
1125
1126 Like `ASSERT_EQ`, both sides are values read without implicit
1127 I/O; read files explicitly first
1128 (`LET $text: STRING = READ "cfg.txt"`).
1129 Bare `stdout` / `stderr` observe stream buffers; `pipe:NAME`
1130 observes a pipe buffer.
1131 "#},
1132 args: &[
1133 ArgSpec { name: "haystack", arg_type: ArgType::Any, description: "Value, stdout, stderr, or pipe:NAME", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
1134 ArgSpec { name: "needle", arg_type: ArgType::Rest(&ArgType::Any), description: "Substring, element, or key", io: IoDirection::Read, index: 1, required: true, fallback_stream: None },
1135 ],
1136 flags: &[],
1137 default_output: None,
1138 examples: &[ Example { name: "assert contains", fence_meta: None, code: indoc! {r#"
1139 ECHO build-complete
1140 ASSERT_CONTAINS stdout "build-complete"
1141 "#} } ],
1142 lower: |flags, args| {
1143 let _ = flags;
1144 let mut it = args.into_iter();
1145 let haystack = lower_assert_target(it.next().ok_or_else(|| anyhow!("ASSERT_CONTAINS requires a value"))?, "ASSERT_CONTAINS")?;
1146 let remaining: Vec<Arg> = it
1147 .map(lower_assert_operand)
1148 .collect::<Vec<Arg>>();
1149 if remaining.is_empty() {
1150 bail!("ASSERT_CONTAINS requires a needle");
1151 }
1152 let needle = join_value(remaining, "ASSERT_CONTAINS")?;
1153 Ok(StepKind::AssertContains { haystack, needle })
1154 },
1155 ],
1156
1157 HashSha256 => [
1158 name: "HASH_SHA256",
1159 variant: HashSha256 { path: Arg },
1160 syntax: "HASH_SHA256 <path>",
1161 summary: "Print SHA-256.",
1162 description: "Computes digest.",
1163 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Read, index: 0, required: true, fallback_stream: None } ],
1164 flags: &[],
1165 default_output: Some(Stream::Stdout),
1166 examples: &[ Example { name: "hash", fence_meta: None, code: indoc! {r#"
1167 WRITE payload.txt hello
1168 HASH_SHA256 payload.txt
1169 "#} } ],
1170 lower: |_flags, args| Ok(StepKind::HashSha256 { path: args.into_iter().next().ok_or_else(|| anyhow!("HASH_SHA256 requires a path"))? }),
1171 ],
1172
1173 Exit => [
1174 name: "EXIT",
1175 variant: Exit(Arg),
1176 syntax: "EXIT <code>",
1177 summary: "Exit pipeline.",
1178 description: indoc! {r#"
1179 Stops the pipeline immediately with an `EXIT requested with code <code>`
1180 error; steps after it never run, at any nesting depth.
1181
1182 Enclosing blocks still unwind their LET/ENV/WORKDIR/WORKSPACE state,
1183 anonymous background tasks are killed synchronously, and files written
1184 before the EXIT persist.
1185 "#},
1186 args: &[ ArgSpec { name: "code", arg_type: ArgType::Int, description: "Code", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1187 flags: &[],
1188 default_output: None,
1189 examples: &[ Example { name: "exit", fence_meta: Some("expect_error:\"EXIT requested with code 0\""), code: indoc! {r#"EXIT 0"#} } ],
1190 lower: |_flags, args| {
1191 let code = args.into_iter().next().ok_or_else(|| anyhow!("EXIT requires a code"))?;
1194 Ok(StepKind::Exit(code))
1195 },
1196 ],
1197
1198 Sleep => [
1199 name: "SLEEP",
1200 variant: Sleep { duration: Arg },
1201 syntax: "SLEEP <duration>",
1202 summary: "Pause execution for a duration.",
1203 description: indoc! {r#"
1204 Parks the step for the duration (e.g. 500ms, 10s, 2m).
1205
1206 Cooperative: checks for cancellation so an enclosing TIMEOUT or task
1207 teardown interrupts the sleep. Cross-platform alternative to shell sleep
1208 for testing time boundaries.
1209 "#},
1210 args: &[ ArgSpec { name: "duration", arg_type: ArgType::Duration, description: "How long to sleep", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1211 flags: &[],
1212 default_output: None,
1213 examples: &[
1214 Example { name: "sleep", fence_meta: None, code: indoc! {r#"SLEEP 100ms"#} },
1215 Example {
1216 name: "sleep variable duration",
1217 fence_meta: None,
1218 code: indoc! {r#"
1219 # durations resolve at runtime, so variables work too —
1220 # quoted or bare, both bind the same string
1221 LET $pause: STRING = "100ms"
1222 SLEEP $pause
1223 LET $bare: STRING = 100ms
1224 SLEEP $bare
1225 "#},
1226 },
1227 ],
1228 lower: |_flags, args| {
1229 let mut it = args.into_iter();
1230 let raw = it
1231 .next()
1232 .ok_or_else(|| anyhow!("SLEEP requires a duration (e.g. SLEEP 500ms)"))?;
1233 if it.next().is_some() {
1234 bail!("SLEEP takes exactly one duration argument");
1235 }
1236 Ok(StepKind::Sleep { duration: raw })
1239 },
1240 ],
1241}
1242
1243pub fn all_structural_metadata() -> Vec<CommandMeta> {
1251 vec![
1252 CommandMeta {
1253 name: "WITH_IO",
1254 syntax: "WITH_IO [<stream>[=pipe:<name>|=$var], ...] <command> | WITH_IO [bindings] { <commands> }",
1255 summary: "Reroute standard streams.",
1256 description: indoc! {r#"
1257 Reroutes the standard streams of the next command or, in block form,
1258 of every enclosed command.
1259
1260 Bindings map streams (`stdin`, `stdout`, `stderr`) to named script
1261 pipes (`stdout=pipe:name`, `stderr=pipe:name`) or to a PIPE-typed
1262 variable (`stdin=$p`, resolved against the live pipe registry when
1263 the step runs). Both stdout and stderr pipes capture output the same way.
1264
1265 Pipes hold bytes in memory and spill to a temp file above 8 MiB, so a
1266 producer can finish before the consumer starts.
1267
1268 If WITH_IO wraps an ASYNC block whose body is a single RUN, guarded or
1269 not, the pipe is a zero copy OS kernel pipe instead: pair it with a
1270 consumer that runs while the producer is alive, since output past the
1271 64 KiB kernel buffer stalls until drained. That promotion never crosses
1272 a CALL boundary: pipes created, bound, or passed by variable inside FUNC
1273 bodies are always script pipes, even when the surrounding task would
1274 otherwise promote.
1275
1276 A second producer or consumer on a live name is an explicit error. A name
1277 bound as output can later feed another command's `stdin`, connecting
1278 commands without touching the terminal. Binding `stdout` and `stderr` to
1279 the same live pipe name fails deterministically. Merge streams in shell
1280 via `2>&1` instead.
1281
1282 Nested blocks stack defaults; inline bindings override inherited ones for
1283 their command only; closing a block restores previous wiring.
1284 "#},
1285 args: &[],
1286 flags: &[],
1287 default_output: None,
1288 examples: &[
1289 Example {
1290 name: "with_io block",
1291 fence_meta: None,
1292 code: indoc! {r#"
1293 WITH_IO [stdout=pipe:log] {
1294 ECHO first
1295 ECHO second
1296 }
1297 WITH_IO [stdin=pipe:log] WRITE captured.txt
1298 "#},
1299 },
1300 Example {
1301 name: "variable pipe binding",
1302 fence_meta: None,
1303 code: indoc! {r#"
1304 # Declare the pipe first with the explicit handle operator
1305 # (like `env:KEY`): `pipe:log` names a pipe without touching
1306 # a stream. A plain string here would be a TypeMismatch.
1307 # `$p` (not `pipe:$p`) is the variable form; literals stay
1308 # `pipe:name`.
1309 LET $p: PIPE = pipe:log
1310 WITH_IO [stdout=$p] ECHO hello
1311 WITH_IO [stdin=$p] READ_LINE $line
1312 ASSERT_EQ $line "hello"
1313 "#},
1314 },
1315 ],
1316 },
1317 CommandMeta {
1318 name: "FOR",
1319 syntax: "FOR $item: TYPE IN <expr> { <commands> } | FOR $key: STRING, $value: TYPE IN <expr> { <commands> }",
1320 summary: "Iterate over a list or map.",
1321 description: indoc! {r#"
1322 The loop variable receives each element (lists) or value (maps); with
1323 two variables, the first receives the key.
1324
1325 Loop variables are declared with explicit types and scoped per iteration;
1326 they do not leak outward. The body may be a braced block
1327 or a single-line `{ ... }` command.
1328
1329 `GLOB("...")` patterns must be quoted (`*` is not a bare word, so
1330 `GLOB(*)` is a parse error); GLOB returns a root-relative sorted list,
1331 empty when nothing matches, and rejects `..` escapes.
1332 "#},
1333 args: &[],
1334 flags: &[],
1335 default_output: None,
1336 examples: &[
1337 Example {
1338 name: "for loop",
1339 fence_meta: None,
1340 code: indoc! {r#"
1341 LET $items: LIST = ["a", "b"]
1342 FOR $item: STRING IN $items {
1343 ECHO $item
1344 }
1345
1346 LET $map: MAP = {"x": 1}
1347 FOR $k: STRING, $v: INT IN $map {
1348 ECHO "$k=$v"
1349 }
1350 "#},
1351 },
1352 Example {
1353 name: "expand every match",
1354 fence_meta: None,
1355 code: indoc! {r#"
1356 # single-line body; $x is a template path, WHO an override
1357 WRITE a.txt "hi \{{ env:WHO }}!"
1358 FOR $x: STRING IN GLOB("*.txt") { EXPAND $x WHO=World }
1359 ASSERT_CONTAINS stdout "hi World!"
1360 "#},
1361 },
1362 ],
1363 },
1364 CommandMeta {
1365 name: "IF",
1366 syntax: "IF <expr> { <commands> } [ELSE IF <expr> { <commands> }] [ELSE { <commands> }]",
1367 summary: "Conditional execution.",
1368 description: indoc! {r#"
1369 The condition is evaluated as a boolean expression.
1370
1371 Prefix `!` negates (`IF !false`); `&&` binds tighter than
1372 `||`, and both short-circuit, so `IF true || $missing`
1373 never evaluates the right side. Only Bool values are
1374 accepted as conditions.
1375 "#},
1376 args: &[],
1377 flags: &[],
1378 default_output: None,
1379 examples: &[
1380 Example {
1381 name: "if else",
1382 fence_meta: None,
1383 code: indoc! {r#"
1384 IF true {
1385 WRITE yes.txt taken
1386 } ELSE {
1387 WRITE yes.txt skipped
1388 }
1389
1390 IF false {
1391 WRITE skipped.txt no
1392 } ELSE IF true {
1393 WRITE fallback.txt taken
1394 }
1395
1396 # !false evaluates to true, so this branch runs.
1397 IF !false {
1398 WRITE negated.txt taken
1399 }
1400 LET $yes_body: STRING = READ yes.txt
1401 LET $fallback_body: STRING = READ fallback.txt
1402 LET $negated_body: STRING = READ negated.txt
1403 ASSERT_EQ $yes_body "taken"
1404 ASSERT_EQ $fallback_body "taken"
1405 ASSERT_EQ $negated_body "taken"
1406 LET $t: STRING = PATH_TYPE("skipped.txt")
1407 ASSERT_EQ $t "absent"
1408 "#},
1409 },
1410 Example {
1411 name: "logical condition composition",
1412 fence_meta: None,
1413 code: indoc! {r#"
1414 LET $role: STRING = "admin"
1415 LET $level: INT = 3
1416 # || is true when either side holds; && needs both.
1417 IF $role == "owner" || $level >= 5 {
1418 WRITE unexpected.txt no
1419 } ELSE {
1420 WRITE fallback.txt or-false
1421 }
1422 IF $role == "admin" || $level >= 5 {
1423 WRITE chosen.txt or-true
1424 }
1425 IF $role == "admin" && $level >= 5 {
1426 WRITE unexpected-too.txt no
1427 } ELSE {
1428 WRITE and.txt and-false
1429 }
1430 LET $fb: STRING = READ fallback.txt
1431 LET $ch: STRING = READ chosen.txt
1432 LET $an: STRING = READ and.txt
1433 ASSERT_EQ $fb "or-false"
1434 ASSERT_EQ $ch "or-true"
1435 ASSERT_EQ $an "and-false"
1436 LET $t1: STRING = PATH_TYPE("unexpected.txt")
1437 LET $t2: STRING = PATH_TYPE("unexpected-too.txt")
1438 ASSERT_EQ $t1 "absent"
1439 ASSERT_EQ $t2 "absent"
1440 "#},
1441 },
1442 ],
1443 },
1444 CommandMeta {
1445 name: "LET",
1446 syntax: "LET $var: TYPE = <expr> | LET $var: TYPE = ASYNC { <commands> } | LET $var: TYPE = <command> | LET $var: TYPE = AWAIT $task",
1447 summary: "Bind script-local variables.",
1448 description: indoc! {r#"
1449 Declares a script-local variable with an explicit type (STRING, INT,
1450 FLOAT, BOOL, PIPE, LIST, MAP, HANDLE, DURATION, PATH). Duplicate LET
1451 in the same scope frame is a redeclaration error; mutate with
1452 `$var = <expr>`.
1453
1454 Variables are usable in templates (`{{ $var }}`), guards, and
1455 expressions. With `ASYNC`, spawns a background task and stores its
1456 handle (see ASYNC). The `$` sigil on the name is mandatory.
1457
1458 The right-hand side is always an expression — literals, lists, maps,
1459 arithmetic (`+ - * /` with `*`/`/` binding tighter, unary `-`,
1460 parentheses), comparisons (`< <= > >=` binding tighter than
1461 `== !=`), logical `&&` (tighter) and `||` with short-circuit,
1462 `!` negation, `env:KEY` reads, `pipe:NAME` handles,
1463 `INSPECT($var)` snapshots, `GLOB("*.md")`, `INT(x)` /
1464 `FLOAT(x)` conversions — never a `{{ ... }}` template;
1465 interpolation happens in string values, not here.
1466
1467 Numbers are numeric literals: `42` binds `INT`, `3.14` binds
1468 `FLOAT`. `Int x Int` stays `INT` (checked, integer division,
1469 so `7 / 2` is `3`); any `Float` operand promotes to `FLOAT`.
1470 Division by zero, overflow, and non-finite results are errors.
1471 Both numeric sides compare numerically (`1 == 1.0` is true);
1472 otherwise `==`/`!=` compare rendered strings and ordering on
1473 non-numerics is a Type Error. Constant subtrees fold at parse
1474 time and dynamic arithmetic compiles to flat RPN with
1475 identical semantics.
1476
1477 Float equality is exact with no epsilon. Floats store decimals
1478 in binary, so a value is exact only when its reduced fraction
1479 has a power-of-2 denominator: 0.5 (1/2), 0.25 (1/4), 0.75
1480 (3/4) are exact, while 0.1 (1/10), 0.2 (1/5), 0.3 (3/10)
1481 repeat forever in binary (like 1/3 in decimal) and truncate,
1482 so `0.1 + 0.2 == 0.3` is false (the sum is
1483 `0.30000000000000004`). Rule of thumb: endings .5, .25, .75,
1484 .125, .625, .875 are exact; .1, .2, .3 and similar are
1485 approximations. Bound approximations instead of comparing
1486 them: `IF $sum > 0.299999 && $sum < 0.300001`.
1487
1488 Comparisons do not chain: `a < b < c` is a parse error, not
1489 `(a < b) < c`. Chaining would compare a `BOOL` against a
1490 number (a runtime Type Error in C-style parsing) or evaluate
1491 the middle term twice (Python-style chaining), so the grammar
1492 accepts exactly one comparison operator per level. Write the
1493 conjunction explicitly: `$a < $b && $b < $c`. The same holds
1494 for equality (`$a == $b == $c` is rejected).
1495
1496 Captured command output is a string, so convert before math:
1497 `LET $total: INT = $total + INT($size_str)` (`INT` trims ASCII
1498 whitespace; `FLOAT` accepts int strings and rejects
1499 non-finite).
1500
1501 Bare words need no quotes: `LET $d: STRING = 30s` binds the same string
1502 as quoted.
1503
1504 When the right-hand side is a synchronous command
1505 (`LET $out: STRING = ECHO hi`), the command runs to completion and its
1506 exact stdout bytes are captured into the variable as a string (no newline
1507 stripping; commands with no stdout capture as `""`; non-UTF8 stdout is
1508 an error). Combining capture with an explicit
1509 `WITH_IO [stdout=pipe:...]` is a parse error.
1510
1511 Coming from Bash, the capture line looks familiar but behaves
1512 strictly:
1513
1514 | | Bash `output=$(...)` | OxDock `LET $out: STRING = ...` |
1515 | --- | --- | --- |
1516 | Trailing newlines | Stripped (all of them) | Preserved byte-exact |
1517 | Variable type | Always an untyped string | Declared: STRING, INT, FLOAT, ... |
1518 | Math on output | Implicit: `$((var + 1))` | Explicit: `INT($out) + 1` |
1519 | Failing command | Continues with empty output unless `set -e` | Step fails immediately, binds nothing |
1520
1521 `LET $out: STRING = AWAIT $var` captures a background task's stdout the
1522 same way; bare `AWAIT $var` forwards it to the parent stdout instead.
1523
1524 `LET $e: STRING = env:FOO` reads the script environment into a plain
1525 string.
1526 "#},
1527 args: &[],
1528 flags: &[],
1529 default_output: None,
1530 examples: &[
1531 Example {
1532 name: "let",
1533 fence_meta: None,
1534 code: indoc! {r#"
1535 LET $name: STRING = "world"
1536 ECHO "hello, {{ $name }}"
1537
1538 LET $items: LIST = ["a", "b"]
1539 LET $count: INT = 42
1540 "#},
1541 },
1542 Example {
1543 name: "glob binding",
1544 fence_meta: None,
1545 code: indoc! {r#"
1546 # the RHS is an expression: GLOB(...) runs and binds a list
1547 WRITE a.txt "x"
1548 LET $files: LIST = GLOB("*.txt")
1549 FOR $f: STRING IN $files { ECHO $f }
1550 ASSERT_CONTAINS stdout "a.txt"
1551 "#},
1552 },
1553 Example {
1554 name: "scoped variable reverts",
1555 fence_meta: None,
1556 code: indoc! {r#"
1557 # LET inside a braced block reverts when the block exits
1558 LET $a: STRING = "outer"
1559 [bool:true] {
1560 LET $a: STRING = "inner"
1561 WRITE inner.txt "{{ $a }}"
1562 }
1563 WRITE outer.txt "{{ $a }}"
1564 LET $in_body: STRING = READ inner.txt
1565 LET $out_body: STRING = READ outer.txt
1566 ASSERT_EQ $in_body "inner"
1567 ASSERT_EQ $out_body "outer"
1568 "#},
1569 },
1570 Example {
1571 name: "capture command output",
1572 fence_meta: None,
1573 code: indoc! {r#"
1574 LET $out: STRING = ECHO hi
1575 ASSERT_EQ $out "hi\n"
1576 "#},
1577 },
1578 Example {
1579 name: "arithmetic over captured output",
1580 fence_meta: None,
1581 code: indoc! {r#"
1582 LET $size_str: STRING = ECHO 41
1583 LET $total: INT = INT($size_str) + 1
1584 LET $ratio: FLOAT = 1 + 2.5
1585 # Int x Int stays INT: integer division truncates.
1586 LET $half: INT = 7 / 2
1587 ASSERT_EQ $total 42
1588 ASSERT_EQ $ratio 3.5
1589 ASSERT_EQ $half 3
1590 "#},
1591 },
1592 Example {
1593 name: "float equality is exact",
1594 fence_meta: None,
1595 code: indoc! {r#"
1596 # Binary fractions compare cleanly; decimal fractions may not:
1597 # 0.1 + 0.2 is 0.30000000000000004, so == is false.
1598 LET $exact: BOOL = 0.5 + 0.25 == 0.75
1599 LET $decimal: BOOL = 0.1 + 0.2 == 0.3
1600 IF $exact {
1601 WRITE exact.txt yes
1602 }
1603 IF $decimal {
1604 WRITE unexpected.txt no
1605 }
1606 LET $ok: STRING = READ exact.txt
1607 ASSERT_EQ $ok "yes"
1608 LET $t: STRING = PATH_TYPE("unexpected.txt")
1609 ASSERT_EQ $t "absent"
1610 "#},
1611 },
1612 Example {
1613 name: "bound inexact decimals",
1614 fence_meta: None,
1615 code: indoc! {r#"
1616 # Never test inexact decimals for equality; bound them.
1617 LET $sum: FLOAT = 0.1 + 0.2
1618 IF $sum > 0.299999 && $sum < 0.300001 {
1619 WRITE bounded.txt yes
1620 }
1621 LET $ok: STRING = READ bounded.txt
1622 ASSERT_EQ $ok "yes"
1623 "#},
1624 },
1625 Example {
1626 name: "inspect a variable",
1627 fence_meta: None,
1628 code: indoc! {r#"
1629 # INSPECT($var) snapshots a variable into a MAP: declared
1630 # type plus live details (pipe backend stats here), so
1631 # scripts can branch on engine state.
1632 LET $p: PIPE = pipe:log
1633 WITH_IO [stdout=$p] ECHO hello
1634 LET $info: MAP = INSPECT($p)
1635 IF $info.is_os_pipe {
1636 WRITE unexpected.txt "should be a script pipe"
1637 }
1638 ASSERT_EQ $info.type "PIPE"
1639 "#},
1640 },
1641 ],
1642 },
1643 CommandMeta {
1644 name: "MUTATION",
1645 syntax: "$var = <expr>",
1646 summary: "Mutate a declared variable.",
1647 description: indoc! {r#"
1648 Reassigns an existing variable, converting the new value to
1649 the type declared at LET time. The explicit annotation is
1650 what authorizes string-to-number conversion here (`$n = "42"`
1651 binds 42 for an INT); a non-numeric string is an error.
1652 Expressions never convert: `"100" + 1` is a Type Error, use
1653 `INT()` / `FLOAT()` to cross that boundary explicitly.
1654
1655 The leading `$` distinguishes mutation from `KEY=value` command
1656 assignments. Assigning an undeclared variable or a mismatched type is
1657 an error.
1658
1659 Mutation writes through to the scope where the variable was
1660 declared, so it survives block exit: `LET $x` outside a block
1661 followed by `$x = ...` inside still reads back the new value
1662 afterwards, for every type. This is the counterpart to LET
1663 shadowing, where `LET $x` *inside* the block declares a
1664 separate inner variable that reverts on exit.
1665 "#},
1666 args: &[],
1667 flags: &[],
1668 default_output: None,
1669 examples: &[
1670 Example {
1671 name: "mutate",
1672 fence_meta: None,
1673 code: indoc! {r#"
1674 LET $count: INT = 1
1675 $count = 2
1676 ASSERT_EQ $count 2
1677 "#},
1678 },
1679 Example {
1680 name: "convert before math",
1681 fence_meta: None,
1682 code: indoc! {r#"
1683 # Captured output is a string: `"100" + 1` is a Type Error.
1684 # Convert explicitly, then mutate with arithmetic.
1685 LET $raw: STRING = ECHO 100
1686 LET $n: INT = INT($raw)
1687 $n = $n + 1
1688 # The declared type also converts plain strings on assignment.
1689 $n = "42"
1690 # Same crossing for decimals via FLOAT().
1691 LET $frac_str: STRING = ECHO 2.5
1692 LET $f: FLOAT = FLOAT($frac_str) + 0.25
1693 ASSERT_EQ $n 42
1694 ASSERT_EQ $f 2.75
1695 "#},
1696 },
1697 ],
1698 },
1699 CommandMeta {
1700 name: "ASYNC",
1701 syntax: "ASYNC <command...> | ASYNC { <commands> } | LET $var: HANDLE = ASYNC { <commands> }",
1702 summary: "Run steps in a background thread.",
1703 description: indoc! {r#"
1704 Runs a command or block of commands in a background thread with
1705 subshell isolation.
1706
1707 Mutations (ENV, WORKDIR) stay within the block. With `LET`, stores a
1708 task handle for `AWAIT`.
1709 "#},
1710 args: &[],
1711 flags: &[],
1712 default_output: None,
1713 examples: &[
1714 Example {
1715 name: "async",
1716 fence_meta: None,
1717 code: indoc! {r#"
1718 ASYNC ECHO "first"
1719
1720 ASYNC {
1721 ECHO "first"
1722 ECHO "second"
1723 }
1724 "#},
1725 },
1726 Example {
1727 name: "async task handle",
1728 fence_meta: None,
1729 code: indoc! {r#"
1730 LET $task: HANDLE = ASYNC {
1731 ECHO "built"
1732 }
1733 AWAIT $task
1734 "#},
1735 },
1736 ],
1737 },
1738 CommandMeta {
1739 name: "AWAIT",
1740 syntax: "AWAIT $var | LET $out: STRING = AWAIT $var",
1741 summary: "Join a background task.",
1742 description: indoc! {r#"
1743 Blocks until the named task completes. Propagates errors if the task failed.
1744
1745 Bare `AWAIT $var` forwards the task's stdout to the parent stdout;
1746 `LET $out: STRING = AWAIT $var` captures it into `$out` instead (same
1747 UTF-8 and spilling rules as `LET $var: STRING = <command>`).
1748 "#},
1749 args: &[],
1750 flags: &[],
1751 default_output: None,
1752 examples: &[
1753 Example {
1754 name: "await",
1755 fence_meta: None,
1756 code: indoc! {r#"
1757 LET $task: HANDLE = ASYNC ECHO "done"
1758 AWAIT $task
1759 "#},
1760 },
1761 Example {
1762 name: "await capture",
1763 fence_meta: None,
1764 code: indoc! {r#"
1765 LET $task: HANDLE = ASYNC ECHO "done"
1766 LET $out: STRING = AWAIT $task
1767 ASSERT_EQ $out "done\n"
1768 "#},
1769 },
1770 ],
1771 },
1772 CommandMeta {
1773 name: "CANCEL",
1774 syntax: "CANCEL $var",
1775 summary: "Synchronously cancel a background task.",
1776 description: indoc! {r#"
1777 Kills the named background task spawned via LET $var: HANDLE = ASYNC ....
1778
1779 Blocking: returns only after the task thread has been joined and its OS
1780 process reaped, so no residual filesystem or stream mutation follows. A
1781 later AWAIT $var reports cancellation. Only named tasks can be cancelled.
1782 "#},
1783 args: &[],
1784 flags: &[],
1785 default_output: None,
1786 examples: &[Example {
1787 name: "cancel",
1788 fence_meta: None,
1789 code: indoc! {r#"
1790 LET $task: HANDLE = ASYNC SLEEP 30s
1791 CANCEL $task
1792 "#},
1793 }],
1794 },
1795 CommandMeta {
1796 name: "TIMEOUT",
1797 syntax: "TIMEOUT <duration> <command...> | TIMEOUT <duration> { <commands> } | TIMEOUT <duration> AWAIT $var",
1798 summary: "Enforce an execution deadline.",
1799 description: indoc! {r#"
1800 Aborts the wrapped step or block with a deadline error if it exceeds the
1801 duration (e.g. 500ms, 10s, 2m; a bare number means seconds).
1802
1803 A blocking foreground process is killed.
1804 "#},
1805 args: &[],
1806 flags: &[],
1807 default_output: None,
1808 examples: &[
1809 Example {
1810 name: "timeout",
1811 fence_meta: None,
1812 code: indoc! {r#"TIMEOUT 30s WRITE heartbeat.txt alive"#},
1813 },
1814 Example {
1815 name: "timeout block",
1816 fence_meta: None,
1817 code: indoc! {r#"
1818 TIMEOUT 30s {
1819 WRITE a.txt one
1820 WRITE b.txt two
1821 }
1822 "#},
1823 },
1824 Example {
1825 name: "timeout variable duration",
1826 fence_meta: None,
1827 code: indoc! {r#"
1828 # durations resolve at runtime, so variables work too
1829 LET $budget: DURATION = "30s"
1830 TIMEOUT $budget WRITE heartbeat.txt alive
1831 LET $beat: STRING = READ heartbeat.txt
1832 ASSERT_EQ $beat "alive"
1833 "#},
1834 },
1835 ],
1836 },
1837 CommandMeta {
1838 name: "FUNC",
1839 syntax: "FUNC NAME($param: TYPE, ...) { <commands> }",
1840 summary: "Define a user function.",
1841 description: indoc! {r#"
1842 Defines a user function with UPPERCASE name and explicitly typed
1843 parameters.
1844
1845 Params bind by position, converting each argument to its
1846 declared parameter type before the body runs.
1847 Bodies run in a fresh variable scope; LETs inside do not leak. A nested
1848 FUNC definition is scoped to its block and reverts on exit. Names share
1849 one namespace with host-registered functions.
1850 "#},
1851 args: &[],
1852 flags: &[],
1853 default_output: None,
1854 examples: &[Example {
1855 name: "func def call",
1856 fence_meta: None,
1857 code: indoc! {r#"
1858 FUNC GREET($name: STRING) {
1859 RETURN $name
1860 }
1861 LET $res: STRING = CALL GREET("ada")
1862 ASSERT_EQ $res "ada"
1863 "#},
1864 }],
1865 },
1866 CommandMeta {
1867 name: "CALL",
1868 syntax: "CALL NAME(<expr>, ...) | LET $var: TYPE = CALL NAME(<expr>, ...)",
1869 summary: "Invoke a user or host function.",
1870 description: indoc! {r#"
1871 Invokes a FUNC-defined or host-registered function by UPPERCASE name.
1872
1873 Bare CALL discards the return value and keeps stdout side effects.
1874 LET $var: TYPE = CALL captures the RETURN value (fallthrough without
1875 RETURN captures as ""), coerced to the declared type; stdout inside the
1876 callee stays observable via ASSERT_CONTAINS stdout and pipes.
1877
1878 Combining LET-capture with WITH_IO [stdout=pipe:...] is a parse error.
1879 "#},
1880 args: &[],
1881 flags: &[],
1882 default_output: None,
1883 examples: &[
1884 Example {
1885 name: "call",
1886 fence_meta: None,
1887 code: indoc! {r#"
1888 FUNC SHOUT($name: STRING) {
1889 ECHO "{{ $name }}"
1890 RETURN $name
1891 }
1892 CALL SHOUT("ada")
1893 ASSERT_CONTAINS stdout "ada"
1894 "#},
1895 },
1896 Example {
1897 name: "call with pipes",
1898 fence_meta: None,
1899 code: indoc! {r#"
1900 # A pipe handle travels into a function as a typed argument
1901 # and is usable as a binding target in both directions.
1902 # `pipe:ch` constructs the handle; `$p` passes it on.
1903 FUNC DRAIN($q: PIPE) {
1904 WITH_IO [stdin=$q] READ_LINE $line
1905 RETURN $line
1906 }
1907 LET $p: PIPE = pipe:ch
1908 WITH_IO [stdout=$p] ECHO "payload"
1909 LET $got: STRING = CALL DRAIN($p)
1910 ASSERT_EQ $got "payload"
1911 "#},
1912 },
1913 ],
1914 },
1915 CommandMeta {
1916 name: "RETURN",
1917 syntax: "RETURN <expr>",
1918 summary: "Return a value from a function.",
1919 description: indoc! {r#"
1920 Ends the nearest enclosing function call with a value.
1921
1922 Falling off the end without RETURN yields "". RETURN outside a function
1923 (including at top level or across an ASYNC boundary) is an error.
1924 "#},
1925 args: &[],
1926 flags: &[],
1927 default_output: None,
1928 examples: &[Example {
1929 name: "return",
1930 fence_meta: None,
1931 code: indoc! {r#"
1932 FUNC PICK($flag: BOOL) {
1933 IF $flag {
1934 RETURN "yes"
1935 }
1936 RETURN "no"
1937 }
1938 LET $res: STRING = CALL PICK(true)
1939 ASSERT_EQ $res "yes"
1940 "#},
1941 }],
1942 },
1943 CommandMeta {
1944 name: "WHILE",
1945 syntax: "WHILE <bool-expr> { <commands> }",
1946 summary: "Loop while a condition holds.",
1947 description: indoc! {r#"
1948 Re-evaluates a Bool condition each iteration (same is_truthy rule as IF;
1949 non-Bool is a type error).
1950
1951 Each iteration runs in a fresh scope; mutate outer state with $var = ...
1952 so the next check observes it. BREAK exits the loop; CONTINUE skips to
1953 the next check.
1954 "#},
1955 args: &[],
1956 flags: &[],
1957 default_output: None,
1958 examples: &[Example {
1959 name: "while loop",
1960 fence_meta: None,
1961 code: indoc! {r#"
1962 LET $done: BOOL = false
1963 WHILE !$done {
1964 WRITE tick.txt "once"
1965 $done = true
1966 }
1967 LET $tick: STRING = READ tick.txt
1968 ASSERT_EQ $tick "once"
1969 "#},
1970 }],
1971 },
1972 CommandMeta {
1973 name: "BREAK",
1974 syntax: "BREAK",
1975 summary: "Exit the innermost loop.",
1976 description: indoc! {r#"
1977 Exits the innermost enclosing FOR or WHILE loop.
1978
1979 BREAK outside a loop, or across a FUNC or ASYNC boundary, is an error.
1980 "#},
1981 args: &[],
1982 flags: &[],
1983 default_output: None,
1984 examples: &[Example {
1985 name: "break",
1986 fence_meta: None,
1987 code: indoc! {r#"
1988 FOR $x: STRING IN ["a", "b"] {
1989 BREAK
1990 }
1991 "#},
1992 }],
1993 },
1994 CommandMeta {
1995 name: "CONTINUE",
1996 syntax: "CONTINUE",
1997 summary: "Skip to the next loop iteration.",
1998 description: indoc! {r#"
1999 Skips the rest of the innermost enclosing FOR or WHILE body and starts
2000 the next iteration.
2001
2002 CONTINUE outside a loop, or across a FUNC or ASYNC boundary, is an error.
2003 "#},
2004 args: &[],
2005 flags: &[],
2006 default_output: None,
2007 examples: &[Example {
2008 name: "continue",
2009 fence_meta: None,
2010 code: indoc! {r#"
2011 FOR $x: STRING IN ["a", "b"] {
2012 CONTINUE
2013 }
2014 "#},
2015 }],
2016 },
2017 ]
2018}
2019
2020impl fmt::Display for StepKind {
2023 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2024 match self {
2025 StepKind::InheritEnv { keys } => write!(f, "INHERIT_ENV [{}]", keys.join(", ")),
2026 StepKind::Workdir(a) => write!(f, "WORKDIR {}", fmt_value(a, quote_arg)),
2027 StepKind::Workspace(t) => write!(f, "WORKSPACE {}", t),
2028 StepKind::Env { key, value } => {
2029 write!(f, "ENV {}={}", key, fmt_value(value, quote_arg))
2030 }
2031 StepKind::Run(c) => write!(f, "RUN {}", fmt_value(c, quote_run)),
2032 StepKind::RunExec { argv } => {
2033 let parts: Vec<String> = argv.iter().map(fmt_exec_arg).collect();
2034 write!(f, "RUN [{}]", parts.join(", "))
2035 }
2036 StepKind::Echo(m) => write!(f, "ECHO {}", fmt_value(m, quote_msg)),
2037 StepKind::Copy {
2038 from_current_workspace,
2039 from,
2040 to,
2041 } => {
2042 if *from_current_workspace {
2043 write!(
2044 f,
2045 "COPY --from-current-workspace {} {}",
2046 fmt_value(from, quote_arg),
2047 fmt_value(to, quote_arg)
2048 )
2049 } else {
2050 write!(
2051 f,
2052 "COPY {} {}",
2053 fmt_value(from, quote_arg),
2054 fmt_value(to, quote_arg)
2055 )
2056 }
2057 }
2058 StepKind::Symlink { from, to } => write!(
2059 f,
2060 "SYMLINK {} {}",
2061 fmt_value(from, quote_arg),
2062 fmt_value(to, quote_arg)
2063 ),
2064 StepKind::Mkdir(a) => write!(f, "MKDIR {}", fmt_value(a, quote_arg)),
2065 StepKind::Ls(a) => {
2066 write!(f, "LS")?;
2067 if let Some(x) = a {
2068 write!(f, " {}", fmt_value(x, quote_arg))?;
2069 }
2070 Ok(())
2071 }
2072 StepKind::Cwd => write!(f, "CWD"),
2073 StepKind::Read(a) => {
2074 write!(f, "READ")?;
2075 if let Some(x) = a {
2076 write!(f, " {}", fmt_value(x, quote_arg))?;
2077 }
2078 Ok(())
2079 }
2080 StepKind::ReadLine { var } => write!(f, "READ_LINE ${}", var),
2081 StepKind::Write { path, contents } => {
2082 write!(f, "WRITE {}", fmt_value(path, quote_arg))?;
2083 if let Some(b) = contents {
2084 write!(f, " {}", fmt_value(b, quote_msg))?;
2085 }
2086 Ok(())
2087 }
2088 StepKind::Append { path, contents } => {
2089 write!(f, "APPEND {}", fmt_value(path, quote_arg))?;
2090 if let Some(b) = contents {
2091 write!(f, " {}", fmt_value(b, quote_msg))?;
2092 }
2093 Ok(())
2094 }
2095 StepKind::Expand { path, overrides } => {
2096 write!(f, "EXPAND")?;
2097 if let Some(p) = path {
2098 write!(f, " {}", fmt_value(p, quote_arg))?;
2099 }
2100 for (k, v) in overrides {
2101 write!(f, " {}={}", k, fmt_value(v, quote_arg))?;
2102 }
2103 Ok(())
2104 }
2105 StepKind::AssertEq {
2106 hash,
2107 actual,
2108 expected,
2109 } => {
2110 if let Some(d) = hash {
2111 write!(f, "ASSERT_EQ --hash {d} {}", fmt_assert_target(actual))?;
2112 } else {
2113 write!(
2114 f,
2115 "ASSERT_EQ {} {}",
2116 fmt_assert_target(actual),
2117 fmt_value(
2118 expected
2119 .as_ref()
2120 .expect("Display of ASSERT_EQ without --hash needs expected"),
2121 quote_msg
2122 )
2123 )?;
2124 }
2125 Ok(())
2126 }
2127 StepKind::AssertContains { haystack, needle } => write!(
2128 f,
2129 "ASSERT_CONTAINS {} {}",
2130 fmt_assert_target(haystack),
2131 fmt_value(needle, quote_msg)
2132 ),
2133 StepKind::WithIo { bindings, cmd } => {
2134 let p: Vec<String> = bindings.iter().map(fmt_io).collect();
2135 write!(f, "WITH_IO [{}] {}", p.join(", "), cmd)
2136 }
2137 StepKind::WithIoBlock { bindings } => {
2138 let p: Vec<String> = bindings.iter().map(fmt_io).collect();
2139 write!(f, "WITH_IO [{}] {{...}}", p.join(", "))
2140 }
2141 StepKind::CopyGit {
2142 rev,
2143 from,
2144 to,
2145 include_dirty,
2146 } => {
2147 if *include_dirty {
2148 write!(
2149 f,
2150 "COPY_GIT --include-dirty {} {} {}",
2151 fmt_value(rev, quote_arg),
2152 fmt_value(from, quote_arg),
2153 fmt_value(to, quote_arg)
2154 )
2155 } else {
2156 write!(
2157 f,
2158 "COPY_GIT {} {} {}",
2159 fmt_value(rev, quote_arg),
2160 fmt_value(from, quote_arg),
2161 fmt_value(to, quote_arg)
2162 )
2163 }
2164 }
2165 StepKind::HashSha256 { path } => {
2166 write!(f, "HASH_SHA256 {}", fmt_value(path, quote_arg))
2167 }
2168 StepKind::Exit(code) => write!(f, "EXIT {}", fmt_raw_arg(code)),
2169 StepKind::Sleep { duration } => write!(f, "SLEEP {}", fmt_raw_arg(duration)),
2170 StepKind::For {
2171 key_var,
2172 key_type,
2173 var,
2174 var_type,
2175 in_expr,
2176 body,
2177 } => {
2178 match key_var {
2179 Some(k) => {
2180 let kt = key_type.as_ref().map(|t| t.label()).unwrap_or("STRING");
2181 write!(
2182 f,
2183 "FOR ${}: {}, ${}: {} IN {} {{",
2184 k, kt, var, var_type, in_expr
2185 )?
2186 }
2187 None => write!(f, "FOR ${}: {} IN {} {{", var, var_type, in_expr)?,
2188 }
2189 for s in body {
2190 write!(f, "\n {}", s)?;
2191 }
2192 write!(f, "\n}}")
2193 }
2194 StepKind::If {
2195 cond,
2196 then_body,
2197 else_ifs,
2198 else_body,
2199 } => {
2200 write!(f, "IF {} {{", cond)?;
2201 for s in then_body {
2202 write!(f, "\n {}", s)?;
2203 }
2204 write!(f, " }}")?;
2205 for (c, b) in else_ifs {
2206 write!(f, " ELSE IF {} {{", c)?;
2207 for s in b {
2208 write!(f, "\n {}", s)?;
2209 }
2210 write!(f, " }}")?;
2211 }
2212 if let Some(b) = else_body {
2213 write!(f, " ELSE {{")?;
2214 for s in b {
2215 write!(f, "\n {}", s)?;
2216 }
2217 write!(f, " }}")?;
2218 }
2219 Ok(())
2220 }
2221 StepKind::Assign {
2222 var,
2223 decl_type,
2224 expr,
2225 } => {
2226 write!(f, "LET ${}: {} = {}", var, decl_type, expr)
2227 }
2228 StepKind::Set { var, expr } => write!(f, "${} = {}", var, expr),
2229 StepKind::AssignCapture {
2230 var,
2231 decl_type,
2232 cmd,
2233 } => {
2234 write!(f, "LET ${}: {} = {}", var, decl_type, cmd)
2235 }
2236 StepKind::AsyncBlock { body } => {
2237 write!(f, "ASYNC {{")?;
2238 for s in body {
2239 write!(f, "\n {}", s)?;
2240 }
2241 write!(f, "\n}}")
2242 }
2243 StepKind::AssignAsync {
2244 var,
2245 decl_type,
2246 body,
2247 } => {
2248 write!(f, "LET ${}: {} = ASYNC {{", var, decl_type)?;
2249 for s in body {
2250 write!(f, "\n {}", s)?;
2251 }
2252 write!(f, "\n}}")
2253 }
2254 StepKind::Await { var } => write!(f, "AWAIT ${}", var),
2255 StepKind::AwaitCapture {
2256 out_var,
2257 out_type,
2258 task_var,
2259 } => {
2260 write!(f, "LET ${}: {} = AWAIT ${}", out_var, out_type, task_var)
2261 }
2262 StepKind::Cancel { var } => write!(f, "CANCEL ${}", var),
2263 StepKind::Timeout { duration, body } => {
2264 let budget = fmt_raw_arg(duration);
2265 if body.len() == 1 {
2266 write!(f, "TIMEOUT {} {}", budget, body[0].kind)
2267 } else {
2268 write!(f, "TIMEOUT {} {{", budget)?;
2269 for s in body {
2270 write!(f, "\n {}", s)?;
2271 }
2272 write!(f, "\n}}")
2273 }
2274 }
2275 StepKind::FuncDef { name, params, body } => {
2276 let ps: Vec<String> = params
2277 .iter()
2278 .map(|(p, t)| format!("${}: {}", p, t))
2279 .collect();
2280 write!(f, "FUNC {}({}) {{", name, ps.join(", "))?;
2281 for s in body {
2282 write!(f, "\n {}", s)?;
2283 }
2284 write!(f, "\n}}")
2285 }
2286 StepKind::Call { name, args } => {
2287 let ps: Vec<String> = args.iter().map(|a| format!("{}", a)).collect();
2288 write!(f, "CALL {}({})", name, ps.join(", "))
2289 }
2290 StepKind::Return { expr } => write!(f, "RETURN {}", expr),
2291 StepKind::While { cond, body } => {
2292 write!(f, "WHILE {} {{", cond)?;
2293 for s in body {
2294 write!(f, "\n {}", s)?;
2295 }
2296 write!(f, "\n}}")
2297 }
2298 StepKind::Break => write!(f, "BREAK"),
2299 StepKind::Continue => write!(f, "CONTINUE"),
2300 }
2301 }
2302}
2303
2304#[cfg(test)]
2305mod tests {
2306 use super::*;
2307 use crate::command::{format_duration, parse_duration};
2308 use crate::parser::parse_script;
2309
2310 fn parse_err(script: &str) -> String {
2311 parse_script(script, lower_command)
2312 .expect_err("script must fail to parse")
2313 .to_string()
2314 }
2315
2316 #[test]
2317 fn malformed_with_io_binding_names_the_bad_binding() {
2318 let err = parse_err("WITH_IO [stdout=discard] ECHO \"test\"\n");
2319 assert!(err.contains("invalid syntax for command WITH_IO"), "{err}");
2320 assert!(!err.contains("unknown command"), "{err}");
2321 assert!(err.contains("stdout=discard"), "{err}");
2322 assert!(err.contains("pipe:<name>"), "{err}");
2323 }
2324
2325 #[test]
2326 fn await_without_task_variable_points_at_syntax() {
2327 let err = parse_err("AWAIT ECHO \"test\"\n");
2328 assert!(err.contains("invalid syntax for command AWAIT"), "{err}");
2329 assert!(!err.contains("unknown command"), "{err}");
2330 assert!(err.contains("AWAIT $t"), "{err}");
2331 assert!(err.contains("ECHO"), "{err}");
2332 }
2333
2334 #[test]
2335 fn bare_let_without_type_points_at_typed_syntax() {
2336 let err = parse_err("LET $x = 1\n");
2337 assert!(err.contains("invalid syntax for command LET"), "{err}");
2338 assert!(err.contains("LET $name: STRING = <expr>"), "{err}");
2339 }
2340
2341 #[test]
2342 fn unknown_type_tag_names_valid_inventory() {
2343 let err = parse_err("LET $x: FOO = 1\n");
2344 assert!(err.contains("unknown type `FOO`"), "{err}");
2345 assert!(err.contains("STRING"), "{err}");
2346 }
2347
2348 #[test]
2349 fn bare_for_without_types_is_rejected() {
2350 let err = parse_err("FOR $i IN [1] { ECHO hi }\n");
2351 assert!(err.contains("FOR requires explicit types"), "{err}");
2352 }
2353
2354 #[test]
2355 fn mutate_statement_parses_without_keyword() {
2356 let steps = parse_script("$y = 2\n", lower_command).expect("mutation parses");
2357 assert!(matches!(steps[0].kind, StepKind::Set { .. }));
2358 }
2359
2360 #[test]
2361 fn set_keyword_is_rejected_with_mutation_hint() {
2362 let err = parse_err("SET $y = 2\n");
2363 assert!(err.contains("not a keyword"), "{err}");
2364 assert!(err.contains("$var = <expr>"), "{err}");
2365 }
2366
2367 #[test]
2368 fn structural_fallthrough_commits_per_keyword() {
2369 for (script, cmd) in [
2370 ("CANCEL foo\n", "CANCEL"),
2371 ("TIMEOUT foo\n", "TIMEOUT"),
2372 ("FOR foo\n", "FOR"),
2373 ("IF foo\n", "IF"),
2374 ("LET foo\n", "LET"),
2375 ("ASYNC\n", "ASYNC"),
2379 ("ELSE foo\n", "ELSE"),
2380 ] {
2381 let err = parse_err(script);
2382 assert!(
2383 err.contains(&format!("invalid syntax for command {cmd}")),
2384 "{cmd}: {err}"
2385 );
2386 assert!(!err.contains("unknown command"), "{cmd}: {err}");
2387 }
2388 }
2389
2390 #[test]
2391 fn leaf_arity_errors_carry_invalid_syntax_prefix() {
2392 let err = parse_err("SLEEP 1s 2s\n");
2393 assert!(err.contains("invalid syntax for command SLEEP"), "{err}");
2394 assert!(!err.contains("unknown command"), "{err}");
2395 }
2396
2397 #[test]
2398 fn genuinely_unknown_command_keeps_bare_message() {
2399 let err = parse_err("FROBNICATE hi\n");
2400 assert!(err.contains("unknown command: FROBNICATE"), "{err}");
2401 assert!(!err.contains("did you mean"), "{err}");
2402 }
2403
2404 #[test]
2405 fn lowercase_command_suggests_uppercase() {
2406 let err = lower_command("echo", vec![Arg::String("hi".to_string(), false)])
2410 .expect_err("must fail")
2411 .to_string();
2412 assert!(err.contains("unknown command: echo"), "{err}");
2413 assert!(err.contains("did you mean `ECHO`"), "{err}");
2414 }
2415
2416 #[test]
2417 fn func_def_requires_typed_uppercase_name() {
2418 let steps = parse_script(
2419 "FUNC GREET($name: STRING) {\n RETURN $name\n}\n",
2420 lower_command,
2421 )
2422 .expect("func def parses");
2423 let StepKind::FuncDef { name, params, body } = &steps[0].kind else {
2424 panic!("expected FuncDef, got {:?}", steps[0].kind);
2425 };
2426 assert_eq!(name, "GREET");
2427 assert_eq!(
2428 params,
2429 &vec![("name".to_string(), TypeKind::String)],
2430 "{params:?}"
2431 );
2432 assert!(matches!(body[0].kind, StepKind::Return { .. }));
2433 }
2434
2435 #[test]
2436 fn lowercase_func_name_is_rejected() {
2437 let err = parse_err("FUNC greet($x: STRING) {\n RETURN $x\n}\n");
2438 assert!(err.contains("FUNC"), "{err}");
2439 }
2440
2441 #[test]
2442 fn call_and_while_lower_correctly() {
2443 let steps = parse_script("CALL GREET(\"ada\")\n", lower_command).expect("call parses");
2444 assert!(
2445 matches!(&steps[0].kind, StepKind::Call { name, .. } if name == "GREET"),
2446 "{:?}",
2447 steps[0].kind
2448 );
2449 let steps =
2450 parse_script("WHILE !$done {\n BREAK\n}\n", lower_command).expect("while parses");
2451 let StepKind::While { body, .. } = &steps[0].kind else {
2452 panic!("expected While, got {:?}", steps[0].kind);
2453 };
2454 assert!(matches!(body[0].kind, StepKind::Break));
2455 }
2456
2457 #[test]
2458 fn let_capture_call_and_async_call_lower() {
2459 let steps = parse_script("LET $r: STRING = CALL GREET(\"ada\")\n", lower_command)
2460 .expect("capture call parses");
2461 let StepKind::AssignCapture { var, cmd, .. } = &steps[0].kind else {
2462 panic!("expected AssignCapture, got {:?}", steps[0].kind);
2463 };
2464 assert_eq!(var, "r");
2465 assert!(matches!(&**cmd, StepKind::Call { .. }), "{cmd:?}");
2466 let steps = parse_script("LET $t: HANDLE = ASYNC CALL GREET(\"a\")\n", lower_command)
2467 .expect("async call parses");
2468 assert!(
2469 matches!(&steps[0].kind, StepKind::AssignAsync { .. }),
2470 "{:?}",
2471 steps[0].kind
2472 );
2473 }
2474
2475 #[test]
2476 fn parse_duration_units() {
2477 use std::time::Duration;
2478 assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
2479 assert_eq!(parse_duration("10s").unwrap(), Duration::from_secs(10));
2480 assert_eq!(parse_duration("2m").unwrap(), Duration::from_secs(120));
2481 assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
2482 assert_eq!(parse_duration("30").unwrap(), Duration::from_secs(30));
2483 }
2484
2485 #[test]
2486 fn parse_duration_rejects_garbage() {
2487 assert!(parse_duration("").is_err());
2488 assert!(parse_duration("banana").is_err());
2489 assert!(parse_duration("10x").is_err());
2490 assert!(parse_duration("0s").is_err());
2491 assert!(parse_duration("0").is_err());
2492 assert!(parse_duration("-5s").is_err());
2493 }
2494
2495 #[test]
2496 fn format_duration_round_trips() {
2497 for text in ["500ms", "10s", "2m", "1h", "90s", "1500ms"] {
2498 let parsed = parse_duration(text).unwrap();
2499 let rendered = format_duration(&parsed);
2500 assert_eq!(
2501 parse_duration(&rendered).unwrap(),
2502 parsed,
2503 "round-trip failed for {text}"
2504 );
2505 }
2506 assert_eq!(format_duration(&parse_duration("90s").unwrap()), "90s");
2507 assert_eq!(format_duration(&parse_duration("2m").unwrap()), "2m");
2508 }
2509
2510 #[test]
2511 fn structural_metadata_covers_all_structural_kinds() {
2512 use crate::ast::Value;
2513
2514 fn metadata_name(kind: &StepKind) -> Option<&'static str> {
2518 match kind {
2519 StepKind::WithIo { .. } | StepKind::WithIoBlock { .. } => Some("WITH_IO"),
2520 StepKind::For { .. } => Some("FOR"),
2521 StepKind::If { .. } => Some("IF"),
2522 StepKind::Assign { .. } => Some("LET"),
2523 StepKind::Set { .. } => Some("MUTATION"),
2524 StepKind::AssignCapture { .. } => Some("LET"),
2525 StepKind::AwaitCapture { .. } => Some("AWAIT"),
2526 StepKind::AsyncBlock { .. } | StepKind::AssignAsync { .. } => Some("ASYNC"),
2527 StepKind::Await { .. } => Some("AWAIT"),
2528 StepKind::Cancel { .. } => Some("CANCEL"),
2529 StepKind::Timeout { .. } => Some("TIMEOUT"),
2530 StepKind::FuncDef { .. } => Some("FUNC"),
2531 StepKind::Call { .. } => Some("CALL"),
2532 StepKind::Return { .. } => Some("RETURN"),
2533 StepKind::While { .. } => Some("WHILE"),
2534 StepKind::Break => Some("BREAK"),
2535 StepKind::Continue => Some("CONTINUE"),
2536 StepKind::RunExec { .. } => None,
2537 StepKind::Workdir(_)
2538 | StepKind::Workspace(_)
2539 | StepKind::Env { .. }
2540 | StepKind::InheritEnv { .. }
2541 | StepKind::Run(_)
2542 | StepKind::Echo(_)
2543 | StepKind::Copy { .. }
2544 | StepKind::Symlink { .. }
2545 | StepKind::Mkdir(_)
2546 | StepKind::Ls(_)
2547 | StepKind::Cwd
2548 | StepKind::Read(_)
2549 | StepKind::ReadLine { .. }
2550 | StepKind::Write { .. }
2551 | StepKind::Append { .. }
2552 | StepKind::Expand { .. }
2553 | StepKind::AssertEq { .. }
2554 | StepKind::AssertContains { .. }
2555 | StepKind::CopyGit { .. }
2556 | StepKind::HashSha256 { .. }
2557 | StepKind::Exit(_)
2558 | StepKind::Sleep { .. } => None,
2559 }
2560 }
2561
2562 let dummies: Vec<StepKind> = vec![
2565 StepKind::WithIo {
2566 bindings: Vec::new(),
2567 cmd: Box::new(StepKind::Echo(crate::ast::Arg::String(
2568 "x".to_string(),
2569 false,
2570 ))),
2571 },
2572 StepKind::For {
2573 key_var: None,
2574 key_type: None,
2575 var: "i".to_string(),
2576 var_type: TypeKind::String,
2577 in_expr: Expr::Literal(Value::Bool(true)),
2578 body: Vec::new(),
2579 },
2580 StepKind::If {
2581 cond: Box::new(Expr::Literal(Value::Bool(true))),
2582 then_body: Vec::new(),
2583 else_ifs: Vec::new(),
2584 else_body: None,
2585 },
2586 StepKind::Assign {
2587 var: "v".to_string(),
2588 decl_type: TypeKind::Bool,
2589 expr: Expr::Literal(Value::Bool(true)),
2590 },
2591 StepKind::Set {
2592 var: "v".to_string(),
2593 expr: Expr::Literal(Value::Bool(true)),
2594 },
2595 StepKind::AssignCapture {
2596 var: "v".to_string(),
2597 decl_type: TypeKind::String,
2598 cmd: Box::new(StepKind::Echo(crate::ast::Arg::String(
2599 "x".to_string(),
2600 false,
2601 ))),
2602 },
2603 StepKind::AwaitCapture {
2604 out_var: "o".to_string(),
2605 out_type: TypeKind::String,
2606 task_var: "t".to_string(),
2607 },
2608 StepKind::AsyncBlock { body: Vec::new() },
2609 StepKind::AssignAsync {
2610 var: "t".to_string(),
2611 decl_type: TypeKind::Handle,
2612 body: Vec::new(),
2613 },
2614 StepKind::Await {
2615 var: "t".to_string(),
2616 },
2617 StepKind::Cancel {
2618 var: "t".to_string(),
2619 },
2620 StepKind::Timeout {
2621 duration: Arg::String("1s".to_string(), false),
2622 body: Vec::new(),
2623 },
2624 StepKind::FuncDef {
2625 name: "F".to_string(),
2626 params: Vec::new(),
2627 body: Vec::new(),
2628 },
2629 StepKind::Call {
2630 name: "F".to_string(),
2631 args: Vec::new(),
2632 },
2633 StepKind::Return {
2634 expr: Box::new(Expr::Literal(Value::Bool(true))),
2635 },
2636 StepKind::While {
2637 cond: Box::new(Expr::Literal(Value::Bool(true))),
2638 body: Vec::new(),
2639 },
2640 StepKind::Break,
2641 StepKind::Continue,
2642 ];
2643 let registry = all_structural_metadata();
2644 for kind in &dummies {
2645 let name = metadata_name(kind).expect("structural kind must map to metadata");
2646 assert!(
2647 registry.iter().any(|meta| meta.name == name),
2648 "no structural metadata entry for {}",
2649 name
2650 );
2651 }
2652 }
2653
2654 #[test]
2655 fn verify_display_sync_with_metadata() {
2656 fn step_contains_kind(kind: &StepKind, name: &str) -> bool {
2657 if kind.to_string().starts_with(name) {
2658 return true;
2659 }
2660 let bodies: Vec<&Vec<Step>> = match kind {
2661 StepKind::For { body, .. }
2662 | StepKind::While { body, .. }
2663 | StepKind::FuncDef { body, .. }
2664 | StepKind::Timeout { body, .. }
2665 | StepKind::AssignAsync { body, .. }
2666 | StepKind::AsyncBlock { body } => vec![body],
2667 StepKind::If {
2668 then_body,
2669 else_ifs,
2670 else_body,
2671 ..
2672 } => {
2673 let mut out = vec![then_body];
2674 out.extend(else_ifs.iter().map(|(_, b)| b));
2675 out.extend(else_body.iter());
2676 out
2677 }
2678 _ => {
2679 if let StepKind::WithIo { cmd, .. } = kind {
2680 return step_contains_kind(cmd, name);
2681 }
2682 if let StepKind::AssignCapture { cmd, .. } = kind {
2683 return step_contains_kind(cmd, name);
2684 }
2685 return false;
2686 }
2687 };
2688 bodies
2689 .iter()
2690 .any(|body| body.iter().any(|s| step_contains_kind(&s.kind, name)))
2691 }
2692
2693 let registry = all_metadata();
2694 for meta in registry {
2695 if meta.examples.is_empty() {
2696 continue;
2697 }
2698
2699 let code = meta.examples[0].code;
2700 let ast = parse_script(code, lower_command)
2701 .unwrap_or_else(|e| panic!("Failed to parse example for {}: {}", meta.name, e));
2702
2703 let matching = ast.iter().find(|step| {
2704 if meta.name == "MUTATION" {
2707 return matches!(step.kind, StepKind::Set { .. });
2708 }
2709 if matches!(meta.name, "RETURN" | "BREAK" | "CONTINUE") {
2713 return step_contains_kind(&step.kind, meta.name);
2714 }
2715 let kind = match &step.kind {
2716 StepKind::WithIo { cmd, .. } => &**cmd,
2717 other => other,
2718 };
2719 kind.to_string().starts_with(meta.name)
2723 || step.kind.to_string().starts_with(meta.name)
2724 });
2725
2726 assert!(
2727 matching.is_some(),
2728 "No step in example for {} produces Display starting with {}",
2729 meta.name,
2730 meta.name
2731 );
2732 }
2733 }
2734}