Skip to main content

oxdock_parser/
ast.rs

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