Skip to main content

flow_ir_core/
lib.rs

1#![deny(unsafe_code)]
2#![warn(missing_docs)]
3//! flow.ir Pure Rust schema + sync interpreter.
4//!
5//! Node kinds (Step / Seq / Branch / Fanout / Loop / Try / Assign) + Expr ops
6//! (canonical wire format — comparison / boolean / existence / arithmetic /
7//! aggregate / `call_extern`) + sync `eval` + `Dispatcher` trait + `Externs`
8//! registry + typed [`Path`] read/write (see [`Path`] for the full path
9//! syntax + uniform malformed-path rejection rules — the single authority,
10//! rather than restating them here).
11//!
12//! mlua / futures / async 依存ゼロ。 async runtime + mlua binding は上流
13//! `mlua-flow-ir` crate が担当する 4 層 stack の core 層。
14//!
15//! # Quick start
16//!
17//! ```
18//! use flow_ir_core::{eval, Dispatcher, EvalError, Expr, Node};
19//! use serde_json::{json, Value};
20//!
21//! let node: Node = serde_json::from_value(json!({
22//!     "kind": "step",
23//!     "ref": "uppercase",
24//!     "in": { "op": "path", "at": "$.input" },
25//!     "out": { "op": "path", "at": "$.output" },
26//! })).unwrap();
27//!
28//! struct Fixture;
29//! impl Dispatcher for Fixture {
30//!     fn dispatch(&self, _r: &str, input: Value) -> Result<Value, EvalError> {
31//!         if let Value::String(s) = input {
32//!             Ok(Value::String(s.to_uppercase()))
33//!         } else {
34//!             Ok(input)
35//!         }
36//!     }
37//! }
38//!
39//! let out = eval(&node, json!({ "input": "hello" }), &Fixture).unwrap();
40//! assert_eq!(out, json!({ "input": "hello", "output": "HELLO" }));
41//! ```
42
43use serde::{Deserialize, Serialize};
44use serde_json::Value;
45use thiserror::Error;
46
47mod path;
48pub use path::{Path, PathParseError};
49
50// ──────────────────────────────────────────────────────────────────────────
51// IR: 7 Node kinds + 20 Expr ops
52// ──────────────────────────────────────────────────────────────────────────
53
54/// flow.ir Node kind.
55///
56/// Discriminated with `kind` tag, `deny_unknown_fields` (open=false),
57/// `rename_all = "snake_case"`. Covers the 7 supported kinds: `Step` / `Seq`
58/// / `Branch` / `Fanout` (canonical schema の `fanout` Node、 4 join mode) /
59/// `Loop` / `Try` / `Assign`. Additional kinds may be added in future
60/// versions.
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
62#[serde(tag = "kind", deny_unknown_fields, rename_all = "snake_case")]
63pub enum Node {
64    /// `Step` — dispatch a referenced operation with `in` input, write result to `out`.
65    Step {
66        /// Dispatcher key (wire field `ref`), resolved via [`Dispatcher::dispatch`].
67        #[serde(rename = "ref")]
68        ref_: String,
69        /// Input `Expr`, evaluated against the ctx snapshot before dispatch (wire field `in`).
70        #[serde(rename = "in")]
71        in_: Expr,
72        /// `Path` `Expr` the dispatcher's output is written to.
73        out: Expr,
74    },
75    /// `Seq` — evaluate children in order, threading the context value through.
76    Seq {
77        /// Child nodes, evaluated in order.
78        children: Vec<Node>,
79    },
80    /// `Branch` — eval `cond`; if `true` run `then`, else run `else`.
81    Branch {
82        /// Condition `Expr`; must evaluate to a JSON boolean.
83        cond: Expr,
84        /// Branch taken when `cond` is `true` (wire field `then`).
85        #[serde(rename = "then")]
86        then_: Box<Node>,
87        /// Branch taken when `cond` is `false` (wire field `else`).
88        #[serde(rename = "else")]
89        else_: Box<Node>,
90    },
91    /// `Fanout` — eval `items` to an array, run `body` per item against a
92    /// branch-local ctx (caller ctx + item written to `bind`), join results
93    /// per `join` mode into `out`. Async parallel runner uses
94    /// `futures::future::{try_join_all|select_ok|join_all}` (executor-agnostic).
95    Fanout {
96        /// `Expr` evaluated to a JSON array; one branch runs per element.
97        items: Expr,
98        /// `Path` `Expr` each branch's item is written to before running `body`.
99        bind: Expr,
100        /// Node run once per `items` element, against a disjoint branch ctx.
101        body: Box<Node>,
102        /// How per-branch results are combined into `out`.
103        join: JoinMode,
104        /// `Path` `Expr` the joined result is written to.
105        out: Expr,
106    },
107    /// `Loop` — counter を 0 から、 `cond` が truthy かつ `counter < max` の間
108    /// `body` を eval。 各 iter 後 counter を increment して `counter` path に書く。
109    /// VerdictLoop 等の retry/poll パターン primitive (canonical schema 整合)。
110    Loop {
111        /// `Path` `Expr` the iteration counter is written to (starts at `0`).
112        counter: Expr,
113        /// Condition re-evaluated before each iteration; loop stops once falsy.
114        cond: Expr,
115        /// Node evaluated once per iteration.
116        body: Box<Node>,
117        /// Hard iteration cap (loop stops once `counter >= max`, regardless of `cond`).
118        max: u32,
119    },
120    /// `Try` — `body` を eval、 raise した場合 `catch` を eval。
121    /// `err_at` が Some なら catch 開始前に error message を ctx に書く。
122    Try {
123        /// Node evaluated first; failures trigger a ctx rollback + `catch`.
124        body: Box<Node>,
125        /// Node evaluated when `body` raises.
126        catch: Box<Node>,
127        /// Optional `Path` `Expr` the error message is written to before `catch` runs.
128        #[serde(default)]
129        err_at: Option<Expr>,
130    },
131    /// `Assign` — pure transform Node。 `value` Expr を ctx snapshot 上で評価し、
132    /// 結果を `at` (Path Expr) に write する。 dispatcher 不要、 副作用は
133    /// `CtxStorage.write` 1 回のみ。 `Seq` の中で Step 間の Adhoc update 表現に
134    /// 使う (= IR primitive、 Command 履歴は CtxStorage の write hook 経由で取得)。
135    Assign {
136        /// `Path` `Expr` the evaluated `value` is written to.
137        at: Expr,
138        /// `Expr` evaluated against the ctx snapshot.
139        value: Expr,
140    },
141}
142
143/// Fanout join semantics (Promise / futures combinators).
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(rename_all = "snake_case")]
146pub enum JoinMode {
147    /// every branch runs; out is an array of per-branch final ctx
148    /// (Promise.all / `futures::try_join_all`).
149    All,
150    /// first non-raising branch's ctx wins; all-fail raises
151    /// (Promise.any / `futures::future::select_ok`).
152    Any,
153    /// first branch to settle wins, success OR raise
154    /// (Promise.race / `futures::future::select`).
155    Race,
156    /// every branch runs, never raises; per-item record
157    /// `{status: fulfilled|rejected, value|reason}` (Promise.allSettled).
158    AllSettled,
159}
160
161/// flow.ir Expr op.
162///
163/// Discriminated with `op` tag, `deny_unknown_fields`, `rename_all = "snake_case"`.
164/// Wire format (op tag / field names) follows the canonical `flow-ir-lua`
165/// schema (`flow/ir/schema.lua`) verbatim: `gte`/`lte` (not `ge`/`le`),
166/// `args` on `and`/`or`, `arg` on `not`/`len`/`exists`.
167///
168/// Ops:
169/// - read / literal: `Path` / `Lit`
170/// - comparison: `Eq` / `Ne` / `Lt` / `Lte` / `Gt` / `Gte` (numbers or strings)
171/// - boolean: `Not` / `And` / `Or`
172/// - existence: `Exists` (truthy iff `arg` evaluates to a non-null value)
173/// - arithmetic: `Add` / `Sub` / `Mul` / `Div` / `Mod`
174/// - aggregate: `Len` (length of array / string / object) / `In` (membership in array)
175/// - hatch: `CallExtern` (host-registered pure function, resolved via `Externs`)
176#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
177#[serde(tag = "op", deny_unknown_fields, rename_all = "snake_case")]
178pub enum Expr {
179    /// `Path` — read a value from ctx by simple `$.a.b.c` form.
180    Path {
181        /// The parsed context path — see [`Path`] for syntax + rejection
182        /// rules. Deserialized (and syntax-validated) once, at parse time.
183        at: Path,
184    },
185    /// `Lit` — literal JSON value.
186    Lit {
187        /// The literal value, returned as-is on evaluation.
188        value: Value,
189    },
190    /// `Eq` — boolean equality of two sub-expressions. Numbers compare by
191    /// f64 value (`5 == 5.0` is `true`), matching the ordering ops' numeric
192    /// coercion; integers above 2^53 may lose precision (same caveat as
193    /// `Lt`/`Lte`/`Gt`/`Gte`).
194    Eq {
195        /// Left-hand operand.
196        lhs: Box<Expr>,
197        /// Right-hand operand.
198        rhs: Box<Expr>,
199    },
200    /// `Ne` — boolean inequality. Same numeric coercion as `Eq` (`5 != 5.0`
201    /// is `false`); precision caveat above 2^53 shared with ordering ops.
202    Ne {
203        /// Left-hand operand.
204        lhs: Box<Expr>,
205        /// Right-hand operand.
206        rhs: Box<Expr>,
207    },
208    /// `Lt` — `lhs < rhs`. Both numbers (f64) or both strings (lexicographic),
209    /// mirroring canonical Lua `<` semantics. Mixed / other types raise.
210    Lt {
211        /// Left-hand operand.
212        lhs: Box<Expr>,
213        /// Right-hand operand.
214        rhs: Box<Expr>,
215    },
216    /// `Lte` — `lhs <= rhs` (canonical wire tag `lte`).
217    Lte {
218        /// Left-hand operand.
219        lhs: Box<Expr>,
220        /// Right-hand operand.
221        rhs: Box<Expr>,
222    },
223    /// `Gt` — `lhs > rhs`.
224    Gt {
225        /// Left-hand operand.
226        lhs: Box<Expr>,
227        /// Right-hand operand.
228        rhs: Box<Expr>,
229    },
230    /// `Gte` — `lhs >= rhs` (canonical wire tag `gte`).
231    Gte {
232        /// Left-hand operand.
233        lhs: Box<Expr>,
234        /// Right-hand operand.
235        rhs: Box<Expr>,
236    },
237    /// `Not` — boolean negation of `arg` (truthy-based; null/false → true).
238    Not {
239        /// Operand negated by truthiness.
240        arg: Box<Expr>,
241    },
242    /// `And` — variadic boolean conjunction (short-circuit). Empty list → true.
243    And {
244        /// Operands evaluated left-to-right until one is falsy.
245        args: Vec<Expr>,
246    },
247    /// `Or` — variadic boolean disjunction (short-circuit). Empty list → false.
248    Or {
249        /// Operands evaluated left-to-right until one is truthy.
250        args: Vec<Expr>,
251    },
252    /// `Exists` — evaluate `arg`; `true` iff it resolves to a non-null value.
253    /// A `Path` arg that raises `PathNotFound` yields `false` (canonical
254    /// `arg ~= nil` semantics — JSON null maps to Lua nil).
255    Exists {
256        /// Operand whose presence (non-null, resolvable) is tested.
257        arg: Box<Expr>,
258    },
259    /// `Add` — numeric `lhs + rhs` (f64).
260    Add {
261        /// Left-hand operand.
262        lhs: Box<Expr>,
263        /// Right-hand operand.
264        rhs: Box<Expr>,
265    },
266    /// `Sub` — numeric `lhs - rhs`.
267    Sub {
268        /// Left-hand operand.
269        lhs: Box<Expr>,
270        /// Right-hand operand.
271        rhs: Box<Expr>,
272    },
273    /// `Mul` — numeric `lhs * rhs`.
274    Mul {
275        /// Left-hand operand.
276        lhs: Box<Expr>,
277        /// Right-hand operand.
278        rhs: Box<Expr>,
279    },
280    /// `Div` — numeric `lhs / rhs`. Division by zero raises `ArithError`.
281    Div {
282        /// Left-hand operand (dividend).
283        lhs: Box<Expr>,
284        /// Right-hand operand (divisor).
285        rhs: Box<Expr>,
286    },
287    /// `Mod` — numeric `lhs % rhs` (Lua `%` semantics: result takes the sign
288    /// of `rhs`). Modulo by zero raises `ArithError`.
289    Mod {
290        /// Left-hand operand (dividend).
291        lhs: Box<Expr>,
292        /// Right-hand operand (divisor).
293        rhs: Box<Expr>,
294    },
295    /// `Len` — length of `arg`: array → element count, string → char count,
296    /// object → key count. Other types raise `TypeError`.
297    Len {
298        /// Operand whose length is computed.
299        arg: Box<Expr>,
300    },
301    /// `In` — `true` if `needle` equals any element of `haystack` (which must
302    /// evaluate to an array). Rust-side extension (not in canonical schema).
303    /// Element equality uses the same numeric coercion as `Eq` (`5 == 5.0`);
304    /// precision caveat above 2^53 shared with ordering ops.
305    In {
306        /// Value tested for membership.
307        needle: Box<Expr>,
308        /// Operand that must evaluate to a JSON array.
309        haystack: Box<Expr>,
310    },
311    /// `CallExtern` — value-shape Hatch: resolve a host-injected pure function
312    /// by opaque key via the `Externs` registry, apply it to evaluated args,
313    /// return the value. The registered function MUST be pure (no side
314    /// effects, no flow control) — see canonical `doc/ir.md §call_extern`.
315    CallExtern {
316        /// Extern registry key (wire field `ref`), resolved via [`Externs::call`].
317        #[serde(rename = "ref")]
318        ref_: String,
319        /// Argument expressions, evaluated before the extern call.
320        args: Vec<Expr>,
321    },
322}
323
324// ──────────────────────────────────────────────────────────────────────────
325// Dispatcher trait + EvalError
326// ──────────────────────────────────────────────────────────────────────────
327
328/// Dispatcher callback: resolves a `Step.ref` against the provided input,
329/// returns the step's raw output value.
330///
331/// Host crates (e.g. `mlua-swarm-engine`) provide concrete implementations:
332/// agent-block process spawn, mlua callback, MCP call, direct LLM, etc.
333/// `Fn(&str, Value) -> Result<Value, EvalError>` closures also implement this
334/// trait via the blanket impl below.
335pub trait Dispatcher {
336    /// Resolve `ref_` against `input`, returning the step's raw output value.
337    fn dispatch(&self, ref_: &str, input: Value) -> Result<Value, EvalError>;
338}
339
340impl<F> Dispatcher for F
341where
342    F: Fn(&str, Value) -> Result<Value, EvalError>,
343{
344    fn dispatch(&self, ref_: &str, input: Value) -> Result<Value, EvalError> {
345        self(ref_, input)
346    }
347}
348
349/// Evaluation error.
350///
351/// `#[non_exhaustive]`: new variants may be added in a minor release: match
352/// with a wildcard arm.
353#[derive(Debug, Error)]
354#[non_exhaustive]
355pub enum EvalError {
356    /// A `Path` read did not resolve — the requested key is missing from ctx.
357    #[error("path not found: {0}")]
358    PathNotFound(String),
359    /// A `Path` string is malformed — see [`Path`] for the full syntax +
360    /// rejection rules. Raised by the `read_path` / `write_path` compat
361    /// wrappers when their `&str` argument fails to parse; never raised by
362    /// [`Path::read`] / [`Path::write`] themselves (an already-parsed
363    /// `Path` cannot represent malformed syntax).
364    #[error("invalid path syntax: {0}")]
365    InvalidPath(String),
366    /// `Branch.cond` evaluated to a non-boolean value.
367    #[error("branch cond must be boolean, got: {0}")]
368    NonBoolCond(Value),
369    /// An expression or node received a value of the wrong type (e.g. a
370    /// `Len`/`In`/comparison/arithmetic operand of the wrong JSON type, or a
371    /// `Fanout.items` result that did not evaluate to an array).
372    #[error("type error in '{op}': {msg}")]
373    TypeError {
374        /// The op (or synthetic label, e.g. `"fanout.any"`) that raised.
375        op: String,
376        /// Human-readable description of the type mismatch.
377        msg: String,
378    },
379    /// An arithmetic operation failed (division/modulo by zero, or a
380    /// numeric result/operand that cannot be represented as `f64`).
381    #[error("arithmetic error in '{op}': {msg}")]
382    ArithError {
383        /// The op (e.g. `"div"`, `"mod"`, `"cmp"`) that raised.
384        op: String,
385        /// Human-readable description of the arithmetic failure.
386        msg: String,
387    },
388    /// The [`Dispatcher`] returned an error for the given `Step.ref`.
389    #[error("dispatcher error for ref '{ref_}': {msg}")]
390    DispatcherError {
391        /// The `Step.ref` that raised.
392        ref_: String,
393        /// The dispatcher's error message.
394        msg: String,
395    },
396    /// The [`Externs`] registry raised for the given `call_extern.ref` (e.g.
397    /// unregistered ref, or the extern fn itself returned an error).
398    #[error("extern error for ref '{ref_}': {msg}")]
399    ExternError {
400        /// The `call_extern.ref` that raised.
401        ref_: String,
402        /// The extern fn's error message.
403        msg: String,
404    },
405}
406
407// ──────────────────────────────────────────────────────────────────────────
408// Externs — whitelist registry for `call_extern` Expr (canonical opts.externs)
409// ──────────────────────────────────────────────────────────────────────────
410
411/// Extern registry: resolves a `call_extern.ref` against evaluated args and
412/// returns the value. Mirror of canonical `opts.externs` (flow-ir-lua
413/// `interpreter.lua`): each entry MUST be a pure function — no side effects,
414/// no flow control, value-shape manipulation only.
415///
416/// Same DI pattern as [`Dispatcher`]: host crates provide concrete
417/// implementations ([`ExternMap`] for plain Rust closures, mlua bridge for
418/// Lua functions upstream).
419pub trait Externs {
420    /// Invoke the extern registered under `ref_` with already-evaluated args.
421    /// Unregistered refs raise [`EvalError::ExternError`].
422    fn call(&self, ref_: &str, args: &[Value]) -> Result<Value, EvalError>;
423}
424
425/// Empty registry — every `call_extern` raises `ExternError` (parity with
426/// canonical "requires opts.externs" error). Used by the externs-less
427/// compat wrappers (`eval` / `eval_expr` / `eval_with_storage`).
428pub struct NoExterns;
429
430impl Externs for NoExterns {
431    fn call(&self, ref_: &str, _args: &[Value]) -> Result<Value, EvalError> {
432        Err(EvalError::ExternError {
433            ref_: ref_.into(),
434            msg: "no externs registry configured".into(),
435        })
436    }
437}
438
439/// Boxed pure extern function stored in [`ExternMap`].
440pub type ExternFn = Box<dyn Fn(&[Value]) -> Result<Value, EvalError> + Send + Sync>;
441
442/// `HashMap`-backed [`Externs`] impl for host-side Rust closures.
443///
444/// ```
445/// use flow_ir_core::{eval_expr_with_externs, EvalError, Expr, ExternMap};
446/// use serde_json::{json, Value};
447///
448/// let mut externs = ExternMap::new();
449/// externs.register("math.sqrt", |args: &[Value]| {
450///     let x = args
451///         .first()
452///         .and_then(|v| v.as_f64())
453///         .ok_or_else(|| EvalError::ExternError {
454///             ref_: "math.sqrt".into(),
455///             msg: "expected number".into(),
456///         })?;
457///     Ok(json!(x.sqrt()))
458/// });
459///
460/// let expr: Expr = serde_json::from_value(json!({
461///     "op": "call_extern", "ref": "math.sqrt",
462///     "args": [{ "op": "lit", "value": 9.0 }],
463/// })).unwrap();
464/// let out = eval_expr_with_externs(&expr, &json!({}), &externs).unwrap();
465/// assert_eq!(out, json!(3.0));
466/// ```
467#[derive(Default)]
468pub struct ExternMap {
469    fns: std::collections::HashMap<String, ExternFn>,
470}
471
472impl ExternMap {
473    /// Create an empty registry.
474    pub fn new() -> Self {
475        Self::default()
476    }
477
478    /// Register a pure function under `name` (overwrites an existing entry).
479    pub fn register<F>(&mut self, name: impl Into<String>, f: F)
480    where
481        F: Fn(&[Value]) -> Result<Value, EvalError> + Send + Sync + 'static,
482    {
483        self.fns.insert(name.into(), Box::new(f));
484    }
485
486    /// Whether `name` is registered (compile-time whitelist check parity).
487    pub fn contains(&self, name: &str) -> bool {
488        self.fns.contains_key(name)
489    }
490}
491
492impl Externs for ExternMap {
493    fn call(&self, ref_: &str, args: &[Value]) -> Result<Value, EvalError> {
494        let f = self.fns.get(ref_).ok_or_else(|| EvalError::ExternError {
495            ref_: ref_.into(),
496            msg: "not registered in externs".into(),
497        })?;
498        f(args)
499    }
500}
501
502// ──────────────────────────────────────────────────────────────────────────
503// CtxStorage — Backend DI for ctx state
504// ──────────────────────────────────────────────────────────────────────────
505
506/// Ctx backend trait — `eval(_with_storage)` 系が ctx state を touch する
507/// 唯一の経路。 `&self` write (interior mutability) で **走行中の Flow と
508/// 外部 task が同じ ctx を共有** できる (= dispatch().await suspend 中に外部
509/// task が `ctx.write` で State 注入 → resume 後 Step が read で観測、 という
510/// dynamic injection 経路を成立させる)。
511///
512/// Default impl は `MemoryCtx` (`Arc<Mutex<Value>>` wrapper、 既存
513/// `serde_json::Value` 直保持と挙動互換)。 consumer は typed struct / KV /
514/// 外部 store / observer wrap / event log 等を custom impl で持ち込める。
515pub trait CtxStorage: Send + Sync {
516    /// Read a single path (`$.a.b.c` 形式) from ctx.
517    fn read(&self, path: &str) -> Result<Value, EvalError>;
518    /// Write `value` to `path` (`$.a.b.c` 形式).
519    fn write(&self, path: &str, value: Value) -> Result<(), EvalError>;
520    /// Take a snapshot of the entire ctx (= Expr eval / Fanout fork で使う pure read view).
521    fn snapshot(&self) -> Value;
522    /// Replace the entire ctx with the given value (= Fanout branch restore 等).
523    fn replace(&self, value: Value);
524}
525
526/// Default `CtxStorage` impl — `Arc<Mutex<Value>>` wrapper。
527///
528/// Send + Sync かつ `&self` write OK = `Arc<MemoryCtx>` で外部 task と共有可能。
529pub struct MemoryCtx {
530    inner: std::sync::Mutex<Value>,
531}
532
533impl MemoryCtx {
534    /// Create a new MemoryCtx initialised with `ctx`.
535    pub fn new(ctx: Value) -> Self {
536        Self {
537            inner: std::sync::Mutex::new(ctx),
538        }
539    }
540
541    /// Convenience: wrap in `Arc<dyn CtxStorage>`.
542    pub fn shared(ctx: Value) -> std::sync::Arc<dyn CtxStorage> {
543        std::sync::Arc::new(Self::new(ctx))
544    }
545}
546
547impl CtxStorage for MemoryCtx {
548    fn read(&self, path: &str) -> Result<Value, EvalError> {
549        let guard = self.inner.lock().expect("ctx mutex poisoned");
550        read_path(path, &guard)
551    }
552
553    fn write(&self, path: &str, value: Value) -> Result<(), EvalError> {
554        let parsed: Path = path
555            .parse()
556            .map_err(|e: PathParseError| EvalError::InvalidPath(e.to_string()))?;
557        let mut guard = self.inner.lock().expect("ctx mutex poisoned");
558        parsed.write(&mut guard, value)
559    }
560
561    fn snapshot(&self) -> Value {
562        let guard = self.inner.lock().expect("ctx mutex poisoned");
563        guard.clone()
564    }
565
566    fn replace(&self, value: Value) {
567        let mut guard = self.inner.lock().expect("ctx mutex poisoned");
568        *guard = value;
569    }
570}
571
572/// Extract the already-parsed [`Path`] out of a `Path` `Expr`, or
573/// `InvalidPath` if `expr` is some other `Expr` variant. No parsing happens
574/// here — the `Path` was parsed once, at deserialize (or `Path::from_str`)
575/// time.
576fn path_of(expr: &Expr) -> Result<&Path, EvalError> {
577    match expr {
578        Expr::Path { at } => Ok(at),
579        _ => Err(EvalError::InvalidPath(
580            "expected Path expr for write target".into(),
581        )),
582    }
583}
584
585// ──────────────────────────────────────────────────────────────────────────
586// Evaluator — storage-backed (canonical) + legacy Value-passing wrapper
587// ──────────────────────────────────────────────────────────────────────────
588
589/// Storage-backed sync evaluator — `CtxStorage` 経由で ctx を touch する正本。
590///
591/// 各 Node 評価開始時に `ctx.snapshot()` で Expr eval 用の pure view を取り、
592/// write は `ctx.write(path, value)` 経由。 これにより同じ `Arc<dyn CtxStorage>`
593/// を共有する外部 task が、 Step 間 (sync の場合は 1 Step 評価内では touch
594/// しないが) や eval 間で ctx state を変更できる。
595pub fn eval_with_storage<D: Dispatcher>(
596    node: &Node,
597    ctx: &dyn CtxStorage,
598    dispatcher: &D,
599) -> Result<(), EvalError> {
600    eval_with_storage_externs(node, ctx, dispatcher, &NoExterns)
601}
602
603/// `eval_with_storage` + externs registry for `call_extern` Expr resolution.
604pub fn eval_with_storage_externs<D: Dispatcher>(
605    node: &Node,
606    ctx: &dyn CtxStorage,
607    dispatcher: &D,
608    externs: &dyn Externs,
609) -> Result<(), EvalError> {
610    match node {
611        Node::Step { ref_, in_, out } => {
612            let snap = ctx.snapshot();
613            let input = eval_expr_with_externs(in_, &snap, externs)?;
614            let output =
615                dispatcher
616                    .dispatch(ref_, input)
617                    .map_err(|e| EvalError::DispatcherError {
618                        ref_: ref_.clone(),
619                        msg: e.to_string(),
620                    })?;
621            ctx.write(&path_of(out)?.to_string(), output)
622        }
623        Node::Seq { children } => {
624            for child in children {
625                eval_with_storage_externs(child, ctx, dispatcher, externs)?;
626            }
627            Ok(())
628        }
629        Node::Branch { cond, then_, else_ } => {
630            let snap = ctx.snapshot();
631            match eval_expr_with_externs(cond, &snap, externs)? {
632                Value::Bool(true) => eval_with_storage_externs(then_, ctx, dispatcher, externs),
633                Value::Bool(false) => eval_with_storage_externs(else_, ctx, dispatcher, externs),
634                other => Err(EvalError::NonBoolCond(other)),
635            }
636        }
637        Node::Fanout {
638            items,
639            bind,
640            body,
641            join,
642            out,
643        } => {
644            // Fanout fork = 各 branch を disjoint MemoryCtx に切り出して逐次
645            // (sync) evaluate、 集約結果を共有 ctx の `out` path に書く。
646            let snap = ctx.snapshot();
647            let items_val = eval_expr_with_externs(items, &snap, externs)?;
648            let items_arr = match items_val {
649                Value::Array(a) => a,
650                other => {
651                    return Err(EvalError::TypeError {
652                        op: "fanout.items".into(),
653                        msg: format!("expected array, got {other:?}"),
654                    })
655                }
656            };
657            let joined =
658                fanout_eval_sync(bind, body, *join, &snap, items_arr, dispatcher, externs)?;
659            ctx.write(&path_of(out)?.to_string(), joined)
660        }
661        Node::Loop {
662            counter,
663            cond,
664            body,
665            max,
666        } => {
667            let counter_path = path_of(counter)?.to_string();
668            ctx.write(&counter_path, Value::Number(serde_json::Number::from(0u32)))?;
669            let mut n: u32 = 0;
670            loop {
671                if n >= *max {
672                    break;
673                }
674                let snap = ctx.snapshot();
675                if !is_truthy(&eval_expr_with_externs(cond, &snap, externs)?) {
676                    break;
677                }
678                eval_with_storage_externs(body, ctx, dispatcher, externs)?;
679                n += 1;
680                ctx.write(&counter_path, Value::Number(serde_json::Number::from(n)))?;
681            }
682            Ok(())
683        }
684        Node::Try {
685            body,
686            catch,
687            err_at,
688        } => {
689            // body 失敗時の rollback 用 snapshot
690            let snap_before = ctx.snapshot();
691            match eval_with_storage_externs(body, ctx, dispatcher, externs) {
692                Ok(()) => Ok(()),
693                Err(e) => {
694                    // body の途中 write を破棄 (Try semantic: rollback)
695                    ctx.replace(snap_before);
696                    if let Some(at) = err_at {
697                        ctx.write(&path_of(at)?.to_string(), Value::String(e.to_string()))?;
698                    }
699                    eval_with_storage_externs(catch, ctx, dispatcher, externs)
700                }
701            }
702        }
703        Node::Assign { at, value } => {
704            let snap = ctx.snapshot();
705            let v = eval_expr_with_externs(value, &snap, externs)?;
706            ctx.write(&path_of(at)?.to_string(), v)
707        }
708    }
709}
710
711/// Internal: fanout per-item sync evaluator (disjoint branch ctx).
712fn fanout_eval_sync<D: Dispatcher>(
713    bind: &Expr,
714    body: &Node,
715    join: JoinMode,
716    base_snap: &Value,
717    items_arr: Vec<Value>,
718    dispatcher: &D,
719    externs: &dyn Externs,
720) -> Result<Value, EvalError> {
721    match join {
722        JoinMode::All => {
723            let mut results = Vec::with_capacity(items_arr.len());
724            for item in items_arr {
725                let branch_ctx = write_path(bind, base_snap.clone(), item)?;
726                let storage = MemoryCtx::new(branch_ctx);
727                eval_with_storage_externs(body, &storage, dispatcher, externs)?;
728                results.push(storage.snapshot());
729            }
730            Ok(Value::Array(results))
731        }
732        JoinMode::Any => {
733            // Promise.any parity: zero items can never produce a winner, so
734            // (unlike All/AllSettled, whose empty-array result shape is
735            // still meaningful) this raises rather than returning `[]`.
736            if items_arr.is_empty() {
737                return Err(EvalError::TypeError {
738                    op: "fanout.any".into(),
739                    msg: "requires at least one item".into(),
740                });
741            }
742            let mut winner: Option<Value> = None;
743            let mut last_err: Option<EvalError> = None;
744            for item in items_arr {
745                let branch_ctx = write_path(bind, base_snap.clone(), item)?;
746                let storage = MemoryCtx::new(branch_ctx);
747                match eval_with_storage_externs(body, &storage, dispatcher, externs) {
748                    Ok(()) => {
749                        winner = Some(storage.snapshot());
750                        last_err = None;
751                        break;
752                    }
753                    Err(e) => last_err = Some(e),
754                }
755            }
756            if let Some(e) = last_err {
757                return Err(e);
758            }
759            Ok(winner.expect("non-empty items_arr always assigns a winner or returns an error"))
760        }
761        JoinMode::Race => {
762            // Same rationale as Any: zero branches means there is nothing to
763            // race, so this raises rather than returning `[]`.
764            let Some(first) = items_arr.into_iter().next() else {
765                return Err(EvalError::TypeError {
766                    op: "fanout.race".into(),
767                    msg: "requires at least one item".into(),
768                });
769            };
770            let branch_ctx = write_path(bind, base_snap.clone(), first)?;
771            let storage = MemoryCtx::new(branch_ctx);
772            eval_with_storage_externs(body, &storage, dispatcher, externs)?;
773            Ok(storage.snapshot())
774        }
775        JoinMode::AllSettled => {
776            let mut records = Vec::with_capacity(items_arr.len());
777            for item in items_arr {
778                let branch_ctx = write_path(bind, base_snap.clone(), item)?;
779                let storage = MemoryCtx::new(branch_ctx);
780                match eval_with_storage_externs(body, &storage, dispatcher, externs) {
781                    Ok(()) => records.push(
782                        serde_json::json!({"status": "fulfilled", "value": storage.snapshot()}),
783                    ),
784                    Err(e) => records
785                        .push(serde_json::json!({"status": "rejected", "reason": e.to_string()})),
786                }
787            }
788            Ok(Value::Array(records))
789        }
790    }
791}
792
793/// Legacy Value-passing sync evaluator — backward compat wrapper around
794/// `eval_with_storage` + `MemoryCtx`. `Value` を所有権で受け取り、 内部で
795/// `MemoryCtx::new(ctx)` を使って storage 版に委譲、 終了後の snapshot を返す。
796///
797/// 既存 caller (= dynamic injection を要求しない、 1-shot pure eval 用途) は
798/// 引き続きこの API で OK。 動的注入が要る場合は `eval_with_storage` を直接
799/// 呼ぶ。
800///
801/// Returns the updated context (= ctx with `Step.out` path written for each step traversed).
802pub fn eval<D: Dispatcher>(node: &Node, ctx: Value, dispatcher: &D) -> Result<Value, EvalError> {
803    eval_externs(node, ctx, dispatcher, &NoExterns)
804}
805
806/// `eval` + externs registry for `call_extern` Expr resolution.
807pub fn eval_externs<D: Dispatcher>(
808    node: &Node,
809    ctx: Value,
810    dispatcher: &D,
811    externs: &dyn Externs,
812) -> Result<Value, EvalError> {
813    let storage = MemoryCtx::new(ctx);
814    eval_with_storage_externs(node, &storage, dispatcher, externs)?;
815    Ok(storage.snapshot())
816}
817
818/// JSON value の truthy 判定 (= flow.ir Branch cond / Loop cond で使う)。
819/// Bool は値そのまま、 null/false 以外は truthy (Lua / JS と整合)。
820pub fn is_truthy(v: &Value) -> bool {
821    match v {
822        Value::Null => false,
823        Value::Bool(b) => *b,
824        _ => true,
825    }
826}
827
828/// Evaluate an `Expr` against a context value, returning the resolved JSON
829/// value. Externs-less compat wrapper — `call_extern` raises `ExternError`.
830pub fn eval_expr(expr: &Expr, ctx: &Value) -> Result<Value, EvalError> {
831    eval_expr_with_externs(expr, ctx, &NoExterns)
832}
833
834/// `eval_expr` + externs registry for `call_extern` Expr resolution.
835pub fn eval_expr_with_externs(
836    expr: &Expr,
837    ctx: &Value,
838    externs: &dyn Externs,
839) -> Result<Value, EvalError> {
840    let ev = |e: &Expr| eval_expr_with_externs(e, ctx, externs);
841    match expr {
842        Expr::Lit { value } => Ok(value.clone()),
843        // `at` is an already-parsed `Path` — no re-parse in this hot path.
844        Expr::Path { at } => at.read(ctx).cloned(),
845        Expr::Eq { lhs, rhs } => Ok(Value::Bool(json_eq(&ev(lhs)?, &ev(rhs)?))),
846        Expr::Ne { lhs, rhs } => Ok(Value::Bool(!json_eq(&ev(lhs)?, &ev(rhs)?))),
847        Expr::Lt { lhs, rhs } => ord_cmp(&ev(lhs)?, &ev(rhs)?, |o| o.is_lt()),
848        Expr::Lte { lhs, rhs } => ord_cmp(&ev(lhs)?, &ev(rhs)?, |o| o.is_le()),
849        Expr::Gt { lhs, rhs } => ord_cmp(&ev(lhs)?, &ev(rhs)?, |o| o.is_gt()),
850        Expr::Gte { lhs, rhs } => ord_cmp(&ev(lhs)?, &ev(rhs)?, |o| o.is_ge()),
851        Expr::Not { arg } => Ok(Value::Bool(!is_truthy(&ev(arg)?))),
852        Expr::And { args } => {
853            for a in args {
854                if !is_truthy(&ev(a)?) {
855                    return Ok(Value::Bool(false));
856                }
857            }
858            Ok(Value::Bool(true))
859        }
860        Expr::Or { args } => {
861            for a in args {
862                if is_truthy(&ev(a)?) {
863                    return Ok(Value::Bool(true));
864                }
865            }
866            Ok(Value::Bool(false))
867        }
868        Expr::Exists { arg } => match ev(arg) {
869            Ok(Value::Null) => Ok(Value::Bool(false)),
870            Ok(_) => Ok(Value::Bool(true)),
871            // canonical: a path to a missing key reads as nil → exists=false
872            Err(EvalError::PathNotFound(_)) => Ok(Value::Bool(false)),
873            Err(e) => Err(e),
874        },
875        Expr::Add { lhs, rhs } => num_arith(&ev(lhs)?, &ev(rhs)?, "add", |a, b| Some(a + b)),
876        Expr::Sub { lhs, rhs } => num_arith(&ev(lhs)?, &ev(rhs)?, "sub", |a, b| Some(a - b)),
877        Expr::Mul { lhs, rhs } => num_arith(&ev(lhs)?, &ev(rhs)?, "mul", |a, b| Some(a * b)),
878        Expr::Div { lhs, rhs } => num_arith(&ev(lhs)?, &ev(rhs)?, "div", |a, b| {
879            if b == 0.0 {
880                None
881            } else {
882                Some(a / b)
883            }
884        }),
885        // Lua `%` semantics (canonical): a - floor(a/b)*b, sign follows rhs.
886        Expr::Mod { lhs, rhs } => num_arith(&ev(lhs)?, &ev(rhs)?, "mod", |a, b| {
887            if b == 0.0 {
888                None
889            } else {
890                Some(a - (a / b).floor() * b)
891            }
892        }),
893        Expr::Len { arg } => {
894            let v = ev(arg)?;
895            let n = match &v {
896                Value::Array(a) => a.len(),
897                Value::String(s) => s.chars().count(),
898                Value::Object(o) => o.len(),
899                other => {
900                    return Err(EvalError::TypeError {
901                        op: "expr.len".into(),
902                        msg: format!("len: unsupported type {other:?}"),
903                    })
904                }
905            };
906            Ok(Value::Number(serde_json::Number::from(n as u64)))
907        }
908        Expr::In { needle, haystack } => {
909            let n = ev(needle)?;
910            let h = ev(haystack)?;
911            match h {
912                Value::Array(a) => Ok(Value::Bool(a.iter().any(|e| json_eq(e, &n)))),
913                other => Err(EvalError::TypeError {
914                    op: "expr.in".into(),
915                    msg: format!("in: haystack must be array, got {other:?}"),
916                }),
917            }
918        }
919        Expr::CallExtern { ref_, args } => {
920            let mut vals = Vec::with_capacity(args.len());
921            for a in args {
922                vals.push(ev(a)?);
923            }
924            externs.call(ref_, &vals)
925        }
926    }
927}
928
929/// Deep equality with Lua-parity numeric coercion: numbers compare by
930/// f64 value (so `5 == 5.0`), matching the coercion Lt/Lte/Gt/Gte use.
931/// Integers above 2^53 may lose precision — same caveat as ordering ops.
932fn json_eq(a: &Value, b: &Value) -> bool {
933    match (a, b) {
934        (Value::Number(na), Value::Number(nb)) => match (na.as_f64(), nb.as_f64()) {
935            (Some(fa), Some(fb)) => fa == fb,
936            // non-f64-representable (e.g. integers beyond 2^53): fall back
937            // to exact comparison rather than a lossy f64 round-trip.
938            _ => na == nb,
939        },
940        (Value::Array(aa), Value::Array(ab)) => {
941            aa.len() == ab.len() && aa.iter().zip(ab.iter()).all(|(x, y)| json_eq(x, y))
942        }
943        (Value::Object(oa), Value::Object(ob)) => {
944            oa.len() == ob.len()
945                && oa
946                    .iter()
947                    .all(|(k, v)| ob.get(k).is_some_and(|ov| json_eq(v, ov)))
948        }
949        _ => a == b,
950    }
951}
952
953/// Coerce a JSON value to f64 for numeric ops. A non-`Number` value is a
954/// `TypeError` (wrong JSON type); a `Number` that itself cannot be
955/// represented as `f64` (e.g. an integer beyond `f64`'s exact range) is an
956/// `ArithError` (right type, unrepresentable value).
957fn to_f64(v: &Value, op: &str) -> Result<f64, EvalError> {
958    match v {
959        Value::Number(n) => n.as_f64().ok_or_else(|| EvalError::ArithError {
960            op: op.into(),
961            msg: format!("non-f64-representable number: {n}"),
962        }),
963        other => Err(EvalError::TypeError {
964            op: op.into(),
965            msg: format!("expected number, got {other:?}"),
966        }),
967    }
968}
969
970/// Ordering comparison over two evaluated values. Mirrors canonical Lua
971/// `< / <= / > / >=`: both numbers (f64) or both strings (lexicographic
972/// byte order, same as Lua's string comparison for UTF-8); anything else
973/// raises.
974fn ord_cmp<F>(lv: &Value, rv: &Value, f: F) -> Result<Value, EvalError>
975where
976    F: Fn(std::cmp::Ordering) -> bool,
977{
978    let ord = match (lv, rv) {
979        (Value::Number(_), Value::Number(_)) => {
980            let l = to_f64(lv, "cmp")?;
981            let r = to_f64(rv, "cmp")?;
982            l.partial_cmp(&r).ok_or_else(|| EvalError::ArithError {
983                op: "cmp".into(),
984                msg: "non-comparable numbers (NaN)".into(),
985            })?
986        }
987        (Value::String(l), Value::String(r)) => l.cmp(r),
988        (l, r) => {
989            return Err(EvalError::TypeError {
990                op: "cmp".into(),
991                msg: format!("cmp: both sides must be numbers or strings, got {l:?} vs {r:?}"),
992            })
993        }
994    };
995    Ok(Value::Bool(f(ord)))
996}
997
998fn num_arith<F>(lv: &Value, rv: &Value, op: &str, f: F) -> Result<Value, EvalError>
999where
1000    F: Fn(f64, f64) -> Option<f64>,
1001{
1002    let l = to_f64(lv, op)?;
1003    let r = to_f64(rv, op)?;
1004    let result = f(l, r).ok_or_else(|| EvalError::ArithError {
1005        op: op.into(),
1006        msg: "arithmetic failure (e.g. division by zero)".into(),
1007    })?;
1008    let n = serde_json::Number::from_f64(result).ok_or_else(|| EvalError::ArithError {
1009        op: op.into(),
1010        msg: format!("result not f64-representable: {result}"),
1011    })?;
1012    Ok(Value::Number(n))
1013}
1014
1015// ──────────────────────────────────────────────────────────────────────────
1016// Path compat wrappers — thin `&str` entry points over the typed `Path` in
1017// `path.rs`, which is the single authority for path syntax + rejection
1018// rules. See [`Path`] docs for the full syntax (dot form, RFC 9535-style
1019// bracket notation, uniform malformed-path rejections).
1020// ──────────────────────────────────────────────────────────────────────────
1021
1022/// Parse `path` and read the value it resolves to inside `ctx`.
1023///
1024/// Thin wrapper: parses `path` via [`str::parse`] (surfacing a parse
1025/// failure as [`EvalError::InvalidPath`]) then delegates to [`Path::read`].
1026/// Callers holding an already-parsed `Path` (e.g. from an `Expr::Path`)
1027/// should call [`Path::read`] directly to avoid re-parsing.
1028pub fn read_path(path: &str, ctx: &Value) -> Result<Value, EvalError> {
1029    let parsed: Path = path
1030        .parse()
1031        .map_err(|e: PathParseError| EvalError::InvalidPath(e.to_string()))?;
1032    parsed.read(ctx).cloned()
1033}
1034
1035/// Write a value at the path location inside ctx, returning the updated ctx.
1036/// `out` must be a `Path` Expr (its `at` field is an already-parsed `Path`,
1037/// so no re-parsing happens here — this is a thin wrapper around
1038/// [`Path::write`], adapted to the `Value`-in/`Value`-out shape the rest of
1039/// this crate's legacy (non-`CtxStorage`) API uses).
1040pub fn write_path(out: &Expr, ctx: Value, value: Value) -> Result<Value, EvalError> {
1041    let path = path_of(out)?;
1042    let mut root = ctx;
1043    path.write(&mut root, value)?;
1044    Ok(root)
1045}