Skip to main content

oxdock_parser/
ast.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4pub use crate::commands::StepKind;
5
6#[derive(Copy, Clone, Debug, Eq, PartialEq)]
7pub enum Command {
8    InheritEnv,
9    Workdir,
10    Workspace,
11    Env,
12    Echo,
13    Run,
14    Copy,
15    WithIo,
16    CopyGit,
17    HashSha256,
18    Symlink,
19    Mkdir,
20    Ls,
21    Cwd,
22    Read,
23    ReadLine,
24    Write,
25    Append,
26    Expand,
27    AssertFile,
28    AssertDir,
29    AssertAbsent,
30    AssertStdout,
31    Exit,
32    Async,
33    Timeout,
34    Sleep,
35}
36
37pub const COMMANDS: &[Command] = &[
38    Command::InheritEnv,
39    Command::Workdir,
40    Command::Workspace,
41    Command::Env,
42    Command::Echo,
43    Command::Run,
44    Command::Copy,
45    Command::WithIo,
46    Command::CopyGit,
47    Command::HashSha256,
48    Command::Symlink,
49    Command::Mkdir,
50    Command::Ls,
51    Command::Cwd,
52    Command::Read,
53    Command::ReadLine,
54    Command::Write,
55    Command::Append,
56    Command::Expand,
57    Command::AssertFile,
58    Command::AssertDir,
59    Command::AssertAbsent,
60    Command::AssertStdout,
61    Command::Exit,
62    Command::Timeout,
63    Command::Sleep,
64];
65
66impl Command {
67    pub const fn as_str(self) -> &'static str {
68        match self {
69            Command::InheritEnv => "INHERIT_ENV",
70            Command::Workdir => "WORKDIR",
71            Command::Workspace => "WORKSPACE",
72            Command::Env => "ENV",
73            Command::Echo => "ECHO",
74            Command::Run => "RUN",
75            Command::Copy => "COPY",
76            Command::WithIo => "WITH_IO",
77            Command::CopyGit => "COPY_GIT",
78            Command::HashSha256 => "HASH_SHA256",
79            Command::Symlink => "SYMLINK",
80            Command::Mkdir => "MKDIR",
81            Command::Ls => "LS",
82            Command::Cwd => "CWD",
83            Command::Read => "READ",
84            Command::ReadLine => "READ_LINE",
85            Command::Write => "WRITE",
86            Command::Append => "APPEND",
87            Command::Expand => "EXPAND",
88            Command::AssertFile => "ASSERT_FILE",
89            Command::AssertDir => "ASSERT_DIR",
90            Command::AssertAbsent => "ASSERT_ABSENT",
91            Command::AssertStdout => "ASSERT_STDOUT",
92            Command::Exit => "EXIT",
93            Command::Async => "ASYNC",
94            Command::Timeout => "TIMEOUT",
95            Command::Sleep => "SLEEP",
96        }
97    }
98
99    pub const fn syntax(self) -> &'static str {
100        match self {
101            Command::InheritEnv => "INHERIT_ENV [KEY1, KEY2, ...]",
102            Command::Workdir => "WORKDIR <path>",
103            Command::Workspace => "WORKSPACE SNAPSHOT|LOCAL",
104            Command::Env => "ENV KEY=value",
105            Command::Echo => "ECHO <message>",
106            Command::Run => "RUN <command...> | RUN [\"exe\", \"arg\", ...]",
107            Command::Copy => "COPY [--from-current-workspace] <from> <to>",
108            Command::CopyGit => "COPY_GIT [--include-dirty] <rev> <src> <dst>",
109            Command::WithIo => "WITH_IO [bindings] [command | { block }]",
110            Command::HashSha256 => "HASH_SHA256 <path>",
111            Command::Symlink => "SYMLINK <from> <to>",
112            Command::Mkdir => "MKDIR <path>",
113            Command::Ls => "LS [<path>]",
114            Command::Cwd => "CWD",
115            Command::Read => "READ [<path>]",
116            Command::ReadLine => "READ_LINE $var",
117            Command::Write => "WRITE <path> [<contents>]",
118            Command::Append => "APPEND <path> [<contents>]",
119            Command::Expand => "EXPAND [<path>] [<KEY=val> ...]",
120            Command::AssertFile => "ASSERT_FILE [--hash <sha256>] <path> [<expected>]",
121            Command::AssertDir => "ASSERT_DIR <path>",
122            Command::AssertAbsent => "ASSERT_ABSENT <path>",
123            Command::AssertStdout => "ASSERT_STDOUT <substring>",
124            Command::Exit => "EXIT <code>",
125            Command::Async => "ASYNC <command...> | ASYNC { <commands> }",
126            Command::Timeout => {
127                "TIMEOUT <duration> <command...> | TIMEOUT <duration> { <commands> }"
128            }
129            Command::Sleep => "SLEEP <duration>",
130        }
131    }
132
133    pub const fn expects_inner_command(self) -> bool {
134        matches!(self, Command::WithIo | Command::Async | Command::Timeout)
135    }
136
137    pub fn parse(s: &str) -> Option<Self> {
138        match s {
139            "INHERIT_ENV" => Some(Command::InheritEnv),
140            "WORKDIR" => Some(Command::Workdir),
141            "WORKSPACE" => Some(Command::Workspace),
142            "ENV" => Some(Command::Env),
143            "ECHO" => Some(Command::Echo),
144            "RUN" => Some(Command::Run),
145            "COPY" => Some(Command::Copy),
146            "WITH_IO" => Some(Command::WithIo),
147            "COPY_GIT" => Some(Command::CopyGit),
148            "HASH_SHA256" => Some(Command::HashSha256),
149            "SYMLINK" => Some(Command::Symlink),
150            "MKDIR" => Some(Command::Mkdir),
151            "LS" => Some(Command::Ls),
152            "CWD" => Some(Command::Cwd),
153            "READ" => Some(Command::Read),
154            "READ_LINE" => Some(Command::ReadLine),
155            "WRITE" => Some(Command::Write),
156            "APPEND" => Some(Command::Append),
157            "EXPAND" => Some(Command::Expand),
158            "ASSERT_FILE" => Some(Command::AssertFile),
159            "ASSERT_DIR" => Some(Command::AssertDir),
160            "ASSERT_ABSENT" => Some(Command::AssertAbsent),
161            "ASSERT_STDOUT" => Some(Command::AssertStdout),
162            "EXIT" => Some(Command::Exit),
163            "ASYNC" => Some(Command::Async),
164            "TIMEOUT" => Some(Command::Timeout),
165            "SLEEP" => Some(Command::Sleep),
166            _ => None,
167        }
168    }
169}
170
171#[derive(Copy, Clone, Debug, Eq, PartialEq)]
172pub enum PlatformGuard {
173    Unix,
174    Windows,
175    Macos,
176    Linux,
177}
178
179#[derive(Debug, Clone, Eq, PartialEq)]
180pub enum Guard {
181    Platform { target: PlatformGuard },
182    EnvExists { key: String },
183    EnvEquals { key: String, value: String },
184    StaticBool { value: String },
185}
186
187#[derive(Debug, Clone, Eq, PartialEq)]
188pub enum GuardExpr {
189    Predicate(Guard),
190    All(Vec<GuardExpr>),
191    Or(Vec<GuardExpr>),
192    Not(Box<GuardExpr>),
193}
194
195impl GuardExpr {
196    pub fn all(exprs: Vec<GuardExpr>) -> GuardExpr {
197        let mut flat = Vec::new();
198        for expr in exprs {
199            match expr {
200                GuardExpr::All(children) => flat.extend(children),
201                other => flat.push(other),
202            }
203        }
204        match flat.len() {
205            0 => panic!("GuardExpr::all requires at least one expression"),
206            1 => flat.into_iter().next().unwrap(),
207            _ => GuardExpr::All(flat),
208        }
209    }
210
211    pub fn or(exprs: Vec<GuardExpr>) -> GuardExpr {
212        let mut flat = Vec::new();
213        for expr in exprs {
214            match expr {
215                GuardExpr::Or(children) => flat.extend(children),
216                other => flat.push(other),
217            }
218        }
219        match flat.len() {
220            0 => panic!("GuardExpr::or requires at least one expression"),
221            1 => flat.into_iter().next().unwrap(),
222            _ => GuardExpr::Or(flat),
223        }
224    }
225
226    pub fn invert(expr: GuardExpr) -> GuardExpr {
227        match expr {
228            GuardExpr::Not(inner) => *inner,
229            other => GuardExpr::Not(Box::new(other)),
230        }
231    }
232}
233
234impl std::ops::Not for GuardExpr {
235    type Output = GuardExpr;
236
237    fn not(self) -> GuardExpr {
238        match self {
239            GuardExpr::Not(inner) => *inner,
240            other => GuardExpr::Not(Box::new(other)),
241        }
242    }
243}
244
245impl From<Guard> for GuardExpr {
246    fn from(guard: Guard) -> Self {
247        GuardExpr::Predicate(guard)
248    }
249}
250
251/// A command argument — either an expandable string or an expression.
252#[derive(Debug, Clone, PartialEq)]
253pub enum Arg {
254    /// Expandable string. The `bool` indicates whether the argument was
255    /// quoted in the source (`true`) or unquoted (`false`). Quoted arguments
256    /// that start with `--` are positional, not flags.
257    String(String, bool),
258    /// Expression — resolved at runtime via evaluate_expr.
259    Expr(Expr),
260    /// Mixed literal/expression value (e.g. `KEY={{ $x }} tail`). Fragments
261    /// resolve independently at runtime and concatenate with no added
262    /// separator — inter-fragment gaps are already materialized as `Text`.
263    Parts(Vec<ArgPart>),
264}
265
266/// One fragment of a mixed [`Arg::Parts`] value.
267#[derive(Debug, Clone, PartialEq)]
268pub enum ArgPart {
269    /// Literal text. The `bool` marks source-quoted regions (exact bytes);
270    /// unquoted text carries single-space-normalized gaps.
271    Text(String, bool),
272    /// Typed expression — resolved via evaluate_expr, never stringified.
273    Expr(Expr),
274}
275
276impl Arg {
277    pub fn as_str(&self) -> &str {
278        match self {
279            Arg::String(s, _) => s,
280            // Expressions and mixed values have no single borrowed string;
281            // use `render()` for an owned display form.
282            Arg::Expr(_) | Arg::Parts(_) => "",
283        }
284    }
285
286    /// Owned display form: `String` verbatim, `Expr` as source (`$x`),
287    /// `Parts` as fragment concatenation. Used for diagnostics and Display;
288    /// runtime resolution must match on variants instead (see resolve_arg).
289    pub fn render(&self) -> String {
290        match self {
291            Arg::String(s, _) => s.clone(),
292            Arg::Expr(e) => e.to_string(),
293            Arg::Parts(parts) => parts.iter().map(ArgPart::render).collect(),
294        }
295    }
296
297    pub fn is_quoted(&self) -> bool {
298        matches!(self, Arg::String(_, true))
299    }
300}
301
302impl ArgPart {
303    pub fn render(&self) -> String {
304        match self {
305            ArgPart::Text(s, _) => s.clone(),
306            ArgPart::Expr(e) => e.to_string(),
307        }
308    }
309}
310
311impl From<String> for Arg {
312    fn from(s: String) -> Self {
313        Arg::String(s, false)
314    }
315}
316
317impl From<&str> for Arg {
318    fn from(s: &str) -> Self {
319        Arg::String(s.to_string(), false)
320    }
321}
322
323impl std::fmt::Display for Arg {
324    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
325        match self {
326            Arg::String(s, _) => write!(f, "{}", s),
327            Arg::Expr(e) => write!(f, "{}", e),
328            Arg::Parts(parts) => {
329                for part in parts {
330                    match part {
331                        ArgPart::Text(s, _) => write!(f, "{}", s)?,
332                        ArgPart::Expr(e) => write!(f, "{}", e)?,
333                    }
334                }
335                Ok(())
336            }
337        }
338    }
339}
340
341impl AsRef<str> for Arg {
342    fn as_ref(&self) -> &str {
343        self.as_str()
344    }
345}
346
347impl PartialEq<str> for Arg {
348    fn eq(&self, other: &str) -> bool {
349        self.as_str() == other
350    }
351}
352
353impl PartialEq<&str> for Arg {
354    fn eq(&self, other: &&str) -> bool {
355        self.as_str() == *other
356    }
357}
358
359#[derive(Debug, Clone, Copy, Eq, PartialEq)]
360pub enum IoStream {
361    Stdin,
362    Stdout,
363    Stderr,
364}
365
366#[derive(Debug, Clone, Eq, PartialEq)]
367pub struct IoBinding {
368    pub stream: IoStream,
369    pub pipe: Option<PipeTarget>,
370}
371
372/// A pipe endpoint for a `WITH_IO` binding: either a literal `pipe:name`
373/// or a `$var` holding a `PIPE` value, resolved against the live pipe
374/// registry when the step runs.
375#[derive(Debug, Clone, Eq, PartialEq)]
376pub enum PipeTarget {
377    Name(String),
378    Var(String),
379}
380
381#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
382pub enum TypeKind {
383    String,
384    Int,
385    Float,
386    Bool,
387    Pipe,
388    List,
389    Map,
390    Handle,
391    Duration,
392    Path,
393}
394
395impl TypeKind {
396    pub const CANONICAL: &[TypeKind] = &[
397        TypeKind::String,
398        TypeKind::Int,
399        TypeKind::Float,
400        TypeKind::Bool,
401        TypeKind::Pipe,
402        TypeKind::List,
403        TypeKind::Map,
404        TypeKind::Handle,
405        TypeKind::Duration,
406        TypeKind::Path,
407    ];
408
409    /// Canonical display name. This is the single source of truth for the
410    /// type vocabulary: anchors, doc titles, `FromStr`, and the `ArgType` /
411    /// `FlagValueType` display labels all derive from these strings.
412    pub fn label(&self) -> &'static str {
413        match self {
414            TypeKind::String => "STRING",
415            TypeKind::Int => "INT",
416            TypeKind::Float => "FLOAT",
417            TypeKind::Bool => "BOOL",
418            TypeKind::Pipe => "PIPE",
419            TypeKind::List => "LIST",
420            TypeKind::Map => "MAP",
421            TypeKind::Handle => "HANDLE",
422            TypeKind::Duration => "DURATION",
423            TypeKind::Path => "PATH",
424        }
425    }
426
427    /// Reference body for the canonical types. The title derives from
428    /// [`label`](Self::label); only the prose body is stored per variant.
429    pub fn doc(&self) -> Option<(String, &'static str)> {
430        let body = match self {
431            TypeKind::String => {
432                "Arbitrary text. Quotes keep exact bytes, lone `$var` evaluates, `{{ ... }}` interpolates."
433            }
434            TypeKind::Int => "64-bit signed integer, e.g. an exit code.",
435            TypeKind::Float => "64-bit float, e.g. a ratio.",
436            TypeKind::Bool => "Boolean `true` or `false`.",
437            TypeKind::Pipe => {
438                "Named script pipe. Validity is checked against the pipe registry at coercion time."
439            }
440            TypeKind::List => "Ordered list of values.",
441            TypeKind::Map => "String-keyed map of values.",
442            TypeKind::Handle => "Background ASYNC task handle for AWAIT/CANCEL.",
443            TypeKind::Duration => {
444                "Positive time span: `500ms`, `10s`, `2m`, `1h`; bare number means seconds."
445            }
446            TypeKind::Path => "Workspace path, resolved against cwd and guarded against escape.",
447        };
448        Some((format!("Value type: {}", self.label()), body))
449    }
450
451    /// Anchor of the type's reference section, derived from
452    /// [`label`](Self::label) the way the Markdown slugger would derive it
453    /// from the doc title.
454    pub fn anchor(&self) -> String {
455        format!("value-type-{}", self.label().to_lowercase())
456    }
457}
458
459impl std::str::FromStr for TypeKind {
460    type Err = anyhow::Error;
461    fn from_str(s: &str) -> Result<Self, Self::Err> {
462        if let Some(kind) = Self::CANONICAL.iter().find(|k| k.label() == s) {
463            return Ok(*kind);
464        }
465        let inventory = Self::CANONICAL
466            .iter()
467            .map(|k| k.label())
468            .collect::<Vec<_>>()
469            .join(", ");
470        anyhow::bail!("unknown type `{s}`; expected one of {inventory}")
471    }
472}
473
474impl std::fmt::Display for TypeKind {
475    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
476        write!(f, "{}", self.label())
477    }
478}
479
480#[derive(Debug, Clone, PartialEq)]
481pub enum Value {
482    String(String),
483    Int(i64),
484    Float(f64),
485    List(Vec<Value>),
486    Map(std::collections::BTreeMap<String, Value>),
487    Bool(bool),
488    Pipe(String), // holds pipe name; validity checked against PipeRegistry
489    Duration(std::time::Duration),
490    // Narrow exception: the PATH slot carries an already-resolved path value.
491    // All guard checks still run through oxdock-fs at coercion/use time.
492    #[allow(clippy::disallowed_types)]
493    Path(std::path::PathBuf),
494    /// Handle to a background ASYNC task. The `u64` is the task ID
495    /// used to look up the handle in `ExecState.named_tasks`.
496    TaskHandle(u64),
497}
498
499#[derive(Debug, Clone, Copy, Eq, PartialEq)]
500pub enum CompareOp {
501    Eq,
502    Ne,
503    Lt,
504    Le,
505    Gt,
506    Ge,
507}
508
509#[derive(Debug, Clone, Copy, Eq, PartialEq)]
510pub enum ArithOp {
511    Add,
512    Sub,
513    Mul,
514    Div,
515}
516
517/// Flat stack-machine op for expression-local arithmetic/comparison.
518///
519/// Lowering folds constant subtrees to `Expr::Literal` and compiles dynamic
520/// arithmetic/comparison subtrees to post-order `Vec<MathOp>` so the runtime
521/// executes a single instruction loop instead of recursive `Box` walking.
522/// `Call` covers value-semantics functions only (`INT`, `FLOAT`, `GLOB`,
523/// `LOAD_TOML`, `LOAD_JSON`); `INSPECT($var)` uses `Inspect` to preserve the
524/// variable identifier (pre-evaluating to `Value` would lose the name).
525#[derive(Debug, Clone, PartialEq)]
526pub enum MathOp {
527    PushConst(Value),
528    LoadVar(String),
529    LoadEnv(String),
530    LoadKeyPath { base: String, keys: Vec<String> },
531    Call { name: String, arity: usize },
532    Inspect(String),
533    Neg,
534    Add,
535    Sub,
536    Mul,
537    Div,
538    Lt,
539    Le,
540    Gt,
541    Ge,
542    Eq,
543    Ne,
544}
545
546#[derive(Debug, Clone, Copy, Eq, PartialEq)]
547pub enum LogicalOp {
548    And,
549    Or,
550}
551
552#[derive(Debug, Clone, PartialEq)]
553pub enum Expr {
554    Literal(Value),
555    Var(String),
556    /// Environment read (`env:KEY`): resolves against the script
557    /// environment at evaluation time.
558    Env(String),
559    KeyPath {
560        base: String,
561        keys: Vec<String>,
562    },
563    List(Vec<Expr>),
564    Map(Vec<(String, Expr)>),
565    Call {
566        name: String,
567        args: Vec<Expr>,
568    },
569    Compare {
570        op: CompareOp,
571        left: Box<Expr>,
572        right: Box<Expr>,
573    },
574    Arithmetic {
575        op: ArithOp,
576        left: Box<Expr>,
577        right: Box<Expr>,
578    },
579    /// Lowering-optimized form: folded literals stay `Literal`, dynamic
580    /// arithmetic/comparison subtrees arrive here as flat RPN.
581    CompiledMath(Vec<MathOp>),
582    /// Lowering-only intermediate staging `9223372036854775808` (the unsigned
583    /// half of `i64::MIN`). Valid only as the direct child of unary `-`;
584    /// any instance reaching lowering completion bails integer overflow.
585    UnsignedIntBoundary(u64),
586    Not(Box<Expr>),
587    Logical {
588        op: LogicalOp,
589        left: Box<Expr>,
590        right: Box<Expr>,
591    },
592}
593
594#[derive(Debug, Clone, PartialEq)]
595pub struct Step {
596    pub guard: Option<GuardExpr>,
597    pub kind: StepKind,
598    pub scope_enter: usize,
599    pub scope_exit: usize,
600}
601
602#[derive(Debug, Clone, Eq, PartialEq)]
603pub enum WorkspaceTarget {
604    Snapshot,
605    Local,
606}
607
608fn platform_matches(target: PlatformGuard) -> bool {
609    #[allow(clippy::disallowed_macros)]
610    match target {
611        PlatformGuard::Unix => cfg!(unix),
612        PlatformGuard::Windows => cfg!(windows),
613        PlatformGuard::Macos => cfg!(target_os = "macos"),
614        PlatformGuard::Linux => cfg!(target_os = "linux"),
615    }
616}
617
618pub trait EnvLookup {
619    fn get_env(&self, key: &str) -> Option<&str>;
620}
621
622impl EnvLookup for HashMap<String, String> {
623    fn get_env(&self, key: &str) -> Option<&str> {
624        self.get(key).map(|s| s.as_str())
625    }
626}
627
628impl EnvLookup for Arc<HashMap<String, String>> {
629    fn get_env(&self, key: &str) -> Option<&str> {
630        (**self).get_env(key)
631    }
632}
633
634pub fn guard_allows(guard: &Guard, env: &impl EnvLookup) -> bool {
635    match guard {
636        Guard::Platform { target } => platform_matches(*target),
637        Guard::EnvExists { key } => env.get_env(key).map(|v| !v.is_empty()).unwrap_or(false),
638        Guard::EnvEquals { key, value } => env
639            .get_env(key)
640            .map(|v| v == value.as_str())
641            .unwrap_or(false),
642        Guard::StaticBool { value } => value.parse::<bool>().unwrap_or(false),
643    }
644}
645
646pub fn guard_expr_allows(expr: &GuardExpr, env: &impl EnvLookup) -> bool {
647    match expr {
648        GuardExpr::Predicate(guard) => guard_allows(guard, env),
649        GuardExpr::All(children) => children.iter().all(|g| guard_expr_allows(g, env)),
650        GuardExpr::Or(children) => children.iter().any(|g| guard_expr_allows(g, env)),
651        GuardExpr::Not(child) => !guard_expr_allows(child, env),
652    }
653}
654
655pub fn guard_option_allows(expr: Option<&GuardExpr>, env: &impl EnvLookup) -> bool {
656    match expr {
657        Some(e) => guard_expr_allows(e, env),
658        None => true,
659    }
660}
661
662use std::fmt;
663
664impl fmt::Display for PlatformGuard {
665    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
666        match self {
667            PlatformGuard::Unix => write!(f, "unix"),
668            PlatformGuard::Windows => write!(f, "windows"),
669            PlatformGuard::Macos => write!(f, "macos"),
670            PlatformGuard::Linux => write!(f, "linux"),
671        }
672    }
673}
674
675impl fmt::Display for Guard {
676    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
677        match self {
678            Guard::Platform { target } => write!(f, "{}", target),
679            Guard::EnvExists { key } => write!(f, "env:{}", key),
680            Guard::EnvEquals { key, value } => write!(f, "eq(env:{}, {})", key, value),
681            Guard::StaticBool { value } => write!(f, "bool:{}", value),
682        }
683    }
684}
685
686impl fmt::Display for WorkspaceTarget {
687    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
688        match self {
689            WorkspaceTarget::Snapshot => write!(f, "SNAPSHOT"),
690            WorkspaceTarget::Local => write!(f, "LOCAL"),
691        }
692    }
693}
694
695impl fmt::Display for Value {
696    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
697        match self {
698            Value::String(s) => write!(f, "\"{}\"", s),
699            Value::Int(i) => write!(f, "{}", i),
700            Value::Float(v) => write!(f, "{}", v),
701            Value::Pipe(n) => write!(f, "pipe:{}", n),
702            Value::Duration(d) => write!(f, "{}", crate::command::format_duration(d)),
703            Value::Path(p) => write!(f, "{}", p.display()),
704            Value::List(items) => {
705                write!(f, "[")?;
706                for (i, item) in items.iter().enumerate() {
707                    if i > 0 {
708                        write!(f, ", ")?;
709                    }
710                    write!(f, "{}", item)?;
711                }
712                write!(f, "]")
713            }
714            Value::Map(map) => {
715                write!(f, "{{")?;
716                for (i, (k, v)) in map.iter().enumerate() {
717                    if i > 0 {
718                        write!(f, ", ")?;
719                    }
720                    write!(f, "{}: {}", k, v)?;
721                }
722                write!(f, "}}")
723            }
724            Value::Bool(b) => write!(f, "{}", b),
725            Value::TaskHandle(id) => write!(f, "task#{}", id),
726        }
727    }
728}
729
730impl fmt::Display for Expr {
731    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
732        match self {
733            Expr::Literal(v) => write!(f, "{}", v),
734            Expr::Var(name) => write!(f, "${}", name),
735            Expr::Env(key) => write!(f, "env:{}", key),
736            Expr::KeyPath { base, keys } => {
737                write!(f, "${}", base)?;
738                for key in keys {
739                    write!(f, ".{}", key)?;
740                }
741                Ok(())
742            }
743            Expr::Call { name, args } => {
744                write!(f, "{}(", name)?;
745                for (i, arg) in args.iter().enumerate() {
746                    if i > 0 {
747                        write!(f, ", ")?;
748                    }
749                    write!(f, "{}", arg)?;
750                }
751                write!(f, ")")
752            }
753            Expr::List(items) => {
754                write!(f, "[")?;
755                for (i, item) in items.iter().enumerate() {
756                    if i > 0 {
757                        write!(f, ", ")?;
758                    }
759                    write!(f, "{}", item)?;
760                }
761                write!(f, "]")
762            }
763            Expr::Map(entries) => {
764                write!(f, "{{")?;
765                for (i, (key, val)) in entries.iter().enumerate() {
766                    if i > 0 {
767                        write!(f, ", ")?;
768                    }
769                    write!(f, "\"{}\": {}", key, val)?;
770                }
771                write!(f, "}}")
772            }
773            Expr::Compare { op, left, right } => {
774                write!(f, "{} {} {}", left, op, right)
775            }
776            Expr::Arithmetic { op, left, right } => {
777                write!(f, "({} {} {})", left, op, right)
778            }
779            Expr::CompiledMath(ops) => {
780                write!(f, "{}", format_compiled_math(ops))
781            }
782            Expr::UnsignedIntBoundary(n) => write!(f, "{}", n),
783            Expr::Not(inner) => {
784                // Parenthesize compound operands so Display round-trips:
785                // `!(a == b)` must not render as `!a == b` (= `(!a) == b`).
786                match inner.as_ref() {
787                    Expr::Compare { .. } | Expr::Arithmetic { .. } | Expr::CompiledMath(_) => {
788                        write!(f, "!({})", inner)
789                    }
790                    _ => write!(f, "!{}", inner),
791                }
792            }
793            Expr::Logical { op, left, right } => {
794                write!(f, "({} {} {})", left, op, right)
795            }
796        }
797    }
798}
799
800impl fmt::Display for CompareOp {
801    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
802        match self {
803            CompareOp::Eq => write!(f, "=="),
804            CompareOp::Ne => write!(f, "!="),
805            CompareOp::Lt => write!(f, "<"),
806            CompareOp::Le => write!(f, "<="),
807            CompareOp::Gt => write!(f, ">"),
808            CompareOp::Ge => write!(f, ">="),
809        }
810    }
811}
812
813impl fmt::Display for ArithOp {
814    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
815        match self {
816            ArithOp::Add => write!(f, "+"),
817            ArithOp::Sub => write!(f, "-"),
818            ArithOp::Mul => write!(f, "*"),
819            ArithOp::Div => write!(f, "/"),
820        }
821    }
822}
823
824/// Render flat RPN back to parenthesized infix so `Display` round-trips
825/// through the parser with identical semantics. Parentheses are emitted
826/// unconditionally around binary/unary ops; redundant parens parse to the
827/// same tree, which is what round-trip requires.
828fn format_compiled_math(ops: &[MathOp]) -> String {
829    let mut stack: Vec<String> = Vec::new();
830    for op in ops {
831        match op {
832            MathOp::PushConst(v) => stack.push(format!("{}", v)),
833            MathOp::LoadVar(name) => stack.push(format!("${}", name)),
834            MathOp::LoadEnv(key) => stack.push(format!("env:{}", key)),
835            MathOp::LoadKeyPath { base, keys } => {
836                let mut s = format!("${}", base);
837                for key in keys {
838                    s.push('.');
839                    s.push_str(key);
840                }
841                stack.push(s);
842            }
843            MathOp::Call { name, arity } => {
844                let mut args = Vec::new();
845                for _ in 0..*arity {
846                    args.push(stack.pop().unwrap_or_else(|| "<underflow>".to_string()));
847                }
848                args.reverse();
849                stack.push(format!("{}({})", name, args.join(", ")));
850            }
851            MathOp::Inspect(name) => stack.push(format!("INSPECT(${})", name)),
852            MathOp::Neg => {
853                let inner = stack.pop().unwrap_or_else(|| "<underflow>".to_string());
854                stack.push(format!("(-{})", inner));
855            }
856            MathOp::Add => push_bin(&mut stack, "+"),
857            MathOp::Sub => push_bin(&mut stack, "-"),
858            MathOp::Mul => push_bin(&mut stack, "*"),
859            MathOp::Div => push_bin(&mut stack, "/"),
860            MathOp::Lt => push_bin(&mut stack, "<"),
861            MathOp::Le => push_bin(&mut stack, "<="),
862            MathOp::Gt => push_bin(&mut stack, ">"),
863            MathOp::Ge => push_bin(&mut stack, ">="),
864            MathOp::Eq => push_bin(&mut stack, "=="),
865            MathOp::Ne => push_bin(&mut stack, "!="),
866        }
867    }
868    if stack.len() == 1 {
869        let mut items = stack;
870        items.pop().unwrap_or_else(|| "<empty>".to_string())
871    } else {
872        stack.join(" ")
873    }
874}
875
876fn push_bin(stack: &mut Vec<String>, op: &str) {
877    let right = stack.pop().unwrap_or_else(|| "<underflow>".to_string());
878    let left = stack.pop().unwrap_or_else(|| "<underflow>".to_string());
879    stack.push(format!("({} {} {})", left, op, right));
880}
881
882impl fmt::Display for LogicalOp {
883    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
884        match self {
885            LogicalOp::And => write!(f, "&&"),
886            LogicalOp::Or => write!(f, "||"),
887        }
888    }
889}
890
891enum GuardDisplayContext {
892    Root,
893    InAnyArg,
894    InNot,
895    InAll,
896}
897
898impl GuardExpr {
899    fn fmt_with_ctx(&self, f: &mut fmt::Formatter<'_>, ctx: GuardDisplayContext) -> fmt::Result {
900        match self {
901            GuardExpr::Predicate(guard) => write!(f, "{}", guard),
902            GuardExpr::All(children) => {
903                let wrap = matches!(
904                    ctx,
905                    GuardDisplayContext::InAnyArg | GuardDisplayContext::InNot
906                ) && children.len() > 1;
907                if wrap {
908                    write!(f, "(")?;
909                }
910                for (i, child) in children.iter().enumerate() {
911                    if i > 0 {
912                        write!(f, ", ")?;
913                    }
914                    child.fmt_with_ctx(f, GuardDisplayContext::InAll)?;
915                }
916                if wrap {
917                    write!(f, ")")?;
918                }
919                Ok(())
920            }
921            GuardExpr::Or(children) => {
922                write!(f, "any(")?;
923                for (i, child) in children.iter().enumerate() {
924                    if i > 0 {
925                        write!(f, ", ")?;
926                    }
927                    child.fmt_with_ctx(f, GuardDisplayContext::InAnyArg)?;
928                }
929                write!(f, ")")
930            }
931            GuardExpr::Not(child) => {
932                write!(f, "not(")?;
933                child.fmt_with_ctx(f, GuardDisplayContext::InNot)?;
934                write!(f, ")")
935            }
936        }
937    }
938}
939
940impl fmt::Display for GuardExpr {
941    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
942        self.fmt_with_ctx(f, GuardDisplayContext::Root)
943    }
944}
945
946impl fmt::Display for Step {
947    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
948        if let Some(expr) = &self.guard {
949            write!(f, "[{}] ", expr)?;
950        }
951        write!(f, "{}", self.kind)
952    }
953}