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