Skip to main content

lex_runtime/
builtins.rs

1//! Pure stdlib builtins — string, numeric, list, option, result, json
2//! ops dispatched via the same `EffectHandler` interface as effects, but
3//! without policy gates (they have no observable side effects).
4
5use lex_bytecode::{MapKey, Value};
6use std::collections::{BTreeMap, BTreeSet, HashMap};
7use std::sync::{Mutex, OnceLock};
8
9/// Returns `true` if `(kind, op)` will be handled by the pure-builtin
10/// path (no side effects, no policy gate needed). Used by the effect
11/// handler to decide whether to consume `args` by value.
12pub fn is_pure_call(kind: &str, op: &str) -> bool {
13    if !is_pure_module(kind) { return false; }
14    !matches!(
15        (kind, op),
16        ("crypto", "random")
17        | ("crypto", "random_str_hex")
18        | ("datetime", "now")
19        | ("http", "send")
20        | ("http", "get")
21        | ("http", "post")
22        | ("http", "stream_lines")
23        // arrow.read_csv reads from disk → effect-handler path (#426 I/O slice).
24        | ("arrow", "read_csv")
25        // arrow.{read,write}_parquet + arrow.write_csv — effect-gated I/O (#432).
26        | ("arrow", "read_parquet")
27        | ("arrow", "read_parquet_cols")
28        | ("arrow", "write_parquet")
29        | ("arrow", "write_csv")
30    )
31}
32
33/// Dispatch a pure-builtin call with owned args (no clone of arg values).
34/// Callers must first verify `is_pure_call(kind, op)` to ensure args
35/// ownership is only transferred for known-pure ops.
36///
37/// `list.cons` is handled here with move semantics so the tail `Vec<Value>`
38/// is extended without cloning each element (#405).
39pub fn call_pure_builtin(kind: &str, op: &str, args: Vec<Value>) -> Result<Value, String> {
40    if (kind, op) == ("list", "cons") {
41        let mut it = args.into_iter();
42        let head = it.next().unwrap_or(Value::Unit);
43        let mut tail = match it.next() {
44            Some(Value::List(v)) => v,
45            Some(other) => return Err(format!("list.cons: expected List, got {other:?}")),
46            None => std::collections::VecDeque::new(),
47        };
48        tail.push_front(head);
49        return Ok(Value::List(tail));
50    }
51    dispatch(kind, op, &args)
52}
53
54/// Returns Some(...) if `(kind, op)` names a known pure builtin.
55/// `None` means "not handled here; fall through to effect dispatch".
56///
57/// Prefer `is_pure_call` + `call_pure_builtin` in hot paths — this
58/// variant takes `&[Value]` and must clone args for operations like
59/// `list.cons`; kept for external callers that already hold a slice.
60pub fn try_pure_builtin(kind: &str, op: &str, args: &[Value]) -> Option<Result<Value, String>> {
61    if !is_pure_call(kind, op) { return None; }
62    Some(dispatch(kind, op, args))
63}
64
65/// `kind` is one of the known pure module aliases — used by the policy
66/// walk to skip pure builtins that programs reference via imports.
67pub fn is_pure_module(kind: &str) -> bool {
68    matches!(kind, "str" | "int" | "float" | "bool" | "list" | "iter"
69        | "option" | "result" | "tuple" | "json" | "bytes" | "flow" | "math"
70        | "map" | "set" | "crypto" | "regex" | "deque" | "datetime" | "duration" | "http"
71        | "toml" | "yaml" | "dotenv" | "csv" | "test" | "random" | "parser"
72        | "cli" | "arrow" | "df" | "decimal")
73}
74
75fn dispatch(kind: &str, op: &str, args: &[Value]) -> Result<Value, String> {
76    match (kind, op) {
77        // -- str --
78        ("str", "is_empty") => Ok(Value::Bool(expect_str(args.first())?.is_empty())),
79        ("str", "len") => Ok(Value::Int(expect_str(args.first())?.len() as i64)),
80        ("str", "concat") => {
81            let a = expect_str(args.first())?;
82            let b = expect_str(args.get(1))?;
83            Ok(Value::Str(format!("{a}{b}").into()))
84        }
85        ("str", "to_int") => {
86            let s = expect_str(args.first())?;
87            match s.parse::<i64>() {
88                Ok(n) => Ok(some(Value::Int(n))),
89                Err(_) => Ok(none()),
90            }
91        }
92        ("str", "split") => {
93            let s = expect_str(args.first())?;
94            let sep = expect_str(args.get(1))?;
95            let items: std::collections::VecDeque<Value> = if sep.is_empty() {
96                s.chars().map(|c| Value::Str(c.to_string().into())).collect()
97            } else {
98                s.split(sep.as_str()).map(|p| Value::Str(p.into())).collect()
99            };
100            Ok(Value::List(items))
101        }
102        ("str", "join") => {
103            let parts = expect_list(args.first())?;
104            let sep = expect_str(args.get(1))?;
105            let mut out = String::new();
106            for (i, p) in parts.iter().enumerate() {
107                if i > 0 { out.push_str(&sep); }
108                match p {
109                    Value::Str(s) => out.push_str(s),
110                    other => return Err(format!("str.join element must be Str, got {other:?}")),
111                }
112            }
113            Ok(Value::Str(out.into()))
114        }
115        ("str", "starts_with") => {
116            let s = expect_str(args.first())?;
117            let prefix = expect_str(args.get(1))?;
118            Ok(Value::Bool(s.starts_with(prefix.as_str())))
119        }
120        ("str", "ends_with") => {
121            let s = expect_str(args.first())?;
122            let suffix = expect_str(args.get(1))?;
123            Ok(Value::Bool(s.ends_with(suffix.as_str())))
124        }
125        ("str", "contains") => {
126            let s = expect_str(args.first())?;
127            let needle = expect_str(args.get(1))?;
128            Ok(Value::Bool(s.contains(needle.as_str())))
129        }
130        ("str", "cmp") => {
131            let a = expect_str(args.first())?;
132            let b = expect_str(args.get(1))?;
133            Ok(Value::Int(match a.as_str().cmp(b.as_str()) {
134                std::cmp::Ordering::Less => -1,
135                std::cmp::Ordering::Equal => 0,
136                std::cmp::Ordering::Greater => 1,
137            }))
138        }
139        ("str", "replace") => {
140            let s = expect_str(args.first())?;
141            let from = expect_str(args.get(1))?;
142            let to = expect_str(args.get(2))?;
143            Ok(Value::Str(s.replace(from.as_str(), to.as_str()).into()))
144        }
145        ("str", "trim") => Ok(Value::Str(expect_str(args.first())?.trim().into())),
146        ("str", "to_upper") => Ok(Value::Str(expect_str(args.first())?.to_uppercase().into())),
147        ("str", "to_lower") => Ok(Value::Str(expect_str(args.first())?.to_lowercase().into())),
148        ("str", "strip_prefix") => {
149            let s = expect_str(args.first())?;
150            let prefix = expect_str(args.get(1))?;
151            Ok(match s.strip_prefix(prefix.as_str()) {
152                Some(rest) => some(Value::Str(rest.into())),
153                None => none(),
154            })
155        }
156        ("str", "strip_suffix") => {
157            let s = expect_str(args.first())?;
158            let suffix = expect_str(args.get(1))?;
159            Ok(match s.strip_suffix(suffix.as_str()) {
160                Some(rest) => some(Value::Str(rest.into())),
161                None => none(),
162            })
163        }
164        ("str", "slice") => {
165            // Half-open byte-range slice. `hi` is clamped to `s.len()`
166            // and a negative `lo` / `hi` clamps to `0`, mirroring
167            // Python's `s[lo:hi]` semantics (and matching what
168            // production users expect when slicing fixed sizes off
169            // a possibly-shorter string — e.g. the first 64 chars
170            // of a license header). Reversed ranges (`lo > hi` after
171            // clamping) error since that's a caller logic bug. A
172            // mid-codepoint `lo` after clamping still errors so
173            // silent UTF-8 truncation never sneaks through.
174            let s = expect_str(args.first())?;
175            let lo_i = expect_int(args.get(1))?;
176            let hi_i = expect_int(args.get(2))?;
177            let lo = (lo_i.max(0) as usize).min(s.len());
178            let hi = (hi_i.max(0) as usize).min(s.len());
179            if lo > hi {
180                return Err(format!(
181                    "str.slice: reversed range [{lo}..{hi}] (after clamping to len {})",
182                    s.len()));
183            }
184            if !s.is_char_boundary(lo) || !s.is_char_boundary(hi) {
185                return Err(format!("str.slice: [{lo}..{hi}] not on char boundaries"));
186            }
187            Ok(Value::Str(s[lo..hi].into()))
188        }
189
190        // -- int / float --
191        ("int", "to_str") => Ok(Value::Str(expect_int(args.first())?.to_string().into())),
192        ("int", "to_float") => Ok(Value::Float(expect_int(args.first())? as f64)),
193        ("float", "to_int") => Ok(Value::Int(expect_float(args.first())? as i64)),
194        ("float", "to_str") => Ok(Value::Str(expect_float(args.first())?.to_string().into())),
195        ("str", "to_float") => {
196            let s = expect_str(args.first())?;
197            match s.parse::<f64>() {
198                Ok(f) => Ok(some(Value::Float(f))),
199                Err(_) => Ok(none()),
200            }
201        }
202
203        // -- list --
204        ("list", "len") => Ok(Value::Int(expect_list(args.first())?.len() as i64)),
205        ("list", "is_empty") => Ok(Value::Bool(expect_list(args.first())?.is_empty())),
206        ("list", "head") => {
207            let xs = expect_list(args.first())?;
208            match xs.front() {
209                Some(v) => Ok(some(v.clone())),
210                None => Ok(none()),
211            }
212        }
213        ("list", "tail") => {
214            let xs = expect_list(args.first())?;
215            if xs.is_empty() { Ok(Value::List(std::collections::VecDeque::new())) }
216            else { Ok(Value::List(xs.iter().skip(1).cloned().collect::<std::collections::VecDeque<_>>())) }
217        }
218        ("list", "range") => {
219            let lo = expect_int(args.first())?;
220            let hi = expect_int(args.get(1))?;
221            Ok(Value::List((lo..hi).map(Value::Int).collect::<std::collections::VecDeque<_>>()))
222        }
223        ("list", "concat") => {
224            let mut out = expect_list(args.first())?.clone();
225            out.extend(expect_list(args.get(1))?.iter().cloned());
226            Ok(Value::List(out))
227        }
228        ("list", "reverse") => {
229            let out = expect_list(args.first())?.clone();
230            let rev: std::collections::VecDeque<Value> = out.into_iter().rev().collect();
231            Ok(Value::List(rev))
232        }
233        // #334: cons — prepend a single element to a list.
234        // (fast path via call_pure_builtin; this branch handles the
235        // borrow-based dispatch path which must clone)
236        ("list", "cons") => {
237            let head = args.first().cloned().unwrap_or(Value::Unit);
238            let mut out: std::collections::VecDeque<Value> =
239                expect_list(args.get(1))?.iter().cloned().collect();
240            out.push_front(head);
241            Ok(Value::List(out))
242        }
243        ("list", "enumerate") => {
244            let xs = expect_list(args.first())?;
245            let pairs = xs.iter().cloned().enumerate()
246                .map(|(i, v)| Value::Tuple(vec![Value::Int(i as i64), v]))
247                .collect::<std::collections::VecDeque<_>>();
248            Ok(Value::List(pairs))
249        }
250
251        // -- tuple --
252        // Per §11.1: fst, snd, third for 2- and 3-tuples. Index out of
253        // range is an error rather than a panic so calling `tuple.third`
254        // on a 2-tuple is a clean failure instead of a host crash.
255        ("tuple", "fst")   => tuple_index(first_arg(args)?, 0),
256        ("tuple", "snd")   => tuple_index(first_arg(args)?, 1),
257        ("tuple", "third") => tuple_index(first_arg(args)?, 2),
258        ("tuple", "len") => match first_arg(args)? {
259            Value::Tuple(items) => Ok(Value::Int(items.len() as i64)),
260            other => Err(format!("tuple.len: expected Tuple, got {other:?}")),
261        },
262
263        // -- option --
264        ("option", "unwrap_or") => {
265            let opt = first_arg(args)?;
266            let default = args.get(1).cloned().unwrap_or(Value::Unit);
267            match opt {
268                Value::Variant { name, args } if name == "Some" && !args.is_empty() => Ok(args[0].clone()),
269                Value::Variant { name, .. } if name == "None" => Ok(default),
270                other => Err(format!("option.unwrap_or expected Option, got {other:?}")),
271            }
272        }
273        // option.unwrap_or_else: lazy default via thunk — only called when None.
274        // Handled inline by the bytecode compiler; this arm is the interpreter
275        // fallback path (thunk is pre-applied as a Value::Unit default since the
276        // runtime cannot call closures itself — the compiler path is canonical).
277        ("option", "unwrap_or_else") => {
278            let opt = first_arg(args)?;
279            match opt {
280                Value::Variant { name, args } if name == "Some" && !args.is_empty() => Ok(args[0].clone()),
281                Value::Variant { name, .. } if name == "None" => {
282                    // The closure argument cannot be invoked from pure-builtin
283                    // context; callers that reach this path have already
284                    // evaluated the thunk and passed its result as args[1].
285                    Ok(args.get(1).cloned().unwrap_or(Value::Unit))
286                }
287                other => Err(format!("option.unwrap_or_else expected Option, got {other:?}")),
288            }
289        }
290        ("option", "is_some") => match first_arg(args)? {
291            Value::Variant { name, .. } => Ok(Value::Bool(name == "Some")),
292            other => Err(format!("option.is_some expected Option, got {other:?}")),
293        },
294        ("option", "is_none") => match first_arg(args)? {
295            Value::Variant { name, .. } => Ok(Value::Bool(name == "None")),
296            other => Err(format!("option.is_none expected Option, got {other:?}")),
297        },
298
299        // -- result --
300        ("result", "is_ok") => match first_arg(args)? {
301            Value::Variant { name, .. } => Ok(Value::Bool(name == "Ok")),
302            other => Err(format!("result.is_ok expected Result, got {other:?}")),
303        },
304        ("result", "is_err") => match first_arg(args)? {
305            Value::Variant { name, .. } => Ok(Value::Bool(name == "Err")),
306            other => Err(format!("result.is_err expected Result, got {other:?}")),
307        },
308        ("result", "unwrap_or") => {
309            let res = first_arg(args)?;
310            let default = args.get(1).cloned().unwrap_or(Value::Unit);
311            match res {
312                Value::Variant { name, args } if name == "Ok" && !args.is_empty() => Ok(args[0].clone()),
313                Value::Variant { name, .. } if name == "Err" => Ok(default),
314                other => Err(format!("result.unwrap_or expected Result, got {other:?}")),
315            }
316        }
317
318        // -- json --
319        ("json", "stringify") => {
320            let v = first_arg(args)?;
321            Ok(Value::Str(serde_json::to_string(&value_to_json(v)).unwrap_or_default().into()))
322        }
323        ("json", "parse") => {
324            let s = expect_str(args.first())?;
325            match serde_json::from_str::<serde_json::Value>(&s) {
326                Ok(v) => Ok(ok_v(json_to_value(&v))),
327                Err(e) => Ok(err_v(Value::Str(format!("{e}").into()))),
328            }
329        }
330        // Tactical fix for #168: validate required fields before
331        // returning Ok. #322: also validate field types via schema.
332        ("json", "parse_strict") => {
333            let s = expect_str(args.first())?;
334            let required = required_field_names(args.get(1))?;
335            let schema = extract_type_schema(args.get(2));
336            match serde_json::from_str::<serde_json::Value>(&s) {
337                Ok(v) => {
338                    if let Err(e) = check_required_fields(&v, &required) {
339                        return Ok(err_v(Value::Str(e.into())));
340                    }
341                    if let Err(e) = validate_field_types(&v, &schema) {
342                        return Ok(err_v(Value::Str(e.into())));
343                    }
344                    Ok(ok_v(apply_option_wrapping(json_to_value(&v), &v, &schema)))
345                }
346                Err(e) => Ok(err_v(Value::Str(format!("{e}").into()))),
347            }
348        }
349
350        // -- toml (config parser; routes through serde_json::Value
351        // so the parsed shape composes with the existing json
352        // tooling. Datetimes become RFC 3339 strings — the only
353        // info-losing step) --
354        ("toml", "parse") => {
355            let s = expect_str(args.first())?;
356            match toml::from_str::<serde_json::Value>(&s) {
357                Ok(mut v) => {
358                    unwrap_toml_datetime_markers(&mut v);
359                    Ok(ok_v(json_to_value(&v)))
360                }
361                Err(e) => Ok(err_v(Value::Str(format!("{e}").into()))),
362            }
363        }
364        // Compiler-emitted variant of parse_strict that carries the type
365        // schema injected by the type-checker rewrite pass (#322).
366        // Identical to parse_strict but the 3rd arg (schema) is always present.
367        ("json", "parse_strict_typed") => {
368            let s = expect_str(args.first())?;
369            let required = required_field_names(args.get(1))?;
370            let schema = extract_type_schema(args.get(2));
371            match serde_json::from_str::<serde_json::Value>(&s) {
372                Ok(v) => {
373                    if let Err(e) = check_required_fields(&v, &required) {
374                        return Ok(err_v(Value::Str(e.into())));
375                    }
376                    if let Err(e) = validate_field_types(&v, &schema) {
377                        return Ok(err_v(Value::Str(e.into())));
378                    }
379                    Ok(ok_v(apply_option_wrapping(json_to_value(&v), &v, &schema)))
380                }
381                Err(e) => Ok(err_v(Value::Str(format!("{e}").into()))),
382            }
383        }
384
385        // Tactical fix for #168: validate required fields before
386        // returning Ok. #322: also validate field types via schema.
387        ("toml", "parse_strict") => {
388            let s = expect_str(args.first())?;
389            let required = required_field_names(args.get(1))?;
390            let schema = extract_type_schema(args.get(2));
391            match toml::from_str::<serde_json::Value>(&s) {
392                Ok(mut v) => {
393                    unwrap_toml_datetime_markers(&mut v);
394                    if let Err(e) = check_required_fields(&v, &required) {
395                        return Ok(err_v(Value::Str(e.into())));
396                    }
397                    if let Err(e) = validate_field_types(&v, &schema) {
398                        return Ok(err_v(Value::Str(e.into())));
399                    }
400                    Ok(ok_v(apply_option_wrapping(json_to_value(&v), &v, &schema)))
401                }
402                Err(e) => Ok(err_v(Value::Str(format!("{e}").into()))),
403            }
404        }
405        ("toml", "parse_strict_typed") => {
406            let s = expect_str(args.first())?;
407            let required = required_field_names(args.get(1))?;
408            let schema = extract_type_schema(args.get(2));
409            match toml::from_str::<serde_json::Value>(&s) {
410                Ok(mut v) => {
411                    unwrap_toml_datetime_markers(&mut v);
412                    if let Err(e) = check_required_fields(&v, &required) {
413                        return Ok(err_v(Value::Str(e.into())));
414                    }
415                    if let Err(e) = validate_field_types(&v, &schema) {
416                        return Ok(err_v(Value::Str(e.into())));
417                    }
418                    Ok(ok_v(apply_option_wrapping(json_to_value(&v), &v, &schema)))
419                }
420                Err(e) => Ok(err_v(Value::Str(format!("{e}").into()))),
421            }
422        }
423        ("toml", "stringify") => {
424            let v = first_arg(args)?;
425            // serde_json::Value → toml::Value via its serde impls.
426            // TOML's grammar is stricter than JSON's (top-level
427            // must be a table; no `null`; no mixed-type arrays),
428            // so the conversion can fail — surface as Result::Err
429            // rather than panic.
430            let json = value_to_json(v);
431            match toml::to_string(&json) {
432                Ok(s)  => Ok(ok_v(Value::Str(s.into()))),
433                Err(e) => Ok(err_v(Value::Str(format!("toml.stringify: {e}").into()))),
434            }
435        }
436
437        // -- yaml -- mirrors std.toml. Wraps serde_yaml so values
438        // map to the same Lex shape as JSON. YAML's Tag/Anchor
439        // features are folded out by serde_yaml's deserialize-to-
440        // Value path; non-representable shapes (e.g. non-string
441        // map keys when stringifying) surface as Result::Err.
442        ("yaml", "parse") => {
443            let s = expect_str(args.first())?;
444            match serde_yaml::from_str::<serde_json::Value>(&s) {
445                Ok(v)  => Ok(ok_v(json_to_value(&v))),
446                Err(e) => Ok(err_v(Value::Str(format!("{e}").into()))),
447            }
448        }
449        // Tactical fix for #168 — same shape as toml.parse_strict.
450        // #322: also validate field types via schema.
451        ("yaml", "parse_strict") => {
452            let s = expect_str(args.first())?;
453            let required = required_field_names(args.get(1))?;
454            let schema = extract_type_schema(args.get(2));
455            match serde_yaml::from_str::<serde_json::Value>(&s) {
456                Ok(v) => {
457                    if let Err(e) = check_required_fields(&v, &required) {
458                        return Ok(err_v(Value::Str(e.into())));
459                    }
460                    if let Err(e) = validate_field_types(&v, &schema) {
461                        return Ok(err_v(Value::Str(e.into())));
462                    }
463                    Ok(ok_v(json_to_value(&v)))
464                }
465                Err(e) => Ok(err_v(Value::Str(format!("{e}").into()))),
466            }
467        }
468        ("yaml", "parse_strict_typed") => {
469            let s = expect_str(args.first())?;
470            let required = required_field_names(args.get(1))?;
471            let schema = extract_type_schema(args.get(2));
472            match serde_yaml::from_str::<serde_json::Value>(&s) {
473                Ok(v) => {
474                    if let Err(e) = check_required_fields(&v, &required) {
475                        return Ok(err_v(Value::Str(e.into())));
476                    }
477                    if let Err(e) = validate_field_types(&v, &schema) {
478                        return Ok(err_v(Value::Str(e.into())));
479                    }
480                    Ok(ok_v(json_to_value(&v)))
481                }
482                Err(e) => Ok(err_v(Value::Str(format!("{e}").into()))),
483            }
484        }
485        ("yaml", "stringify") => {
486            let v = first_arg(args)?;
487            let json = value_to_json(v);
488            match serde_yaml::to_string(&json) {
489                Ok(s)  => Ok(ok_v(Value::Str(s.into()))),
490                Err(e) => Ok(err_v(Value::Str(format!("yaml.stringify: {e}").into()))),
491            }
492        }
493
494        // -- dotenv -- KEY=VALUE pair files. Hand-rolled parser
495        // because the dotenvy crate's API is geared at loading
496        // into the process env, not parsing-to-data. The grammar
497        // we accept: blank lines, `# comment` lines, and
498        // `KEY=VALUE` (optional surrounding `"..."` or `'...'`,
499        // unescaped). Simple but covers the .env files in the
500        // wild that aren't trying to be shell.
501        ("dotenv", "parse") => {
502            use std::collections::BTreeMap;
503            use lex_bytecode::MapKey;
504            let s = expect_str(args.first())?;
505            match parse_dotenv(&s) {
506                Ok(map) => {
507                    let mut bt: BTreeMap<MapKey, Value> = BTreeMap::new();
508                    for (k, v) in map {
509                        bt.insert(MapKey::Str(k), Value::Str(v.into()));
510                    }
511                    Ok(ok_v(Value::Map(bt)))
512                }
513                Err(e) => Ok(err_v(Value::Str(e.into()))),
514            }
515        }
516
517        // -- csv -- rows-as-lists; first row is whatever the file
518        // has. The caller decides whether row 0 is a header. We
519        // could ship a `parse_with_headers` later that returns a
520        // List[Map[Str, Str]]; v1 keeps the surface tight.
521        ("csv", "parse") => {
522            let s = expect_str(args.first())?;
523            let mut rdr = csv::ReaderBuilder::new()
524                .has_headers(false)
525                .flexible(true)
526                .from_reader(s.as_bytes());
527            let mut rows: std::collections::VecDeque<Value> = std::collections::VecDeque::new();
528            for r in rdr.records() {
529                match r {
530                    Ok(rec) => {
531                        let row: std::collections::VecDeque<Value> = rec.iter()
532                            .map(|f| Value::Str(f.into()))
533                            .collect();
534                        rows.push_back(Value::List(row));
535                    }
536                    Err(e) => return Ok(err_v(Value::Str(format!("csv.parse: {e}").into()))),
537                }
538            }
539            Ok(ok_v(Value::List(rows)))
540        }
541        ("csv", "stringify") => {
542            // List[List[Str]] → CSV string. Mixed-type rows are
543            // not allowed (CSV is text-only); non-Str cells get
544            // stringified via to_json since that's already the
545            // convention for `json.stringify` etc.
546            let v = first_arg(args)?;
547            let rows = match v {
548                Value::List(rs) => rs,
549                _ => return Ok(err_v(Value::Str("csv.stringify expects List[List[Str]]".into()))),
550            };
551            let mut out = Vec::new();
552            {
553                let mut wtr = csv::WriterBuilder::new()
554                    .has_headers(false)
555                    .from_writer(&mut out);
556                for row in rows {
557                    let cells = match row {
558                        Value::List(cs) => cs,
559                        _ => return Ok(err_v(Value::Str("csv.stringify row must be List[Str]".into()))),
560                    };
561                    let strs: Vec<String> = cells.iter().map(|c| match c {
562                        Value::Str(s) => s.to_string(),
563                        other => serde_json::to_string(&other.to_json())
564                            .unwrap_or_else(|_| String::new()),
565                    }).collect();
566                    if let Err(e) = wtr.write_record(&strs) {
567                        return Ok(err_v(Value::Str(format!("csv.stringify: {e}").into())));
568                    }
569                }
570                if let Err(e) = wtr.flush() {
571                    return Ok(err_v(Value::Str(format!("csv.stringify flush: {e}").into())));
572                }
573            }
574            match String::from_utf8(out) {
575                Ok(s) => Ok(ok_v(Value::Str(s.into()))),
576                Err(e) => Ok(err_v(Value::Str(format!("csv.stringify utf8: {e}").into()))),
577            }
578        }
579
580        // -- test -- tiny assertion library. Each helper is pure
581        // and returns `Result[Unit, Str]` so tests are themselves
582        // functions returning a Result. A suite is a List the user
583        // iterates with `list.fold`; no Rust-side Suite/Runner
584        // types in v1, so the whole thing is 4 builtins + a few
585        // Lex-source helpers callers can copy into their tests/.
586        ("test", "assert_eq") => {
587            let a = first_arg(args)?;
588            let b = args.get(1).ok_or("test.assert_eq: missing second arg")?;
589            if a == b {
590                Ok(ok_v(Value::Unit))
591            } else {
592                Ok(err_v(Value::Str(format!("assert_eq: lhs {} != rhs {}",
593                    value_to_json(a), value_to_json(b)).into())))
594            }
595        }
596        ("test", "assert_ne") => {
597            let a = first_arg(args)?;
598            let b = args.get(1).ok_or("test.assert_ne: missing second arg")?;
599            if a != b {
600                Ok(ok_v(Value::Unit))
601            } else {
602                Ok(err_v(Value::Str(format!("assert_ne: both sides are {}",
603                    value_to_json(a)).into())))
604            }
605        }
606        ("test", "assert_true") => {
607            match first_arg(args)? {
608                Value::Bool(true) => Ok(ok_v(Value::Unit)),
609                Value::Bool(false) => Ok(err_v(Value::Str("assert_true: was false".into()))),
610                other => Err(format!("test.assert_true expects Bool, got {other:?}")),
611            }
612        }
613        ("test", "assert_false") => {
614            match first_arg(args)? {
615                Value::Bool(false) => Ok(ok_v(Value::Unit)),
616                Value::Bool(true)  => Ok(err_v(Value::Str("assert_false: was true".into()))),
617                other => Err(format!("test.assert_false expects Bool, got {other:?}")),
618            }
619        }
620
621        // -- bytes --
622        ("bytes", "len") => {
623            let b = expect_bytes(args.first())?;
624            Ok(Value::Int(b.len() as i64))
625        }
626        ("bytes", "eq") => {
627            let a = expect_bytes(args.first())?;
628            let b = expect_bytes(args.get(1))?;
629            Ok(Value::Bool(a == b))
630        }
631        ("bytes", "from_str") => {
632            let s = expect_str(args.first())?;
633            Ok(Value::Bytes(s.into_bytes()))
634        }
635        ("bytes", "to_str") => {
636            let b = expect_bytes(args.first())?;
637            match String::from_utf8(b.to_vec()) {
638                Ok(s) => Ok(ok_v(Value::Str(s.into()))),
639                Err(e) => Ok(err_v(Value::Str(format!("{e}").into()))),
640            }
641        }
642        ("bytes", "slice") => {
643            let b = expect_bytes(args.first())?;
644            let lo = expect_int(args.get(1))? as usize;
645            let hi = expect_int(args.get(2))? as usize;
646            if lo > hi || hi > b.len() {
647                return Err(format!("bytes.slice: out of range [{lo}..{hi}] of {}", b.len()));
648            }
649            Ok(Value::Bytes(b[lo..hi].to_vec()))
650        }
651        ("bytes", "is_empty") => {
652            let b = expect_bytes(args.first())?;
653            Ok(Value::Bool(b.is_empty()))
654        }
655
656        // -- math --
657        // Matrices are stored as the F64Array fast-lane variant (a flat
658        // row-major Vec<f64> with shape). Lex code treats them as the
659        // type alias `Matrix = { rows :: Int, cols :: Int, data ::
660        // List[Float] }`; field access is unsupported, so all
661        // introspection happens through these helpers.
662        ("math", "exp")   => Ok(Value::Float(expect_float(args.first())?.exp())),
663        ("math", "log")   => Ok(Value::Float(expect_float(args.first())?.ln())),
664        ("math", "log2")  => Ok(Value::Float(expect_float(args.first())?.log2())),
665        ("math", "log10") => Ok(Value::Float(expect_float(args.first())?.log10())),
666        ("math", "sqrt")  => Ok(Value::Float(expect_float(args.first())?.sqrt())),
667        ("math", "abs")   => Ok(Value::Float(expect_float(args.first())?.abs())),
668        ("math", "sin")   => Ok(Value::Float(expect_float(args.first())?.sin())),
669        ("math", "cos")   => Ok(Value::Float(expect_float(args.first())?.cos())),
670        ("math", "tan")   => Ok(Value::Float(expect_float(args.first())?.tan())),
671        ("math", "asin")  => Ok(Value::Float(expect_float(args.first())?.asin())),
672        ("math", "acos")  => Ok(Value::Float(expect_float(args.first())?.acos())),
673        ("math", "atan")  => Ok(Value::Float(expect_float(args.first())?.atan())),
674        ("math", "floor") => Ok(Value::Float(expect_float(args.first())?.floor())),
675        ("math", "ceil")  => Ok(Value::Float(expect_float(args.first())?.ceil())),
676        ("math", "round") => Ok(Value::Float(expect_float(args.first())?.round())),
677        ("math", "trunc") => Ok(Value::Float(expect_float(args.first())?.trunc())),
678        ("math", "pow") => {
679            let a = expect_float(args.first())?;
680            let b = expect_float(args.get(1))?;
681            Ok(Value::Float(a.powf(b)))
682        }
683        ("math", "atan2") => {
684            let y = expect_float(args.first())?;
685            let x = expect_float(args.get(1))?;
686            Ok(Value::Float(y.atan2(x)))
687        }
688        ("math", "min") => {
689            let a = expect_float(args.first())?;
690            let b = expect_float(args.get(1))?;
691            Ok(Value::Float(a.min(b)))
692        }
693        ("math", "max") => {
694            let a = expect_float(args.first())?;
695            let b = expect_float(args.get(1))?;
696            Ok(Value::Float(a.max(b)))
697        }
698        ("math", "zeros") => {
699            let r = expect_int(args.first())?;
700            let c = expect_int(args.get(1))?;
701            if r < 0 || c < 0 {
702                return Err(format!("math.zeros: negative dim {r}x{c}"));
703            }
704            let r = r as usize; let c = c as usize;
705            Ok(Value::F64Array { rows: r as u32, cols: c as u32, data: vec![0.0; r * c] })
706        }
707        ("math", "ones") => {
708            let r = expect_int(args.first())?;
709            let c = expect_int(args.get(1))?;
710            if r < 0 || c < 0 {
711                return Err(format!("math.ones: negative dim {r}x{c}"));
712            }
713            let r = r as usize; let c = c as usize;
714            Ok(Value::F64Array { rows: r as u32, cols: c as u32, data: vec![1.0; r * c] })
715        }
716        ("math", "from_lists") => {
717            let rows = expect_list(args.first())?;
718            let r = rows.len();
719            if r == 0 {
720                return Ok(Value::F64Array { rows: 0, cols: 0, data: Vec::new() });
721            }
722            let first_row = match &rows[0] {
723                Value::List(xs) => xs,
724                other => return Err(format!("math.from_lists: row 0 not List, got {other:?}")),
725            };
726            let c = first_row.len();
727            let mut data = Vec::with_capacity(r * c);
728            for (i, row) in rows.iter().enumerate() {
729                let row = match row {
730                    Value::List(xs) => xs,
731                    other => return Err(format!("math.from_lists: row {i} not List, got {other:?}")),
732                };
733                if row.len() != c {
734                    return Err(format!("math.from_lists: row {i} has {} cols, expected {c}", row.len()));
735                }
736                for (j, v) in row.iter().enumerate() {
737                    let f = match v {
738                        Value::Float(f) => *f,
739                        Value::Int(n) => *n as f64,
740                        other => return Err(format!("math.from_lists: ({i},{j}) not numeric, got {other:?}")),
741                    };
742                    data.push(f);
743                }
744            }
745            Ok(Value::F64Array { rows: r as u32, cols: c as u32, data })
746        }
747        ("math", "from_flat") => {
748            let r = expect_int(args.first())?;
749            let c = expect_int(args.get(1))?;
750            let xs = expect_list(args.get(2))?;
751            if r < 0 || c < 0 {
752                return Err(format!("math.from_flat: negative dim {r}x{c}"));
753            }
754            let r = r as usize; let c = c as usize;
755            if xs.len() != r * c {
756                return Err(format!("math.from_flat: list len {} != {}*{}", xs.len(), r, c));
757            }
758            let mut data = Vec::with_capacity(r * c);
759            for v in xs {
760                data.push(match v {
761                    Value::Float(f) => *f,
762                    Value::Int(n)   => *n as f64,
763                    other => return Err(format!("math.from_flat: non-numeric element {other:?}")),
764                });
765            }
766            Ok(Value::F64Array { rows: r as u32, cols: c as u32, data })
767        }
768        ("math", "rows") => {
769            let (r, _, _) = unpack_matrix(first_arg(args)?)?;
770            Ok(Value::Int(r as i64))
771        }
772        ("math", "cols") => {
773            let (_, c, _) = unpack_matrix(first_arg(args)?)?;
774            Ok(Value::Int(c as i64))
775        }
776        ("math", "get") => {
777            let (r, c, data) = unpack_matrix(first_arg(args)?)?;
778            let i = expect_int(args.get(1))? as usize;
779            let j = expect_int(args.get(2))? as usize;
780            if i >= r || j >= c {
781                return Err(format!("math.get: ({i},{j}) out of {r}x{c}"));
782            }
783            Ok(Value::Float(data[i * c + j]))
784        }
785        ("math", "to_flat") => {
786            let (_, _, data) = unpack_matrix(first_arg(args)?)?;
787            Ok(Value::List(data.into_iter().map(Value::Float).collect()))
788        }
789        ("math", "transpose") => {
790            let (r, c, data) = unpack_matrix(first_arg(args)?)?;
791            let mut out = vec![0.0; r * c];
792            for i in 0..r {
793                for j in 0..c {
794                    out[j * r + i] = data[i * c + j];
795                }
796            }
797            Ok(Value::F64Array { rows: c as u32, cols: r as u32, data: out })
798        }
799        ("math", "matmul") => {
800            let (m, k1, a) = unpack_matrix(first_arg(args)?)?;
801            let (k2, n, b) = unpack_matrix(args.get(1).ok_or("math.matmul: missing arg 1")?)?;
802            if k1 != k2 {
803                return Err(format!("math.matmul: dim mismatch {m}x{k1} · {k2}x{n}"));
804            }
805            // Plain triple loop. For the small matrices used in the ML
806            // demo (n<200, k<10) this is well under a millisecond and
807            // avoids pulling in matrixmultiply for the runtime crate.
808            let mut c = vec![0.0; m * n];
809            for i in 0..m {
810                for kk in 0..k1 {
811                    let aik = a[i * k1 + kk];
812                    for j in 0..n {
813                        c[i * n + j] += aik * b[kk * n + j];
814                    }
815                }
816            }
817            Ok(Value::F64Array { rows: m as u32, cols: n as u32, data: c })
818        }
819        ("math", "scale") => {
820            let s = expect_float(args.first())?;
821            let (r, c, mut data) = unpack_matrix(args.get(1).ok_or("math.scale: missing arg 1")?)?;
822            for x in &mut data { *x *= s; }
823            Ok(Value::F64Array { rows: r as u32, cols: c as u32, data })
824        }
825        ("math", "add") | ("math", "sub") => {
826            let (ar, ac, a) = unpack_matrix(first_arg(args)?)?;
827            let (br, bc, b) = unpack_matrix(args.get(1).ok_or("math.add/sub: missing arg 1")?)?;
828            if ar != br || ac != bc {
829                return Err(format!("math.{op}: shape mismatch {ar}x{ac} vs {br}x{bc}"));
830            }
831            let neg = op == "sub";
832            let mut out = a;
833            for (i, x) in out.iter_mut().enumerate() {
834                if neg { *x -= b[i] } else { *x += b[i] }
835            }
836            Ok(Value::F64Array { rows: ar as u32, cols: ac as u32, data: out })
837        }
838        ("math", "sigmoid") => {
839            let (r, c, mut data) = unpack_matrix(first_arg(args)?)?;
840            for x in &mut data { *x = 1.0 / (1.0 + (-*x).exp()); }
841            Ok(Value::F64Array { rows: r as u32, cols: c as u32, data })
842        }
843
844        // -- map --
845        ("map", "new") => Ok(Value::Map(BTreeMap::new())),
846        ("map", "size") => Ok(Value::Int(expect_map(args.first())?.len() as i64)),
847        ("map", "has") => {
848            let m = expect_map(args.first())?;
849            let k = MapKey::from_value(args.get(1).ok_or("map.has: missing key")?)?;
850            Ok(Value::Bool(m.contains_key(&k)))
851        }
852        ("map", "get") => {
853            let m = expect_map(args.first())?;
854            let k = MapKey::from_value(args.get(1).ok_or("map.get: missing key")?)?;
855            Ok(match m.get(&k) {
856                Some(v) => some(v.clone()),
857                None    => none(),
858            })
859        }
860        ("map", "set") => {
861            let mut m = expect_map(args.first())?.clone();
862            let k = MapKey::from_value(args.get(1).ok_or("map.set: missing key")?)?;
863            let v = args.get(2).ok_or("map.set: missing value")?.clone();
864            m.insert(k, v);
865            Ok(Value::Map(m))
866        }
867        ("map", "delete") => {
868            let mut m = expect_map(args.first())?.clone();
869            let k = MapKey::from_value(args.get(1).ok_or("map.delete: missing key")?)?;
870            m.remove(&k);
871            Ok(Value::Map(m))
872        }
873        ("map", "keys") => {
874            let m = expect_map(args.first())?;
875            Ok(Value::List(m.keys().cloned().map(MapKey::into_value).collect()))
876        }
877        ("map", "values") => {
878            let m = expect_map(args.first())?;
879            Ok(Value::List(m.values().cloned().collect()))
880        }
881        ("map", "entries") => {
882            let m = expect_map(args.first())?;
883            Ok(Value::List(m.iter()
884                .map(|(k, v)| Value::Tuple(vec![k.as_value(), v.clone()]))
885                .collect()))
886        }
887        ("map", "from_list") => {
888            let pairs = expect_list(args.first())?;
889            let mut m = BTreeMap::new();
890            for p in pairs {
891                let items = match p {
892                    Value::Tuple(items) if items.len() == 2 => items,
893                    other => return Err(format!(
894                        "map.from_list element must be a 2-tuple, got {other:?}")),
895                };
896                let k = MapKey::from_value(&items[0])?;
897                m.insert(k, items[1].clone());
898            }
899            Ok(Value::Map(m))
900        }
901
902        // -- set --
903        ("set", "new") => Ok(Value::Set(BTreeSet::new())),
904        ("set", "size") => Ok(Value::Int(expect_set(args.first())?.len() as i64)),
905        ("set", "has") => {
906            let s = expect_set(args.first())?;
907            let k = MapKey::from_value(args.get(1).ok_or("set.has: missing element")?)?;
908            Ok(Value::Bool(s.contains(&k)))
909        }
910        ("set", "add") => {
911            let mut s = expect_set(args.first())?.clone();
912            let k = MapKey::from_value(args.get(1).ok_or("set.add: missing element")?)?;
913            s.insert(k);
914            Ok(Value::Set(s))
915        }
916        ("set", "delete") => {
917            let mut s = expect_set(args.first())?.clone();
918            let k = MapKey::from_value(args.get(1).ok_or("set.delete: missing element")?)?;
919            s.remove(&k);
920            Ok(Value::Set(s))
921        }
922        ("set", "to_list") => {
923            let s = expect_set(args.first())?;
924            Ok(Value::List(s.iter().cloned().map(MapKey::into_value).collect()))
925        }
926        ("set", "from_list") => {
927            let xs = expect_list(args.first())?;
928            let mut s = BTreeSet::new();
929            for x in xs {
930                s.insert(MapKey::from_value(x)?);
931            }
932            Ok(Value::Set(s))
933        }
934        ("set", "union") => {
935            let a = expect_set(args.first())?;
936            let b = expect_set(args.get(1))?;
937            Ok(Value::Set(a.union(b).cloned().collect()))
938        }
939        ("set", "intersect") => {
940            let a = expect_set(args.first())?;
941            let b = expect_set(args.get(1))?;
942            Ok(Value::Set(a.intersection(b).cloned().collect()))
943        }
944        ("set", "diff") => {
945            let a = expect_set(args.first())?;
946            let b = expect_set(args.get(1))?;
947            Ok(Value::Set(a.difference(b).cloned().collect()))
948        }
949        ("set", "is_empty") => Ok(Value::Bool(expect_set(args.first())?.is_empty())),
950        ("set", "is_subset") => {
951            let a = expect_set(args.first())?;
952            let b = expect_set(args.get(1))?;
953            Ok(Value::Bool(a.is_subset(b)))
954        }
955
956        // -- map helpers --
957        ("map", "merge") => {
958            // b's entries override a's. We construct a new BTreeMap
959            // by extending a with b's pairs.
960            let a = expect_map(args.first())?.clone();
961            let b = expect_map(args.get(1))?;
962            let mut out = a;
963            for (k, v) in b {
964                out.insert(k.clone(), v.clone());
965            }
966            Ok(Value::Map(out))
967        }
968        ("map", "is_empty") => Ok(Value::Bool(expect_map(args.first())?.is_empty())),
969
970        // -- deque --
971        ("deque", "new") => Ok(Value::Deque(std::collections::VecDeque::new())),
972        ("deque", "size") => Ok(Value::Int(expect_deque(args.first())?.len() as i64)),
973        ("deque", "is_empty") => Ok(Value::Bool(expect_deque(args.first())?.is_empty())),
974        ("deque", "push_back") => {
975            let mut d = expect_deque(args.first())?.clone();
976            let x = args.get(1).ok_or("deque.push_back: missing value")?.clone();
977            d.push_back(x);
978            Ok(Value::Deque(d))
979        }
980        ("deque", "push_front") => {
981            let mut d = expect_deque(args.first())?.clone();
982            let x = args.get(1).ok_or("deque.push_front: missing value")?.clone();
983            d.push_front(x);
984            Ok(Value::Deque(d))
985        }
986        ("deque", "pop_back") => {
987            let mut d = expect_deque(args.first())?.clone();
988            match d.pop_back() {
989                Some(x) => Ok(Value::Variant {
990                    name: "Some".into(),
991                    args: vec![Value::Tuple(vec![x, Value::Deque(d)])],
992                }),
993                None => Ok(Value::Variant { name: "None".into(), args: vec![] }),
994            }
995        }
996        ("deque", "pop_front") => {
997            let mut d = expect_deque(args.first())?.clone();
998            match d.pop_front() {
999                Some(x) => Ok(Value::Variant {
1000                    name: "Some".into(),
1001                    args: vec![Value::Tuple(vec![x, Value::Deque(d)])],
1002                }),
1003                None => Ok(Value::Variant { name: "None".into(), args: vec![] }),
1004            }
1005        }
1006        ("deque", "peek_back") => {
1007            let d = expect_deque(args.first())?;
1008            match d.back() {
1009                Some(x) => Ok(Value::Variant {
1010                    name: "Some".into(),
1011                    args: vec![x.clone()],
1012                }),
1013                None => Ok(Value::Variant { name: "None".into(), args: vec![] }),
1014            }
1015        }
1016        ("deque", "peek_front") => {
1017            let d = expect_deque(args.first())?;
1018            match d.front() {
1019                Some(x) => Ok(Value::Variant {
1020                    name: "Some".into(),
1021                    args: vec![x.clone()],
1022                }),
1023                None => Ok(Value::Variant { name: "None".into(), args: vec![] }),
1024            }
1025        }
1026        ("deque", "from_list") => {
1027            let xs = expect_list(args.first())?;
1028            Ok(Value::Deque(xs.iter().cloned().collect()))
1029        }
1030        ("deque", "to_list") => {
1031            let d = expect_deque(args.first())?;
1032            Ok(Value::List(d.iter().cloned().collect()))
1033        }
1034
1035        // -- crypto (pure ops; crypto.random is effectful and routes
1036        // through the handler under [random], see try_pure_builtin) --
1037        ("crypto", "sha256") => {
1038            use sha2::{Digest, Sha256};
1039            let data = expect_bytes(args.first())?;
1040            let mut h = Sha256::new();
1041            h.update(data);
1042            Ok(Value::Bytes(h.finalize().to_vec()))
1043        }
1044        ("crypto", "sha512") => {
1045            use sha2::{Digest, Sha512};
1046            let data = expect_bytes(args.first())?;
1047            let mut h = Sha512::new();
1048            h.update(data);
1049            Ok(Value::Bytes(h.finalize().to_vec()))
1050        }
1051        ("crypto", "md5") => {
1052            use md5::{Digest, Md5};
1053            let data = expect_bytes(args.first())?;
1054            let mut h = Md5::new();
1055            h.update(data);
1056            Ok(Value::Bytes(h.finalize().to_vec()))
1057        }
1058        // BLAKE2b (#382) — 64-byte digest, faster than SHA-512 on most
1059        // CPUs with the same security level. Backed by the `blake2`
1060        // crate; uses `Blake2b512` (the standard 512-bit variant).
1061        ("crypto", "blake2b") => {
1062            use blake2::{Blake2b512, Digest};
1063            let data = expect_bytes(args.first())?;
1064            let mut h = Blake2b512::new();
1065            h.update(data);
1066            Ok(Value::Bytes(h.finalize().to_vec()))
1067        }
1068        // Hex-string convenience hashers (#382). Equivalent to
1069        // `hex_encode(shaN(bytes_of_str(s)))` for the common case
1070        // where the caller has a Str and wants a hex Str digest.
1071        ("crypto", "sha256_str") => {
1072            use sha2::{Digest, Sha256};
1073            let s = expect_str(args.first())?;
1074            let mut h = Sha256::new();
1075            h.update(s.as_bytes());
1076            Ok(Value::Str(hex::encode(h.finalize()).into()))
1077        }
1078        ("crypto", "sha512_str") => {
1079            use sha2::{Digest, Sha512};
1080            let s = expect_str(args.first())?;
1081            let mut h = Sha512::new();
1082            h.update(s.as_bytes());
1083            Ok(Value::Str(hex::encode(h.finalize()).into()))
1084        }
1085        ("crypto", "hmac_sha256") => {
1086            use hmac::{Hmac, KeyInit, Mac};
1087            type HmacSha256 = Hmac<sha2::Sha256>;
1088            let key = expect_bytes(args.first())?;
1089            let data = expect_bytes(args.get(1))?;
1090            let mut mac = HmacSha256::new_from_slice(key)
1091                .map_err(|e| format!("hmac_sha256 key: {e}"))?;
1092            mac.update(data);
1093            Ok(Value::Bytes(mac.finalize().into_bytes().to_vec()))
1094        }
1095        ("crypto", "hmac_sha512") => {
1096            use hmac::{Hmac, KeyInit, Mac};
1097            type HmacSha512 = Hmac<sha2::Sha512>;
1098            let key = expect_bytes(args.first())?;
1099            let data = expect_bytes(args.get(1))?;
1100            let mut mac = HmacSha512::new_from_slice(key)
1101                .map_err(|e| format!("hmac_sha512 key: {e}"))?;
1102            mac.update(data);
1103            Ok(Value::Bytes(mac.finalize().into_bytes().to_vec()))
1104        }
1105        ("crypto", "base64_encode") => {
1106            use base64::{Engine, engine::general_purpose::STANDARD};
1107            let data = expect_bytes(args.first())?;
1108            Ok(Value::Str(STANDARD.encode(data).into()))
1109        }
1110        ("crypto", "base64_decode") => {
1111            use base64::{Engine, engine::general_purpose::STANDARD};
1112            let s = expect_str(args.first())?;
1113            match STANDARD.decode(s) {
1114                Ok(b)  => Ok(ok_v(Value::Bytes(b))),
1115                Err(e) => Ok(err_v(Value::Str(format!("base64: {e}").into()))),
1116            }
1117        }
1118        // URL-safe base64 (#382). Alphabet `-_` instead of `+/`,
1119        // padding stripped. Use for JWT segments, signed cookies, any
1120        // token that travels in a URL or path component.
1121        ("crypto", "base64url_encode") => {
1122            use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
1123            let data = expect_bytes(args.first())?;
1124            Ok(Value::Str(URL_SAFE_NO_PAD.encode(data).into()))
1125        }
1126        ("crypto", "base64url_decode") => {
1127            use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
1128            let s = expect_str(args.first())?;
1129            match URL_SAFE_NO_PAD.decode(s) {
1130                Ok(b)  => Ok(ok_v(Value::Bytes(b))),
1131                Err(e) => Ok(err_v(Value::Str(format!("base64url: {e}").into()))),
1132            }
1133        }
1134        ("crypto", "hex_encode") => {
1135            let data = expect_bytes(args.first())?;
1136            Ok(Value::Str(hex::encode(data).into()))
1137        }
1138        ("crypto", "hex_decode") => {
1139            let s = expect_str(args.first())?;
1140            match hex::decode(s) {
1141                Ok(b)  => Ok(ok_v(Value::Bytes(b))),
1142                Err(e) => Ok(err_v(Value::Str(format!("hex: {e}").into()))),
1143            }
1144        }
1145        ("crypto", "constant_time_eq") | ("crypto", "eq") => {
1146            use subtle::ConstantTimeEq;
1147            let a = expect_bytes(args.first())?;
1148            let b = expect_bytes(args.get(1))?;
1149            // `subtle` returns Choice; comparison only meaningful when
1150            // lengths match. For mismatched lengths return false in
1151            // constant time (length itself isn't secret, but we want
1152            // a single comparison shape).
1153            //
1154            // `eq` (#382) is the recommended spelling — same semantics,
1155            // shorter name. `constant_time_eq` stays as an alias for
1156            // existing callers.
1157            let eq = if a.len() == b.len() {
1158                a.ct_eq(b).into()
1159            } else {
1160                false
1161            };
1162            Ok(Value::Bool(eq))
1163        }
1164        // Constant-time string equality (#382). Compares the bytes of
1165        // both strings; semantics identical to `eq` after `.as_bytes()`.
1166        ("crypto", "eq_str") => {
1167            use subtle::ConstantTimeEq;
1168            let a = expect_str(args.first())?;
1169            let b = expect_str(args.get(1))?;
1170            let eq = if a.len() == b.len() {
1171                a.as_bytes().ct_eq(b.as_bytes()).into()
1172            } else {
1173                false
1174            };
1175            Ok(Value::Bool(eq))
1176        }
1177
1178        // -- AEAD (#382 AEAD slice). Pure: same key + nonce + aad +
1179        // plaintext always produce the same ciphertext + tag. The
1180        // `[random]` effect lives one level up at the caller, where the
1181        // nonce is generated; AEAD ops themselves are deterministic and
1182        // therefore pure.
1183        //
1184        // AES-GCM key length is 128 / 192 / 256 bits; we pick the
1185        // variant from the key size at runtime so callers don't have
1186        // to choose between three near-identical wrappers.
1187        ("crypto", "aes_gcm_seal") => Ok(aes_gcm_seal_impl(args)),
1188        ("crypto", "aes_gcm_open") => Ok(aes_gcm_open_impl(args)),
1189        ("crypto", "chacha20_poly1305_seal") => Ok(chacha20_seal_impl(args)),
1190        ("crypto", "chacha20_poly1305_open") => Ok(chacha20_open_impl(args)),
1191        ("crypto", "pbkdf2_sha256") => Ok(pbkdf2_sha256_impl(args)),
1192        ("crypto", "hkdf_sha256")   => Ok(hkdf_sha256_impl(args)),
1193        ("crypto", "argon2id")      => Ok(argon2id_impl(args)),
1194
1195        // -- random (#219): pure, seeded RNG. Backed by SplitMix64;
1196        // state is the u64 mixer state stored as a single i64 in
1197        // `Rng = { state :: Int }`. Threading the Rng through the
1198        // call site is the user's responsibility — there is no
1199        // global RNG and therefore no `[random]` effect tag for
1200        // pure-seeded usage. --
1201        ("random", "seed") => {
1202            let s = args.first().ok_or("random.seed: missing arg")?.as_int();
1203            // Hash the user-supplied seed once before installing it.
1204            // SplitMix64 is fine when seeded with any u64, but
1205            // hashing first protects against pathological seeds
1206            // (e.g., 0) that would make the very first draw zero.
1207            let mixed = splitmix64(s as u64).0;
1208            Ok(rng_value(mixed))
1209        }
1210        ("random", "int") => {
1211            let state = rng_decode(args.first())?;
1212            let lo = args.get(1).ok_or("random.int: missing lo")?.as_int();
1213            let hi = args.get(2).ok_or("random.int: missing hi")?.as_int();
1214            if hi < lo {
1215                return Err(format!(
1216                    "random.int: hi ({hi}) must be >= lo ({lo})"));
1217            }
1218            let span = (hi as i128) - (lo as i128) + 1;
1219            let (raw, next_state) = splitmix64(state);
1220            // Reduce uniformly to [lo, hi]. The bias from a plain
1221            // modulo is at most `(u64::MAX % span) / u64::MAX`,
1222            // which for any practical span is invisible. Crypto
1223            // applications should use `crypto.random` instead.
1224            let drawn = lo as i128 + (raw as u128 % span as u128) as i128;
1225            Ok(Value::Tuple(vec![
1226                Value::Int(drawn as i64),
1227                rng_value(next_state),
1228            ]))
1229        }
1230        ("random", "float") => {
1231            let state = rng_decode(args.first())?;
1232            let (raw, next_state) = splitmix64(state);
1233            // Take the top 53 bits and divide by 2^53 to land in
1234            // [0.0, 1.0); this is the standard f64 uniform draw.
1235            let f = ((raw >> 11) as f64) / ((1u64 << 53) as f64);
1236            Ok(Value::Tuple(vec![Value::Float(f), rng_value(next_state)]))
1237        }
1238        ("random", "choose") => {
1239            let state = rng_decode(args.first())?;
1240            let xs = match args.get(1) {
1241                Some(Value::List(xs)) => xs,
1242                _ => return Err("random.choose: expected List".into()),
1243            };
1244            if xs.is_empty() {
1245                return Ok(Value::Variant {
1246                    name: "None".into(), args: vec![],
1247                });
1248            }
1249            let (raw, next_state) = splitmix64(state);
1250            let idx = (raw as usize) % xs.len();
1251            let pick = xs[idx].clone();
1252            Ok(Value::Variant {
1253                name: "Some".into(),
1254                args: vec![Value::Tuple(vec![pick, rng_value(next_state)])],
1255            })
1256        }
1257
1258        // -- parser (#217): parser combinators. Parser values are
1259        // tagged Records — `{ kind: "Char", ch: "x" }` etc. — so
1260        // canonical equality follows from the canonical Record
1261        // encoding. The interpreter is `parser_run_impl`. --
1262        ("parser", "char") => {
1263            let s = expect_str(args.first())?;
1264            if s.chars().count() != 1 {
1265                return Err(format!(
1266                    "parser.char: expected 1-character string, got {s:?}"));
1267            }
1268            Ok(parser_node("Char", &[("ch", Value::Str(s.into()))]))
1269        }
1270        ("parser", "string") => {
1271            let s = expect_str(args.first())?;
1272            Ok(parser_node("String", &[("s", Value::Str(s.into()))]))
1273        }
1274        ("parser", "digit") => Ok(parser_node("Digit", &[])),
1275        ("parser", "alpha") => Ok(parser_node("Alpha", &[])),
1276        ("parser", "whitespace") => Ok(parser_node("Whitespace", &[])),
1277        ("parser", "eof") => Ok(parser_node("Eof", &[])),
1278        ("parser", "seq") => {
1279            let a = args.first().cloned()
1280                .ok_or_else(|| "parser.seq: missing first parser".to_string())?;
1281            let b = args.get(1).cloned()
1282                .ok_or_else(|| "parser.seq: missing second parser".to_string())?;
1283            Ok(parser_node("Seq", &[("a", a), ("b", b)]))
1284        }
1285        ("parser", "alt") => {
1286            let a = args.first().cloned()
1287                .ok_or_else(|| "parser.alt: missing first parser".to_string())?;
1288            let b = args.get(1).cloned()
1289                .ok_or_else(|| "parser.alt: missing second parser".to_string())?;
1290            Ok(parser_node("Alt", &[("a", a), ("b", b)]))
1291        }
1292        ("parser", "many") => {
1293            let p = args.first().cloned()
1294                .ok_or_else(|| "parser.many: missing inner parser".to_string())?;
1295            Ok(parser_node("Many", &[("p", p)]))
1296        }
1297        ("parser", "optional") => {
1298            let p = args.first().cloned()
1299                .ok_or_else(|| "parser.optional: missing inner parser".to_string())?;
1300            Ok(parser_node("Optional", &[("p", p)]))
1301        }
1302        // `parser.map` and `parser.and_then` (#221): closure-bearing
1303        // combinators. Constructors only — actual closure invocation
1304        // happens at parser.run time via the Vm-level interpreter.
1305        ("parser", "map") => {
1306            let p = args.first().cloned()
1307                .ok_or_else(|| "parser.map: missing parser".to_string())?;
1308            let f = args.get(1).cloned()
1309                .ok_or_else(|| "parser.map: missing closure".to_string())?;
1310            Ok(parser_node("Map", &[("p", p), ("f", f)]))
1311        }
1312        ("parser", "and_then") => {
1313            let p = args.first().cloned()
1314                .ok_or_else(|| "parser.and_then: missing parser".to_string())?;
1315            let f = args.get(1).cloned()
1316                .ok_or_else(|| "parser.and_then: missing closure".to_string())?;
1317            Ok(parser_node("AndThen", &[("p", p), ("f", f)]))
1318        }
1319        // `parser.run` is handled at the Vm level (lex-bytecode's
1320        // `Op::EffectCall` intercept) — it needs reentrant Vm access
1321        // to invoke the closures inside `Map` / `AndThen` nodes. The
1322        // pure-builtin path doesn't have that, so we deliberately do
1323        // *not* have a `("parser", "run")` arm here.
1324
1325        // -- regex (the compiled `Regex` is stored as the pattern
1326        // string; the runtime caches the actual `regex::Regex` so
1327        // ops don't re-compile on every call) --
1328        ("regex", "compile") => {
1329            let pat = expect_str(args.first())?;
1330            match get_or_compile_regex(&pat) {
1331                Ok(_) => Ok(ok_v(Value::Str(pat.into()))),
1332                Err(e) => Ok(err_v(Value::Str(e.into()))),
1333            }
1334        }
1335        ("regex", "is_match") => {
1336            let pat = expect_str(args.first())?;
1337            let s = expect_str(args.get(1))?;
1338            let re = get_or_compile_regex(&pat).map_err(|e| format!("regex.is_match: {e}"))?;
1339            Ok(Value::Bool(re.is_match(&s)))
1340        }
1341        // is_match_str :: Str, Str -> Bool
1342        // Compiles the first argument as a pattern on the fly (uses the shared
1343        // cache) and matches against the second.  Returns false on invalid
1344        // pattern rather than propagating an error, keeping the pure signature.
1345        ("regex", "is_match_str") => {
1346            let pat = expect_str(args.first())?;
1347            let s = expect_str(args.get(1))?;
1348            match get_or_compile_regex(&pat) {
1349                Ok(re) => Ok(Value::Bool(re.is_match(&s))),
1350                Err(_) => Ok(Value::Bool(false)),
1351            }
1352        }
1353        ("regex", "find") => {
1354            let pat = expect_str(args.first())?;
1355            let s = expect_str(args.get(1))?;
1356            let re = get_or_compile_regex(&pat).map_err(|e| format!("regex.find: {e}"))?;
1357            match re.captures(&s) {
1358                Some(caps) => Ok(Value::Variant {
1359                    name: "Some".into(),
1360                    args: vec![match_value(&caps)],
1361                }),
1362                None => Ok(Value::Variant { name: "None".into(), args: vec![] }),
1363            }
1364        }
1365        ("regex", "find_all") => {
1366            let pat = expect_str(args.first())?;
1367            let s = expect_str(args.get(1))?;
1368            let re = get_or_compile_regex(&pat).map_err(|e| format!("regex.find_all: {e}"))?;
1369            let items: std::collections::VecDeque<Value> = re.captures_iter(&s).map(|caps| match_value(&caps)).collect();
1370            Ok(Value::List(items))
1371        }
1372        ("regex", "replace") => {
1373            let pat = expect_str(args.first())?;
1374            let s = expect_str(args.get(1))?;
1375            let rep = expect_str(args.get(2))?;
1376            let re = get_or_compile_regex(&pat).map_err(|e| format!("regex.replace: {e}"))?;
1377            Ok(Value::Str(re.replace(&s, rep.as_str()).into_owned().into()))
1378        }
1379        ("regex", "replace_all") => {
1380            let pat = expect_str(args.first())?;
1381            let s = expect_str(args.get(1))?;
1382            let rep = expect_str(args.get(2))?;
1383            let re = get_or_compile_regex(&pat).map_err(|e| format!("regex.replace_all: {e}"))?;
1384            Ok(Value::Str(re.replace_all(&s, rep.as_str()).into_owned().into()))
1385        }
1386        // -- datetime (pure ops; datetime.now is effectful and routes
1387        // through the handler under [time]) --
1388        ("datetime", "parse_iso") => {
1389            let s = expect_str(args.first())?;
1390            match chrono::DateTime::parse_from_rfc3339(&s) {
1391                Ok(dt) => Ok(ok_v(Value::Int(instant_from_chrono(dt)))),
1392                Err(e) => Ok(err_v(Value::Str(format!("parse_iso: {e}").into()))),
1393            }
1394        }
1395        ("datetime", "format_iso") => {
1396            let n = expect_int(args.first())?;
1397            Ok(Value::Str(format_iso(n).into()))
1398        }
1399        ("datetime", "parse") => {
1400            let s = expect_str(args.first())?;
1401            let fmt = expect_str(args.get(1))?;
1402            match chrono::NaiveDateTime::parse_from_str(&s, &fmt) {
1403                Ok(naive) => {
1404                    use chrono::TimeZone;
1405                    match chrono::Utc.from_local_datetime(&naive).single() {
1406                        Some(dt) => Ok(ok_v(Value::Int(instant_from_chrono(dt)))),
1407                        None => Ok(err_v(Value::Str("parse: ambiguous local time".into()))),
1408                    }
1409                }
1410                Err(e) => Ok(err_v(Value::Str(format!("parse: {e}").into()))),
1411            }
1412        }
1413        ("datetime", "format") => {
1414            let n = expect_int(args.first())?;
1415            let fmt = expect_str(args.get(1))?;
1416            let dt = chrono_from_instant(n);
1417            Ok(Value::Str(dt.format(&fmt).to_string().into()))
1418        }
1419        ("datetime", "to_components") => {
1420            let n = expect_int(args.first())?;
1421            let tz = match parse_tz_arg(args.get(1)) {
1422                Ok(t) => t,
1423                Err(e) => return Ok(err_v(Value::Str(e.into()))),
1424            };
1425            match resolve_tz_to_components(n, &tz) {
1426                Ok(rec) => Ok(ok_v(rec)),
1427                Err(e) => Ok(err_v(Value::Str(e.into()))),
1428            }
1429        }
1430        ("datetime", "from_components") => {
1431            let rec = match args.first() {
1432                Some(Value::Record { fields: r, .. }) => r.clone(),
1433                _ => return Err("from_components: expected DateTime record".into()),
1434            };
1435            match instant_from_components(&rec) {
1436                Ok(n) => Ok(ok_v(Value::Int(n))),
1437                Err(e) => Ok(err_v(Value::Str(e.into()))),
1438            }
1439        }
1440        ("datetime", "add") => {
1441            let a = expect_int(args.first())?;
1442            let d = expect_int(args.get(1))?;
1443            Ok(Value::Int(a.saturating_add(d)))
1444        }
1445        ("datetime", "diff") => {
1446            let a = expect_int(args.first())?;
1447            let b = expect_int(args.get(1))?;
1448            Ok(Value::Int(a.saturating_sub(b)))
1449        }
1450        ("datetime", "duration_seconds") => {
1451            let s = expect_float(args.first())?;
1452            let nanos = (s * 1_000_000_000.0) as i64;
1453            Ok(Value::Int(nanos))
1454        }
1455        ("datetime", "duration_minutes") => {
1456            let m = expect_int(args.first())?;
1457            Ok(Value::Int(m.saturating_mul(60_000_000_000)))
1458        }
1459        ("datetime", "duration_days") => {
1460            let d = expect_int(args.first())?;
1461            Ok(Value::Int(d.saturating_mul(86_400_000_000_000)))
1462        }
1463        // #331: Instant comparison ops.
1464        ("datetime", "before") => {
1465            let a = expect_int(args.first())?;
1466            let b = expect_int(args.get(1))?;
1467            Ok(Value::Bool(a < b))
1468        }
1469        ("datetime", "after") => {
1470            let a = expect_int(args.first())?;
1471            let b = expect_int(args.get(1))?;
1472            Ok(Value::Bool(a > b))
1473        }
1474        ("datetime", "compare") => {
1475            let a = expect_int(args.first())?;
1476            let b = expect_int(args.get(1))?;
1477            Ok(Value::Int(a.cmp(&b) as i64))
1478        }
1479        // #331: Duration scalar extraction.
1480        ("duration", "seconds") => {
1481            let nanos = expect_int(args.first())?;
1482            Ok(Value::Int(nanos / 1_000_000_000))
1483        }
1484
1485        ("regex", "split") => {
1486            let pat = expect_str(args.first())?;
1487            let s = expect_str(args.get(1))?;
1488            let re = get_or_compile_regex(&pat).map_err(|e| format!("regex.split: {e}"))?;
1489            let parts: std::collections::VecDeque<Value> = re.split(&s).map(|p| Value::Str(p.into())).collect();
1490            Ok(Value::List(parts))
1491        }
1492
1493        // -- http (builders + decoders; wire ops live in the
1494        // effect handler under `[net]`) --
1495        ("http", "with_header") => {
1496            let req = expect_record_pure(args.first())?.clone();
1497            let k = expect_str(args.get(1))?;
1498            let v = expect_str(args.get(2))?;
1499            Ok(Value::record_interned(http_set_header(req, &k, &v)))
1500        }
1501        ("http", "with_auth") => {
1502            let req = expect_record_pure(args.first())?.clone();
1503            let scheme = expect_str(args.get(1))?;
1504            let token = expect_str(args.get(2))?;
1505            let value = format!("{scheme} {token}");
1506            Ok(Value::record_interned(http_set_header(req, "Authorization", &value)))
1507        }
1508        ("http", "with_query") => {
1509            let req = expect_record_pure(args.first())?.clone();
1510            let params = match args.get(1) {
1511                Some(Value::Map(m)) => m.clone(),
1512                Some(other) => return Err(format!(
1513                    "http.with_query: params must be Map[Str, Str], got {other:?}")),
1514                None => return Err("http.with_query: missing params argument".into()),
1515            };
1516            Ok(Value::record_interned(http_append_query(req, &params)))
1517        }
1518        ("http", "with_timeout_ms") => {
1519            let req = expect_record_pure(args.first())?.clone();
1520            let ms = expect_int(args.get(1))?;
1521            let mut out = req;
1522            out.insert("timeout_ms".into(), Value::Variant {
1523                name: "Some".into(),
1524                args: vec![Value::Int(ms)],
1525            });
1526            Ok(Value::record_interned(out))
1527        }
1528        ("http", "json_body") => {
1529            let resp = expect_record_pure(args.first())?;
1530            let body = match resp.get("body") {
1531                Some(Value::Bytes(b)) => b.clone(),
1532                _ => return Err("http.json_body: HttpResponse.body must be Bytes".into()),
1533            };
1534            let s = match std::str::from_utf8(&body) {
1535                Ok(s) => s,
1536                Err(e) => return Ok(http_decode_err_pure(format!("body not UTF-8: {e}"))),
1537            };
1538            match serde_json::from_str::<serde_json::Value>(s) {
1539                Ok(j) => Ok(ok_v(Value::from_json(&j))),
1540                Err(e) => Ok(http_decode_err_pure(format!("json parse: {e}"))),
1541            }
1542        }
1543        ("http", "text_body") => {
1544            let resp = expect_record_pure(args.first())?;
1545            let body = match resp.get("body") {
1546                Some(Value::Bytes(b)) => b.clone(),
1547                _ => return Err("http.text_body: HttpResponse.body must be Bytes".into()),
1548            };
1549            match String::from_utf8(body) {
1550                Ok(s) => Ok(ok_v(Value::Str(s.into()))),
1551                Err(e) => Ok(http_decode_err_pure(format!("body not UTF-8: {e}"))),
1552            }
1553        }
1554
1555        // -- std.cli (Rubric port): argparse-equivalent for end-user
1556        // programs. Specs are tagged Json values; the parser walks
1557        // argv against the spec and returns a CliParsed Json record.
1558        ("cli", "flag") => {
1559            let name = expect_str(args.first())?;
1560            let short = opt_str(args.get(1));
1561            let help = expect_str(args.get(2))?;
1562            Ok(value_from_json(crate::cli::flag_spec(&name, short.as_deref(), &help)))
1563        }
1564        ("cli", "option") => {
1565            let name = expect_str(args.first())?;
1566            let short = opt_str(args.get(1));
1567            let help = expect_str(args.get(2))?;
1568            let default = opt_str(args.get(3));
1569            Ok(value_from_json(crate::cli::option_spec(&name, short.as_deref(), &help, default.as_deref())))
1570        }
1571        ("cli", "positional") => {
1572            let name = expect_str(args.first())?;
1573            let help = expect_str(args.get(1))?;
1574            let required = expect_bool(args.get(2))?;
1575            Ok(value_from_json(crate::cli::positional_spec(&name, &help, required)))
1576        }
1577        ("cli", "spec") => {
1578            let name = expect_str(args.first())?;
1579            let help = expect_str(args.get(1))?;
1580            let arg_specs: Vec<serde_json::Value> = expect_list(args.get(2))?
1581                .iter().map(value_to_json).collect();
1582            let subs: Vec<serde_json::Value> = expect_list(args.get(3))?
1583                .iter().map(value_to_json).collect();
1584            Ok(value_from_json(crate::cli::build_spec(&name, &help, arg_specs, subs)))
1585        }
1586        ("cli", "parse") => {
1587            let spec = value_to_json(args.first().unwrap_or(&Value::Unit));
1588            let argv: Vec<String> = expect_list(args.get(1))?
1589                .iter().map(|v| match v {
1590                    Value::Str(s) => Ok(s.to_string()),
1591                    other => Err(format!("cli.parse: argv must be List[Str], got {other:?}")),
1592                }).collect::<Result<_, _>>()?;
1593            match crate::cli::parse(&spec, &argv) {
1594                Ok(parsed) => Ok(ok_v(value_from_json(parsed))),
1595                Err(msg) => Ok(err_v(Value::Str(msg.into()))),
1596            }
1597        }
1598        ("cli", "envelope") => {
1599            let ok = expect_bool(args.first())?;
1600            let cmd = expect_str(args.get(1))?;
1601            let data = value_to_json(args.get(2).unwrap_or(&Value::Unit));
1602            Ok(value_from_json(crate::cli::envelope(ok, &cmd, data)))
1603        }
1604        ("cli", "describe") => {
1605            let spec = value_to_json(args.first().unwrap_or(&Value::Unit));
1606            Ok(value_from_json(crate::cli::describe(&spec)))
1607        }
1608        ("cli", "help") => {
1609            let spec = value_to_json(args.first().unwrap_or(&Value::Unit));
1610            Ok(Value::Str(crate::cli::help_text(&spec).into()))
1611        }
1612
1613        // -- arrow -- delegated to a dedicated module (#426)
1614        ("arrow", op) => match crate::arrow::dispatch(op, args) {
1615            Some(r) => r,
1616            None => Err(format!("unknown pure builtin: arrow.{op}")),
1617        },
1618        // -- df -- Polars-backed query ops (#427), gated behind the
1619        // `df` feature so embedders that don't need dataframes avoid
1620        // the polars dep tree.
1621        #[cfg(feature = "df")]
1622        ("df", op) => match crate::df::dispatch(op, args) {
1623            Some(r) => r,
1624            None => Err(format!("unknown pure builtin: df.{op}")),
1625        },
1626        #[cfg(not(feature = "df"))]
1627        ("df", op) => Err(format!(
1628            "df.{op}: this build was compiled without the `df` feature; \
1629             Polars-backed dataframe query ops are unavailable"
1630        )),
1631
1632        // -- std.decimal (#574): exact scaled-integer decimal arithmetic.
1633        // Decimal values are `{ coefficient :: Int, exponent :: Int }` records
1634        // representing `coefficient × 10^exponent`. All arithmetic is exact
1635        // (no IEEE 754 rounding); precision loss happens only at `round_to`,
1636        // which requires an explicit rounding mode string.
1637
1638        ("decimal", "decimal") => {
1639            let coef = expect_int(args.first())?;
1640            let exp  = expect_int(args.get(1))?;
1641            Ok(make_decimal(coef, exp))
1642        }
1643        ("decimal", "zero") => Ok(make_decimal(0, 0)),
1644        ("decimal", "one")  => Ok(make_decimal(1, 0)),
1645        ("decimal", "from_int") => {
1646            Ok(make_decimal(expect_int(args.first())?, 0))
1647        }
1648        ("decimal", "pow10") => {
1649            Ok(Value::Int(decimal_pow10(expect_int(args.first())?)?))
1650        }
1651        ("decimal", "add") => {
1652            let (ca, ea) = expect_decimal(args.first())?;
1653            let (cb, eb) = expect_decimal(args.get(1))?;
1654            let (a2, b2, e) = decimal_align(ca, ea, cb, eb)?;
1655            Ok(make_decimal(
1656                a2.checked_add(b2).ok_or("decimal.add: overflow")?, e))
1657        }
1658        ("decimal", "sub") => {
1659            let (ca, ea) = expect_decimal(args.first())?;
1660            let (cb, eb) = expect_decimal(args.get(1))?;
1661            let (a2, b2, e) = decimal_align(ca, ea, cb, eb)?;
1662            Ok(make_decimal(
1663                a2.checked_sub(b2).ok_or("decimal.sub: overflow")?, e))
1664        }
1665        ("decimal", "mul") => {
1666            let (ca, ea) = expect_decimal(args.first())?;
1667            let (cb, eb) = expect_decimal(args.get(1))?;
1668            Ok(make_decimal(
1669                ca.checked_mul(cb).ok_or("decimal.mul: overflow")?,
1670                ea.checked_add(eb).ok_or("decimal.mul: exponent overflow")?,
1671            ))
1672        }
1673        ("decimal", "compare") => {
1674            let (ca, ea) = expect_decimal(args.first())?;
1675            let (cb, eb) = expect_decimal(args.get(1))?;
1676            let (a2, b2, _) = decimal_align(ca, ea, cb, eb)?;
1677            Ok(Value::Int(if a2 < b2 { -1 } else if a2 > b2 { 1 } else { 0 }))
1678        }
1679        ("decimal", "is_zero")     => {
1680            let (c, _) = expect_decimal(args.first())?;
1681            Ok(Value::Bool(c == 0))
1682        }
1683        ("decimal", "is_positive") => {
1684            let (c, _) = expect_decimal(args.first())?;
1685            Ok(Value::Bool(c > 0))
1686        }
1687        ("decimal", "is_negative") => {
1688            let (c, _) = expect_decimal(args.first())?;
1689            Ok(Value::Bool(c < 0))
1690        }
1691        ("decimal", "negate") => {
1692            let (c, e) = expect_decimal(args.first())?;
1693            Ok(make_decimal(-c, e))
1694        }
1695        ("decimal", "abs") => {
1696            let (c, e) = expect_decimal(args.first())?;
1697            Ok(make_decimal(c.abs(), e))
1698        }
1699        ("decimal", "normalize") => {
1700            let (mut c, mut e) = expect_decimal(args.first())?;
1701            if c == 0 { return Ok(make_decimal(0, 0)); }
1702            while c % 10 == 0 { c /= 10; e += 1; }
1703            Ok(make_decimal(c, e))
1704        }
1705        ("decimal", "round_to") => {
1706            let (c, e)   = expect_decimal(args.first())?;
1707            let target_e = expect_int(args.get(1))?;
1708            let mode     = expect_str(args.get(2))?;
1709            Ok(make_decimal(decimal_round(c, e, target_e, &mode)?, target_e))
1710        }
1711        ("decimal", "to_str") => {
1712            let (c, e) = expect_decimal(args.first())?;
1713            Ok(Value::Str(decimal_to_str(c, e)?.into()))
1714        }
1715
1716        _ => Err(format!("unknown pure builtin: {kind}.{op}")),
1717    }
1718}
1719
1720// -- std.decimal helpers (#574) ------------------------------------------
1721
1722/// Extract `(coefficient, exponent)` from a `Decimal` record value.
1723fn expect_decimal(v: Option<&Value>) -> Result<(i64, i64), String> {
1724    match v {
1725        Some(Value::Record { fields, .. }) => {
1726            let coef = match fields.get("coefficient") {
1727                Some(Value::Int(n)) => *n,
1728                _ => return Err("decimal: missing or invalid 'coefficient' field".into()),
1729            };
1730            let exp = match fields.get("exponent") {
1731                Some(Value::Int(n)) => *n,
1732                _ => return Err("decimal: missing or invalid 'exponent' field".into()),
1733            };
1734            Ok((coef, exp))
1735        }
1736        Some(other) => Err(format!("decimal: expected {{ coefficient, exponent }} record, got {other:?}")),
1737        None => Err("decimal: missing argument".into()),
1738    }
1739}
1740
1741/// Build a `Decimal` `Value::Record`.
1742fn make_decimal(coefficient: i64, exponent: i64) -> Value {
1743    let mut fields = indexmap::IndexMap::new();
1744    fields.insert("coefficient".into(), Value::Int(coefficient));
1745    fields.insert("exponent".into(), Value::Int(exponent));
1746    Value::record_interned(fields)
1747}
1748
1749/// 10^n for n in [0, 18]. Returns an error outside that range.
1750fn decimal_pow10(n: i64) -> Result<i64, String> {
1751    if n < 0  { return Err(format!("decimal.pow10: negative exponent {n}")); }
1752    if n > 18 { return Err(format!("decimal.pow10: exponent {n} exceeds max (18)")); }
1753    Ok(10i64.pow(n as u32))
1754}
1755
1756/// Bring two Decimals to the same exponent.
1757/// Returns `(coef_a_aligned, coef_b_aligned, common_exponent)`.
1758fn decimal_align(ca: i64, ea: i64, cb: i64, eb: i64) -> Result<(i64, i64, i64), String> {
1759    if ea == eb { return Ok((ca, cb, ea)); }
1760    if ea > eb {
1761        let scale = decimal_pow10(ea - eb)?;
1762        let ca2 = ca.checked_mul(scale)
1763            .ok_or_else(|| format!("decimal: overflow aligning (shift {})", ea - eb))?;
1764        Ok((ca2, cb, eb))
1765    } else {
1766        let scale = decimal_pow10(eb - ea)?;
1767        let cb2 = cb.checked_mul(scale)
1768            .ok_or_else(|| format!("decimal: overflow aligning (shift {})", eb - ea))?;
1769        Ok((ca, cb2, ea))
1770    }
1771}
1772
1773/// Compute the rounded coefficient when scaling `c × 10^e` to `target_e`.
1774/// `target_e > e` (we're reducing precision): divides by `10^(target_e - e)`
1775/// and applies `mode`. When `target_e <= e` (gaining precision) multiplies
1776/// exactly — no rounding needed.
1777fn decimal_round(c: i64, e: i64, target_e: i64, mode: &str) -> Result<i64, String> {
1778    if e >= target_e {
1779        // Gaining precision (or staying equal) — exact, no rounding.
1780        let shift = e - target_e;
1781        let scale = decimal_pow10(shift)?;
1782        return c.checked_mul(scale)
1783            .ok_or_else(|| format!("decimal.round_to: overflow scaling (shift {shift})"));
1784    }
1785    // Losing precision — divide and round.
1786    let shift   = target_e - e;
1787    let divisor = decimal_pow10(shift)?;
1788    let q = c / divisor;
1789    let r = c % divisor;  // same sign as c (Rust truncation toward zero)
1790
1791    if r == 0 { return Ok(q); }
1792
1793    let abs_r   = r.abs();
1794    let positive = r > 0; // sign of the original value when q is near zero
1795
1796    let rounded = match mode {
1797        "Down"     => q,
1798        "Up"       => if positive { q + 1 } else { q - 1 },
1799        "Floor"    => if positive { q }     else { q - 1 },
1800        "Ceiling"  => if positive { q + 1 } else { q },
1801        "HalfUp"   => {
1802            if abs_r * 2 >= divisor {
1803                if positive { q + 1 } else { q - 1 }
1804            } else { q }
1805        }
1806        "HalfDown" => {
1807            if abs_r * 2 > divisor {
1808                if positive { q + 1 } else { q - 1 }
1809            } else { q }
1810        }
1811        "HalfEven" => {
1812            if abs_r * 2 > divisor {
1813                if positive { q + 1 } else { q - 1 }
1814            } else if abs_r * 2 == divisor {
1815                // Round to nearest even (banker's rounding)
1816                if q % 2 == 0 { q } else { if positive { q + 1 } else { q - 1 } }
1817            } else { q }
1818        }
1819        other => return Err(format!(
1820            "decimal.round_to: unknown rounding mode {other:?}; \
1821             valid modes: HalfUp HalfDown HalfEven Down Up Ceiling Floor")),
1822    };
1823    Ok(rounded)
1824}
1825
1826/// Format a Decimal as a decimal-notation string.
1827/// e.g. `(12345, -2)` → `"123.45"`, `(7, 2)` → `"700"`, `(-63, -2)` → `"-0.63"`.
1828fn decimal_to_str(c: i64, e: i64) -> Result<String, String> {
1829    if e == 0 { return Ok(c.to_string()); }
1830    if e > 0 {
1831        let scale = decimal_pow10(e)?;
1832        let val   = c.checked_mul(scale)
1833            .ok_or("decimal.to_str: overflow")?;
1834        return Ok(val.to_string());
1835    }
1836    // e < 0: render fractional digits
1837    let scale          = decimal_pow10(-e)?;
1838    let sign           = if c < 0 { "-" } else { "" };
1839    let abs_c          = c.abs();
1840    let int_part       = abs_c / scale;
1841    let frac_part      = abs_c % scale;
1842    let decimal_places = (-e) as usize;
1843    Ok(format!("{sign}{int_part}.{frac_part:0>decimal_places$}"))
1844}
1845
1846/// Extract `Option[Str]` arg as `Option<String>`. None and missing
1847/// arg both map to `None`. Used by the `cli` builders so callers can
1848/// pass `option.none()` or `Some("v")` interchangeably.
1849fn opt_str(arg: Option<&Value>) -> Option<String> {
1850    match arg {
1851        Some(Value::Variant { name, args }) if name == "Some" => {
1852            args.first().and_then(|v| match v {
1853                Value::Str(s) => Some(s.to_string()),
1854                _ => None,
1855            })
1856        }
1857        _ => None,
1858    }
1859}
1860
1861fn value_from_json(v: serde_json::Value) -> Value { Value::from_json(&v) }
1862
1863/// Process-wide cache of compiled regexes, keyed by the pattern
1864/// string. Compilation is the only cost we want to amortize; matching
1865/// the same `Regex` from multiple threads is safe (`regex::Regex` is
1866/// `Send + Sync`).
1867fn regex_cache() -> &'static Mutex<HashMap<String, regex::Regex>> {
1868    static CACHE: OnceLock<Mutex<HashMap<String, regex::Regex>>> = OnceLock::new();
1869    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
1870}
1871
1872fn get_or_compile_regex(pattern: &str) -> Result<regex::Regex, String> {
1873    let cache = regex_cache();
1874    {
1875        let guard = cache.lock().unwrap();
1876        if let Some(re) = guard.get(pattern) {
1877            return Ok(re.clone());
1878        }
1879    }
1880    let re = regex::Regex::new(pattern).map_err(|e| format!("invalid regex: {e}"))?;
1881    let mut guard = cache.lock().unwrap();
1882    guard.insert(pattern.to_string(), re.clone());
1883    Ok(re)
1884}
1885
1886/// Build a `Match` record value: `{ text, start, end, groups }` where
1887/// `groups` is the captured groups in order (group 0 is the full match).
1888/// Missing optional groups become empty strings.
1889fn match_value(caps: &regex::Captures) -> Value {
1890    let m0 = caps.get(0).expect("regex match always has group 0");
1891    let mut rec = indexmap::IndexMap::new();
1892    rec.insert("text".into(), Value::Str(m0.as_str().into()));
1893    rec.insert("start".into(), Value::Int(m0.start() as i64));
1894    rec.insert("end".into(), Value::Int(m0.end() as i64));
1895    let groups: std::collections::VecDeque<Value> = (1..caps.len())
1896        .map(|i| {
1897            Value::Str(
1898                caps.get(i)
1899                    .map(|m| m.as_str())
1900                    .unwrap_or_default()
1901                    .into(),
1902            )
1903        })
1904        .collect();
1905    rec.insert("groups".into(), Value::List(groups));
1906    Value::record_dynamic(rec)
1907}
1908
1909fn expect_map(v: Option<&Value>) -> Result<&BTreeMap<MapKey, Value>, String> {
1910    match v {
1911        Some(Value::Map(m)) => Ok(m),
1912        other => Err(format!("expected Map, got {other:?}")),
1913    }
1914}
1915
1916fn expect_set(v: Option<&Value>) -> Result<&BTreeSet<MapKey>, String> {
1917    match v {
1918        Some(Value::Set(s)) => Ok(s),
1919        other => Err(format!("expected Set, got {other:?}")),
1920    }
1921}
1922
1923/// Unpack any matrix-shaped Value into (rows, cols, flat row-major data).
1924/// Accepts the F64Array fast lane and the legacy `Record { rows, cols,
1925/// data: List[Float] }` shape for compatibility with hand-built matrices.
1926fn unpack_matrix(v: &Value) -> Result<(usize, usize, Vec<f64>), String> {
1927    if let Value::F64Array { rows, cols, data } = v {
1928        return Ok((*rows as usize, *cols as usize, data.clone()));
1929    }
1930    let rec = match v {
1931        Value::Record { fields: r, .. } => r,
1932        other => return Err(format!("expected matrix, got {other:?}")),
1933    };
1934    let rows = match rec.get("rows") {
1935        Some(Value::Int(n)) => *n as usize,
1936        _ => return Err("matrix: missing/invalid `rows`".into()),
1937    };
1938    let cols = match rec.get("cols") {
1939        Some(Value::Int(n)) => *n as usize,
1940        _ => return Err("matrix: missing/invalid `cols`".into()),
1941    };
1942    let data = match rec.get("data") {
1943        Some(Value::List(items)) => {
1944            let mut out = Vec::with_capacity(items.len());
1945            for it in items {
1946                out.push(match it {
1947                    Value::Float(f) => *f,
1948                    Value::Int(n) => *n as f64,
1949                    other => return Err(format!("matrix data: not numeric, got {other:?}")),
1950                });
1951            }
1952            out
1953        }
1954        _ => return Err("matrix: missing/invalid `data`".into()),
1955    };
1956    if data.len() != rows * cols {
1957        return Err(format!("matrix: data len {} != {rows}*{cols}", data.len()));
1958    }
1959    Ok((rows, cols, data))
1960}
1961
1962fn expect_bytes(v: Option<&Value>) -> Result<&Vec<u8>, String> {
1963    match v {
1964        Some(Value::Bytes(b)) => Ok(b),
1965        Some(other) => Err(format!("expected Bytes, got {other:?}")),
1966        None => Err("missing argument".into()),
1967    }
1968}
1969
1970fn first_arg(args: &[Value]) -> Result<&Value, String> {
1971    args.first().ok_or_else(|| "missing argument".into())
1972}
1973
1974fn tuple_index(v: &Value, i: usize) -> Result<Value, String> {
1975    match v {
1976        Value::Tuple(items) => items.get(i).cloned()
1977            .ok_or_else(|| format!("tuple index {i} out of range (len={})", items.len())),
1978        other => Err(format!("expected Tuple, got {other:?}")),
1979    }
1980}
1981
1982fn expect_str(v: Option<&Value>) -> Result<String, String> {
1983    match v {
1984        Some(Value::Str(s)) => Ok(s.to_string()),
1985        Some(other) => Err(format!("expected Str, got {other:?}")),
1986        None => Err("missing argument".into()),
1987    }
1988}
1989
1990fn expect_int(v: Option<&Value>) -> Result<i64, String> {
1991    match v {
1992        Some(Value::Int(n)) => Ok(*n),
1993        Some(other) => Err(format!("expected Int, got {other:?}")),
1994        None => Err("missing argument".into()),
1995    }
1996}
1997
1998fn expect_float(v: Option<&Value>) -> Result<f64, String> {
1999    match v {
2000        Some(Value::Float(f)) => Ok(*f),
2001        Some(other) => Err(format!("expected Float, got {other:?}")),
2002        None => Err("missing argument".into()),
2003    }
2004}
2005
2006fn expect_list(v: Option<&Value>) -> Result<&std::collections::VecDeque<Value>, String> {
2007    match v {
2008        Some(Value::List(xs)) => Ok(xs),
2009        Some(other) => Err(format!("expected List, got {other:?}")),
2010        None => Err("missing argument".into()),
2011    }
2012}
2013
2014fn expect_bool(v: Option<&Value>) -> Result<bool, String> {
2015    match v {
2016        Some(Value::Bool(b)) => Ok(*b),
2017        Some(other) => Err(format!("expected Bool, got {other:?}")),
2018        None => Err("missing argument".into()),
2019    }
2020}
2021
2022fn expect_deque(v: Option<&Value>) -> Result<&std::collections::VecDeque<Value>, String> {
2023    match v {
2024        Some(Value::Deque(d)) => Ok(d),
2025        Some(other) => Err(format!("expected Deque, got {other:?}")),
2026        None => Err("missing argument".into()),
2027    }
2028}
2029
2030fn some(v: Value) -> Value { Value::Variant { name: "Some".into(), args: vec![v] } }
2031fn none() -> Value { Value::Variant { name: "None".into(), args: Vec::new() } }
2032fn ok_v(v: Value) -> Value { Value::Variant { name: "Ok".into(), args: vec![v] } }
2033fn err_v(v: Value) -> Value { Value::Variant { name: "Err".into(), args: vec![v] } }
2034
2035// -- std.parser helpers (#217) ----------------------------------------
2036
2037/// Construct a tagged parser-AST node. The runtime representation is
2038/// `{ kind: "Char" | "Seq" | ..., ...children }`; the type system
2039/// treats these as opaque `Parser[T]` so user code can't poke at the
2040/// fields. Encoding is canonical because `IndexMap` insertion order
2041/// is stable and we always insert `kind` first.
2042fn parser_node(kind: &str, fields: &[(&str, Value)]) -> Value {
2043    let mut r = indexmap::IndexMap::new();
2044    r.insert("kind".into(), Value::Str(kind.into()));
2045    for (k, v) in fields {
2046        r.insert((*k).into(), v.clone());
2047    }
2048    Value::record_dynamic(r)
2049}
2050
2051// `parser.run` interpretation lives in `lex-bytecode::parser_runtime`
2052// (#221) — it needs reentrant Vm access to invoke closures inside
2053// `Map` / `AndThen` nodes, which the pure-builtin path doesn't have.
2054
2055// -- std.random helpers (#219) ----------------------------------------
2056
2057/// SplitMix64 — single-`u64` state PRNG that is byte-identical
2058/// across platforms (no float math, no platform-dependent reductions).
2059/// Returns `(drawn, next_state)`. Constants are the canonical
2060/// SplitMix64 mixer from the original 2014 paper.
2061fn splitmix64(state: u64) -> (u64, u64) {
2062    let next = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
2063    let mut z = next;
2064    z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
2065    z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
2066    let z = z ^ (z >> 31);
2067    (z, next)
2068}
2069
2070/// Encode a SplitMix64 state as the user-facing `Rng` value.
2071/// `Rng = { state :: Int }`; the type-checker treats `Rng` as
2072/// opaque so users can't poke at the field.
2073fn rng_value(state: u64) -> Value {
2074    let mut fields = indexmap::IndexMap::new();
2075    fields.insert("state".into(), Value::Int(state as i64));
2076    Value::record_dynamic(fields)
2077}
2078
2079/// Pull the SplitMix64 state out of a `Value::Record { state }`.
2080fn rng_decode(v: Option<&Value>) -> Result<u64, String> {
2081    let rec = match v {
2082        Some(Value::Record { fields: r, .. }) => r,
2083        Some(other) => return Err(format!("expected Rng, got {other:?}")),
2084        None => return Err("missing Rng arg".into()),
2085    };
2086    match rec.get("state") {
2087        Some(Value::Int(n)) => Ok(*n as u64),
2088        _ => Err("malformed Rng: missing `state :: Int`".into()),
2089    }
2090}
2091
2092// -- helpers for `std.http` builders / decoders --
2093
2094fn expect_record_pure(v: Option<&Value>) -> Result<&indexmap::IndexMap<smol_str::SmolStr, Value>, String> {
2095    match v {
2096        Some(Value::Record { fields: r, .. }) => Ok(r),
2097        Some(other) => Err(format!("expected Record, got {other:?}")),
2098        None => Err("missing Record argument".into()),
2099    }
2100}
2101
2102fn http_decode_err_pure(msg: String) -> Value {
2103    let inner = Value::Variant {
2104        name: "DecodeError".into(),
2105        args: vec![Value::Str(msg.into())],
2106    };
2107    err_v(inner)
2108}
2109
2110/// Apply or replace a header in an `HttpRequest` record's `headers`
2111/// field. Header names are normalized to lowercase to match HTTP/1.1
2112/// case-insensitivity; an existing entry under any casing is
2113/// overwritten by the new value.
2114fn http_set_header(
2115    mut req: indexmap::IndexMap<smol_str::SmolStr, Value>,
2116    name: &str,
2117    value: &str,
2118) -> indexmap::IndexMap<smol_str::SmolStr, Value> {
2119    use lex_bytecode::MapKey;
2120    let mut headers = match req.shift_remove("headers") {
2121        Some(Value::Map(m)) => m,
2122        _ => std::collections::BTreeMap::new(),
2123    };
2124    let key = MapKey::Str(name.to_lowercase());
2125    // Drop any case variant of the same header name first so casing
2126    // flips don't accumulate duplicates.
2127    let lowered = name.to_lowercase();
2128    headers.retain(|k, _| match k {
2129        MapKey::Str(s) => s.to_lowercase() != lowered,
2130        _ => true,
2131    });
2132    headers.insert(key, Value::Str(value.into()));
2133    req.insert("headers".into(), Value::Map(headers));
2134    req
2135}
2136
2137/// Append `?k=v&...` (URL-encoded) to the `url` field of an
2138/// `HttpRequest` record. Existing query string is preserved and
2139/// extended with `&`. Iteration order is the input map's natural
2140/// order (`BTreeMap` → sorted by key) so the produced URL is
2141/// deterministic.
2142fn http_append_query(
2143    mut req: indexmap::IndexMap<smol_str::SmolStr, Value>,
2144    params: &std::collections::BTreeMap<lex_bytecode::MapKey, Value>,
2145) -> indexmap::IndexMap<smol_str::SmolStr, Value> {
2146    use lex_bytecode::MapKey;
2147    let url = match req.get("url") {
2148        Some(Value::Str(s)) => s.clone(),
2149        _ => return req,
2150    };
2151    let mut pieces = Vec::new();
2152    for (k, v) in params {
2153        let kk = match k { MapKey::Str(s) => s.to_string(), _ => continue };
2154        let vv = match v { Value::Str(s) => s.to_string(), _ => continue };
2155        pieces.push(format!("{}={}", url_encode(&kk), url_encode(&vv)));
2156    }
2157    if pieces.is_empty() { return req; }
2158    let sep = if url.contains('?') { '&' } else { '?' };
2159    let new_url = format!("{url}{sep}{}", pieces.join("&"));
2160    req.insert("url".into(), Value::Str(new_url.into()));
2161    req
2162}
2163
2164/// Minimal RFC-3986 percent-encode for `application/x-www-form-
2165/// urlencoded` query values. Pulling in `urlencoding` for one
2166/// callsite would drag a dep into the runtime; the inline version is
2167/// short and easy to audit.
2168fn url_encode(s: &str) -> String {
2169    let mut out = String::with_capacity(s.len());
2170    for b in s.bytes() {
2171        match b {
2172            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
2173                out.push(b as char);
2174            }
2175            _ => out.push_str(&format!("%{:02X}", b)),
2176        }
2177    }
2178    out
2179}
2180
2181fn value_to_json(v: &Value) -> serde_json::Value { v.to_json() }
2182
2183/// The `toml` crate's serde adapter wraps datetimes in a sentinel
2184/// object `{"$__toml_private_datetime": "<rfc3339>"}` so that the
2185/// `Datetime` type round-trips through `serde::Value`. For Lex's
2186/// purposes a plain RFC-3339 string is what we want — callers can
2187/// then pipe through `datetime.parse_iso` if they need an
2188/// `Instant`. Walk the tree and replace each wrapper with its
2189/// inner string, in-place.
2190fn unwrap_toml_datetime_markers(v: &mut serde_json::Value) {
2191    use serde_json::Value as J;
2192    match v {
2193        J::Object(map) => {
2194            // Detect single-key marker objects and replace them
2195            // with their inner string. We have to take care to
2196            // avoid borrow conflicts.
2197            if map.len() == 1 {
2198                if let Some(J::String(s)) = map.get("$__toml_private_datetime") {
2199                    let s = s.clone();
2200                    *v = J::String(s);
2201                    return;
2202                }
2203            }
2204            for (_, child) in map.iter_mut() {
2205                unwrap_toml_datetime_markers(child);
2206            }
2207        }
2208        J::Array(items) => {
2209            for item in items.iter_mut() {
2210                unwrap_toml_datetime_markers(item);
2211            }
2212        }
2213        _ => {}
2214    }
2215}
2216
2217fn json_to_value(v: &serde_json::Value) -> Value { Value::from_json(v) }
2218
2219/// Extract the `List[Str]` of required field names from the second
2220/// argument of `*.parse_strict`. The list is allowed to be empty
2221/// (the parse degenerates to plain `parse`); other shapes are a
2222/// caller bug rather than a parse error.
2223fn required_field_names(arg: Option<&Value>) -> Result<Vec<String>, String> {
2224    let list = expect_list(arg)?;
2225    let mut out = Vec::with_capacity(list.len());
2226    for v in list {
2227        match v {
2228            Value::Str(s) => out.push(s.to_string()),
2229            other => return Err(format!(
2230                "parse_strict: required-fields list must contain Str, got {other:?}"
2231            )),
2232        }
2233    }
2234    Ok(out)
2235}
2236
2237/// Verify that `value` is an object containing every entry in
2238/// `required`. A required entry may be a plain field name (must
2239/// exist at the top level) or a dotted path (`"project.license"`)
2240/// which descends through nested objects. Returns a stable,
2241/// human-readable error listing every missing path so the agent's
2242/// verifier can surface it directly.
2243///
2244/// Tactical fix for #168 — gives users a way to make `parse[T]`
2245/// errors propagate as `Result::Err` instead of as runtime
2246/// `GetField` errors at access time. The full type-driven fix
2247/// (deriving `required` from `T` at type-check time so plain
2248/// `parse[T]` works, including auto-wrapping `Option[F]` fields
2249/// as not-required) is the cleaner endgame; see #168.
2250///
2251/// Path semantics:
2252/// * `"name"` → top-level `name` must be present (any value).
2253/// * `"a.b.c"` → walk `a`, then `b`, then check `c` exists. Each
2254///   intermediate value must itself be an object.
2255/// * `\\.` is the literal-dot escape (e.g. `"weird\\.key"` for a
2256///   field that genuinely contains a dot in its name).
2257fn check_required_fields(
2258    value: &serde_json::Value,
2259    required: &[String],
2260) -> Result<(), String> {
2261    if required.is_empty() {
2262        return Ok(());
2263    }
2264    if !matches!(value, serde_json::Value::Object(_)) {
2265        return Err(format!(
2266            "parse_strict: expected top-level object with fields {:?}, got {value}",
2267            required
2268        ));
2269    }
2270    let mut missing: Vec<String> = Vec::new();
2271    for path in required {
2272        if !path_exists(value, path) {
2273            missing.push(path.clone());
2274        }
2275    }
2276    if missing.is_empty() {
2277        Ok(())
2278    } else {
2279        Err(format!("missing required field(s): {}", missing.join(", ")))
2280    }
2281}
2282
2283/// Walk `value` along the dotted `path` and report whether the
2284/// terminal segment exists. Intermediate non-object stops surface
2285/// as "missing" — a path can't traverse through a string, list, or
2286/// scalar.
2287fn path_exists(value: &serde_json::Value, path: &str) -> bool {
2288    let mut cursor = value;
2289    let segments = split_dotted_path(path);
2290    for seg in &segments {
2291        match cursor {
2292            serde_json::Value::Object(o) => match o.get(seg.as_str()) {
2293                Some(next) => cursor = next,
2294                None => return false,
2295            },
2296            _ => return false,
2297        }
2298    }
2299    true
2300}
2301
2302/// Split `"a.b.c"` into `["a", "b", "c"]`, with `\.` recognised
2303/// as a literal-dot escape so legitimate dotted field names
2304/// (e.g. `"package\.json"`) don't accidentally start a descent.
2305fn split_dotted_path(path: &str) -> Vec<String> {
2306    let mut out: Vec<String> = Vec::new();
2307    let mut cur = String::new();
2308    let mut iter = path.chars().peekable();
2309    while let Some(c) = iter.next() {
2310        if c == '\\' {
2311            // Backslash at end is preserved; only `\.` is special.
2312            if let Some(&'.') = iter.peek() {
2313                cur.push('.');
2314                iter.next();
2315                continue;
2316            }
2317            cur.push(c);
2318        } else if c == '.' {
2319            out.push(std::mem::take(&mut cur));
2320        } else {
2321            cur.push(c);
2322        }
2323    }
2324    out.push(cur);
2325    out
2326}
2327
2328/// Extract the `List[(Str, Str)]` type schema from the third argument
2329/// of `*.parse_strict` (#322). If the argument is absent or malformed,
2330/// returns an empty vec — callers treat that as "skip type validation".
2331fn extract_type_schema(v: Option<&Value>) -> Vec<(String, String)> {
2332    match v {
2333        Some(Value::List(pairs)) => pairs.iter().filter_map(|p| {
2334            if let Value::Tuple(items) = p {
2335                if items.len() == 2 {
2336                    if let (Value::Str(name), Value::Str(tag)) = (&items[0], &items[1]) {
2337                        return Some((name.to_string(), tag.to_string()));
2338                    }
2339                }
2340            }
2341            None
2342        }).collect(),
2343        _ => vec![],
2344    }
2345}
2346
2347/// Validate each field in `json` against its declared type tag from
2348/// the schema. Returns `Err` for the first field whose JSON value
2349/// doesn't match its tag. Fields not present in the JSON object are
2350/// silently skipped (presence is enforced separately by
2351/// `check_required_fields`).
2352fn validate_field_types(
2353    json: &serde_json::Value,
2354    schema: &[(String, String)],
2355) -> Result<(), String> {
2356    if schema.is_empty() {
2357        return Ok(());
2358    }
2359    let obj = match json.as_object() {
2360        Some(o) => o,
2361        None => return Ok(()), // not an object — let other validation handle it
2362    };
2363    for (field, tag) in schema {
2364        if let Some(val) = obj.get(field) {
2365            if let Err(e) = check_json_type(val, tag) {
2366                return Err(format!("field `{field}`: {e}"));
2367            }
2368        }
2369    }
2370    Ok(())
2371}
2372
2373/// Post-process a Record produced by `json_to_value` to correctly wrap
2374/// `Option[X]` fields. `json_to_value` is schema-blind: it converts JSON null
2375/// to `Value::Unit` and never wraps non-null values in `some(...)`. This pass
2376/// fixes that for every field declared as `Option[X]` in the type schema.
2377fn apply_option_wrapping(v: Value, json: &serde_json::Value, schema: &[(String, String)]) -> Value {
2378    if schema.is_empty() {
2379        return v;
2380    }
2381    let fields = match v {
2382        Value::Record { fields, .. } => *fields,
2383        other => return other,
2384    };
2385    let json_obj = match json.as_object() {
2386        Some(o) => o,
2387        None => return Value::record_interned(fields),
2388    };
2389    let mut new_fields = fields;
2390    for (field_name, tag) in schema {
2391        if tag.starts_with("Option[") && tag.ends_with(']') {
2392            let json_val = json_obj.get(field_name.as_str());
2393            let wrapped = match json_val {
2394                None | Some(serde_json::Value::Null) => none(),
2395                Some(_) => {
2396                    let inner = new_fields
2397                        .get(field_name.as_str())
2398                        .cloned()
2399                        .unwrap_or(Value::Unit);
2400                    some(inner)
2401                }
2402            };
2403            new_fields.insert(smol_str::SmolStr::from(field_name.as_str()), wrapped);
2404        }
2405    }
2406    Value::record_interned(new_fields)
2407}
2408
2409/// Recursively check that `val` conforms to the compact type `tag`.
2410fn check_json_type(val: &serde_json::Value, tag: &str) -> Result<(), String> {
2411    use serde_json::Value as J;
2412    match (tag, val) {
2413        ("Int", J::Number(n)) if n.is_i64() || n.is_u64() => Ok(()),
2414        ("Int", other) => Err(format!("expected Int, got {}", json_type_name(other))),
2415        ("Float", J::Number(_)) => Ok(()),
2416        ("Float", other) => Err(format!("expected Float, got {}", json_type_name(other))),
2417        ("Bool", J::Bool(_)) => Ok(()),
2418        ("Bool", other) => Err(format!("expected Bool, got {}", json_type_name(other))),
2419        ("Str", J::String(_)) => Ok(()),
2420        ("Str", other) => Err(format!("expected Str, got {}", json_type_name(other))),
2421        // Option[X]: null maps to None — any null is acceptable
2422        (tag, J::Null) if tag.starts_with("Option[") => Ok(()),
2423        (tag, val) if tag.starts_with("Option[") && tag.ends_with(']') => {
2424            let inner = &tag[7..tag.len() - 1]; // strip "Option[" and "]"
2425            check_json_type(val, inner)
2426        }
2427        // List[X]: validate each element
2428        (tag, J::Array(items)) if tag.starts_with("List[") && tag.ends_with(']') => {
2429            let inner = &tag[5..tag.len() - 1]; // strip "List[" and "]"
2430            for (i, item) in items.iter().enumerate() {
2431                if let Err(e) = check_json_type(item, inner) {
2432                    return Err(format!("[{i}]: {e}"));
2433                }
2434            }
2435            Ok(())
2436        }
2437        ("Record", _) => Ok(()), // opaque nested record — skip deep check
2438        ("Any", _) => Ok(()),    // unknown type — skip
2439        _ => Ok(()),             // unrecognized tag — skip
2440    }
2441}
2442
2443fn json_type_name(v: &serde_json::Value) -> &'static str {
2444    match v {
2445        serde_json::Value::Null => "null",
2446        serde_json::Value::Bool(_) => "Bool",
2447        serde_json::Value::Number(_) => "Number",
2448        serde_json::Value::String(_) => "Str",
2449        serde_json::Value::Array(_) => "Array",
2450        serde_json::Value::Object(_) => "Object",
2451    }
2452}
2453
2454/// Parse a `.env`-style file into key→value pairs. Accepts:
2455///
2456/// * Blank lines and `# comment` lines (ignored).
2457/// * `KEY=VALUE` with no spaces around `=`. Optional surrounding
2458///   `"..."` or `'...'` quotes on the value. No escape sequences,
2459///   no shell expansion — by design; we want this to be a *data*
2460///   parser, not a shell snippet evaluator.
2461///
2462/// Errors carry the offending line number (1-indexed) so the
2463/// agent's verifier can point a human at the right place.
2464fn parse_dotenv(src: &str) -> Result<indexmap::IndexMap<String, String>, String> {
2465    let mut out = indexmap::IndexMap::new();
2466    for (idx, raw) in src.lines().enumerate() {
2467        let line = raw.trim();
2468        if line.is_empty() || line.starts_with('#') {
2469            continue;
2470        }
2471        // Optional `export KEY=VALUE` shell form — accepted for
2472        // compat with files that grew out of `set -a` workflows.
2473        let after_export = line.strip_prefix("export ").unwrap_or(line);
2474        let (k, v) = match after_export.split_once('=') {
2475            Some(kv) => kv,
2476            None => return Err(format!("dotenv.parse line {}: missing `=`", idx + 1)),
2477        };
2478        let key = k.trim();
2479        if key.is_empty() {
2480            return Err(format!("dotenv.parse line {}: empty key", idx + 1));
2481        }
2482        let v_trim = v.trim();
2483        let value = if let Some(q) = v_trim.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
2484            q.to_string()
2485        } else if let Some(q) = v_trim.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')) {
2486            q.to_string()
2487        } else {
2488            v_trim.to_string()
2489        };
2490        out.insert(key.to_string(), value);
2491    }
2492    Ok(out)
2493}
2494
2495// -- datetime helpers (Instant ↔ chrono::DateTime<Utc>) --
2496
2497/// Convert a `chrono::DateTime` (any `TimeZone`) into a Lex `Instant`,
2498/// represented as nanoseconds since the UTC unix epoch. Saturates on
2499/// out-of-range timestamps so the runtime never panics.
2500fn instant_from_chrono<Tz: chrono::TimeZone>(dt: chrono::DateTime<Tz>) -> i64 {
2501    dt.timestamp_nanos_opt().unwrap_or(i64::MAX)
2502}
2503
2504fn chrono_from_instant(n: i64) -> chrono::DateTime<chrono::Utc> {
2505    let secs = n.div_euclid(1_000_000_000);
2506    let nanos = n.rem_euclid(1_000_000_000) as u32;
2507    use chrono::TimeZone;
2508    chrono::Utc
2509        .timestamp_opt(secs, nanos)
2510        .single()
2511        .unwrap_or_else(chrono::Utc::now)
2512}
2513
2514fn format_iso(n: i64) -> String {
2515    chrono_from_instant(n).to_rfc3339()
2516}
2517
2518/// Parsed form of the user-side `Tz` variant. Mirrors the type
2519/// registered in `TypeEnv::new_with_builtins`.
2520enum TzArg {
2521    Utc,
2522    Local,
2523    /// Fixed offset in minutes east of UTC.
2524    Offset(i32),
2525    /// IANA name like `"America/New_York"`.
2526    Iana(String),
2527}
2528
2529fn parse_tz_arg(v: Option<&Value>) -> Result<TzArg, String> {
2530    match v {
2531        Some(Value::Variant { name, args }) => match (name.as_str(), args.as_slice()) {
2532            ("Utc", []) => Ok(TzArg::Utc),
2533            ("Local", []) => Ok(TzArg::Local),
2534            ("Offset", [Value::Int(m)]) => {
2535                let m = i32::try_from(*m).map_err(|_| {
2536                    format!("Tz::Offset: minutes out of range: {m}")
2537                })?;
2538                Ok(TzArg::Offset(m))
2539            }
2540            ("Iana", [Value::Str(s)]) => Ok(TzArg::Iana(s.to_string())),
2541            (other, _) => Err(format!(
2542                "expected Tz variant (Utc | Local | Offset(Int) | Iana(Str)), got `{other}` with {} arg(s)",
2543                args.len()
2544            )),
2545        },
2546        Some(other) => Err(format!("expected Tz variant, got {other:?}")),
2547        None => Err("missing Tz argument".into()),
2548    }
2549}
2550
2551fn resolve_tz_to_components(n: i64, tz: &TzArg) -> Result<Value, String> {
2552    use chrono::{TimeZone, Datelike, Timelike, Offset};
2553    let utc_dt = chrono_from_instant(n);
2554    let (y, m, d, hh, mm, ss, ns, off_min) = match tz {
2555        TzArg::Utc => {
2556            let d = utc_dt;
2557            (d.year(), d.month() as i32, d.day() as i32,
2558             d.hour() as i32, d.minute() as i32, d.second() as i32,
2559             d.nanosecond() as i32, 0)
2560        }
2561        TzArg::Local => {
2562            let d = utc_dt.with_timezone(&chrono::Local);
2563            let off = d.offset().fix().local_minus_utc() / 60;
2564            (d.year(), d.month() as i32, d.day() as i32,
2565             d.hour() as i32, d.minute() as i32, d.second() as i32,
2566             d.nanosecond() as i32, off)
2567        }
2568        TzArg::Offset(off_min) => {
2569            let off_secs = off_min.saturating_mul(60);
2570            let fixed = chrono::FixedOffset::east_opt(off_secs)
2571                .ok_or("to_components: offset out of range")?;
2572            let d = utc_dt.with_timezone(&fixed);
2573            (d.year(), d.month() as i32, d.day() as i32,
2574             d.hour() as i32, d.minute() as i32, d.second() as i32,
2575             d.nanosecond() as i32, *off_min)
2576        }
2577        TzArg::Iana(name) => {
2578            let tz: chrono_tz::Tz = name.parse()
2579                .map_err(|e| format!("to_components: unknown timezone `{name}`: {e}"))?;
2580            let d = utc_dt.with_timezone(&tz);
2581            let off = d.offset().fix().local_minus_utc() / 60;
2582            (d.year(), d.month() as i32, d.day() as i32,
2583             d.hour() as i32, d.minute() as i32, d.second() as i32,
2584             d.nanosecond() as i32, off)
2585        }
2586    };
2587    let mut rec = indexmap::IndexMap::new();
2588    rec.insert("year".into(),    Value::Int(y as i64));
2589    rec.insert("month".into(),   Value::Int(m as i64));
2590    rec.insert("day".into(),     Value::Int(d as i64));
2591    rec.insert("hour".into(),    Value::Int(hh as i64));
2592    rec.insert("minute".into(),  Value::Int(mm as i64));
2593    rec.insert("second".into(),  Value::Int(ss as i64));
2594    rec.insert("nano".into(),    Value::Int(ns as i64));
2595    rec.insert("tz_offset_minutes".into(), Value::Int(off_min as i64));
2596    let _ = chrono::Utc.timestamp_opt(0, 0); // touch TimeZone to suppress unused-import lint paths
2597    Ok(Value::record_dynamic(rec))
2598}
2599
2600
2601fn instant_from_components(rec: &indexmap::IndexMap<smol_str::SmolStr, Value>) -> Result<i64, String> {
2602    use chrono::TimeZone;
2603    fn get_int(rec: &indexmap::IndexMap<smol_str::SmolStr, Value>, k: &str) -> Result<i64, String> {
2604        match rec.get(k) {
2605            Some(Value::Int(n)) => Ok(*n),
2606            other => Err(format!("from_components: missing or non-int field `{k}`: {other:?}")),
2607        }
2608    }
2609    let y = get_int(rec, "year")? as i32;
2610    let m = get_int(rec, "month")? as u32;
2611    let d = get_int(rec, "day")? as u32;
2612    let hh = get_int(rec, "hour")? as u32;
2613    let mm = get_int(rec, "minute")? as u32;
2614    let ss = get_int(rec, "second")? as u32;
2615    let ns = get_int(rec, "nano")? as u32;
2616    let off_min = get_int(rec, "tz_offset_minutes")? as i32;
2617    let off = chrono::FixedOffset::east_opt(off_min * 60)
2618        .ok_or("from_components: offset out of range")?;
2619    let dt = off
2620        .with_ymd_and_hms(y, m, d, hh, mm, ss)
2621        .single()
2622        .ok_or("from_components: invalid or ambiguous date/time")?;
2623    let dt = dt + chrono::Duration::nanoseconds(ns as i64);
2624    Ok(instant_from_chrono(dt))
2625}
2626
2627// ── AEAD helpers (#382 AEAD slice) ────────────────────────────────────
2628//
2629// Each `*_seal_impl` returns a `Result[AeadResult, Str]` Lex Variant:
2630// `Ok(AeadResult { ciphertext, tag })` on success, `Err(msg)` on input
2631// validation failure (wrong key/nonce length). Each `*_open_impl`
2632// returns `Result[Bytes, Str]` — authentication failure (bad tag /
2633// modified ciphertext) surfaces as `Err`, not a panic.
2634//
2635// Pure ops: every output is a deterministic function of the inputs;
2636// no syscalls, no clock reads, no entropy. Live in the pure-builtin
2637// dispatch table so callers don't need an effect grant beyond
2638// whatever they used to obtain key + nonce in the first place.
2639
2640/// `(key, nonce, aad, plaintext)` references unpacked from a 4-arg
2641/// AEAD seal call. Aliased so the `type_complexity` clippy lint stays
2642/// quiet on the tuple of four borrows.
2643type Aead4<'a> = (&'a Vec<u8>, &'a Vec<u8>, &'a Vec<u8>, &'a Vec<u8>);
2644
2645/// `(key, nonce, aad, ciphertext, tag)` references unpacked from a
2646/// 5-arg AEAD open call.
2647type Aead5<'a> = (&'a Vec<u8>, &'a Vec<u8>, &'a Vec<u8>, &'a Vec<u8>, &'a Vec<u8>);
2648
2649fn unpack4_bytes<'a>(
2650    args: &'a [Value],
2651    op: &str,
2652) -> Result<Aead4<'a>, String> {
2653    let pick = |i: usize, name: &str| -> Result<&'a Vec<u8>, String> {
2654        match args.get(i) {
2655            Some(Value::Bytes(b)) => Ok(b),
2656            Some(other) => Err(format!("{op}: {name} must be Bytes, got {other:?}")),
2657            None => Err(format!("{op}: missing {name} argument")),
2658        }
2659    };
2660    Ok((pick(0, "key")?, pick(1, "nonce")?, pick(2, "aad")?, pick(3, "plaintext")?))
2661}
2662
2663fn unpack5_bytes<'a>(
2664    args: &'a [Value],
2665    op: &str,
2666) -> Result<Aead5<'a>, String> {
2667    let pick = |i: usize, name: &str| -> Result<&'a Vec<u8>, String> {
2668        match args.get(i) {
2669            Some(Value::Bytes(b)) => Ok(b),
2670            Some(other) => Err(format!("{op}: {name} must be Bytes, got {other:?}")),
2671            None => Err(format!("{op}: missing {name} argument")),
2672        }
2673    };
2674    Ok((
2675        pick(0, "key")?,
2676        pick(1, "nonce")?,
2677        pick(2, "aad")?,
2678        pick(3, "ciphertext")?,
2679        pick(4, "tag")?,
2680    ))
2681}
2682
2683fn aead_result(ciphertext: Vec<u8>, tag: Vec<u8>) -> Value {
2684    let mut rec = indexmap::IndexMap::new();
2685    rec.insert("ciphertext".into(), Value::Bytes(ciphertext));
2686    rec.insert("tag".into(), Value::Bytes(tag));
2687    Value::record_dynamic(rec)
2688}
2689
2690fn aead_err(msg: impl Into<String>) -> Value {
2691    let s: String = msg.into();
2692    err_v(Value::Str(s.into()))
2693}
2694
2695fn aes_gcm_seal_impl(args: &[Value]) -> Value {
2696    use aes_gcm::aead::{Aead, KeyInit, Payload};
2697    use aes_gcm::{Aes128Gcm, Aes256Gcm, Nonce};
2698    let (key, nonce, aad, plaintext) = match unpack4_bytes(args, "aes_gcm_seal") {
2699        Ok(t) => t,
2700        Err(e) => return aead_err(e),
2701    };
2702    if nonce.len() != 12 {
2703        return aead_err(format!(
2704            "aes_gcm_seal: nonce must be exactly 12 bytes, got {}", nonce.len()
2705        ));
2706    }
2707    let n = Nonce::from_slice(nonce);
2708    let payload = Payload { msg: plaintext, aad };
2709    // Encrypts and appends the 16-byte tag. We split the tag back out so
2710    // the caller sees the structured AeadResult shape.
2711    let combined = match key.len() {
2712        16 => {
2713            let cipher = Aes128Gcm::new_from_slice(key)
2714                .map_err(|e| e.to_string());
2715            match cipher {
2716                Ok(c) => c.encrypt(n, payload).map_err(|e| format!("aes_gcm_seal: {e}")),
2717                Err(e) => Err(format!("aes_gcm_seal: {e}")),
2718            }
2719        }
2720        32 => {
2721            let cipher = Aes256Gcm::new_from_slice(key)
2722                .map_err(|e| e.to_string());
2723            match cipher {
2724                Ok(c) => c.encrypt(n, payload).map_err(|e| format!("aes_gcm_seal: {e}")),
2725                Err(e) => Err(format!("aes_gcm_seal: {e}")),
2726            }
2727        }
2728        // AES-192 is rarely used; the aes-gcm crate doesn't expose
2729        // Aes192Gcm in its default API. Reject other sizes explicitly.
2730        other => return aead_err(format!(
2731            "aes_gcm_seal: key must be 16 or 32 bytes, got {other}"
2732        )),
2733    };
2734    match combined {
2735        Ok(mut buf) => {
2736            // tag is the last 16 bytes.
2737            let tag_start = buf.len() - 16;
2738            let tag = buf.split_off(tag_start);
2739            ok_v(aead_result(buf, tag))
2740        }
2741        Err(e) => aead_err(e),
2742    }
2743}
2744
2745fn aes_gcm_open_impl(args: &[Value]) -> Value {
2746    use aes_gcm::aead::{Aead, KeyInit, Payload};
2747    use aes_gcm::{Aes128Gcm, Aes256Gcm, Nonce};
2748    let (key, nonce, aad, ciphertext, tag) = match unpack5_bytes(args, "aes_gcm_open") {
2749        Ok(t) => t,
2750        Err(e) => return err_v(Value::Str(e.into())),
2751    };
2752    if nonce.len() != 12 {
2753        return err_v(Value::Str(format!(
2754            "aes_gcm_open: nonce must be exactly 12 bytes, got {}", nonce.len()
2755        ).into()));
2756    }
2757    if tag.len() != 16 {
2758        return err_v(Value::Str(format!(
2759            "aes_gcm_open: tag must be exactly 16 bytes, got {}", tag.len()
2760        ).into()));
2761    }
2762    // Rebuild the "ciphertext || tag" buffer the aes-gcm crate expects.
2763    let mut combined = Vec::with_capacity(ciphertext.len() + tag.len());
2764    combined.extend_from_slice(ciphertext);
2765    combined.extend_from_slice(tag);
2766    let n = Nonce::from_slice(nonce);
2767    let payload = Payload { msg: &combined, aad };
2768    let plaintext = match key.len() {
2769        16 => Aes128Gcm::new_from_slice(key)
2770            .map_err(|e| format!("aes_gcm_open: {e}"))
2771            .and_then(|c| c.decrypt(n, payload).map_err(|e| format!("aes_gcm_open: {e}"))),
2772        32 => Aes256Gcm::new_from_slice(key)
2773            .map_err(|e| format!("aes_gcm_open: {e}"))
2774            .and_then(|c| c.decrypt(n, payload).map_err(|e| format!("aes_gcm_open: {e}"))),
2775        other => return err_v(Value::Str(format!(
2776            "aes_gcm_open: key must be 16 or 32 bytes, got {other}"
2777        ).into())),
2778    };
2779    match plaintext {
2780        Ok(p) => ok_v(Value::Bytes(p)),
2781        Err(e) => err_v(Value::Str(e.into())),
2782    }
2783}
2784
2785fn chacha20_seal_impl(args: &[Value]) -> Value {
2786    use chacha20poly1305::aead::{Aead, KeyInit, Payload};
2787    use chacha20poly1305::{ChaCha20Poly1305, Nonce};
2788    let (key, nonce, aad, plaintext) = match unpack4_bytes(args, "chacha20_poly1305_seal") {
2789        Ok(t) => t,
2790        Err(e) => return aead_err(e),
2791    };
2792    if key.len() != 32 {
2793        return aead_err(format!(
2794            "chacha20_poly1305_seal: key must be exactly 32 bytes, got {}", key.len()
2795        ));
2796    }
2797    if nonce.len() != 12 {
2798        return aead_err(format!(
2799            "chacha20_poly1305_seal: nonce must be exactly 12 bytes, got {}", nonce.len()
2800        ));
2801    }
2802    let cipher = ChaCha20Poly1305::new_from_slice(key)
2803        .map_err(|e| format!("chacha20_poly1305_seal: {e}"));
2804    let n = Nonce::from_slice(nonce);
2805    let payload = Payload { msg: plaintext, aad };
2806    let combined = match cipher {
2807        Ok(c) => c.encrypt(n, payload).map_err(|e| format!("chacha20_poly1305_seal: {e}")),
2808        Err(e) => Err(e),
2809    };
2810    match combined {
2811        Ok(mut buf) => {
2812            let tag_start = buf.len() - 16;
2813            let tag = buf.split_off(tag_start);
2814            ok_v(aead_result(buf, tag))
2815        }
2816        Err(e) => aead_err(e),
2817    }
2818}
2819
2820fn chacha20_open_impl(args: &[Value]) -> Value {
2821    use chacha20poly1305::aead::{Aead, KeyInit, Payload};
2822    use chacha20poly1305::{ChaCha20Poly1305, Nonce};
2823    let (key, nonce, aad, ciphertext, tag) = match unpack5_bytes(args, "chacha20_poly1305_open") {
2824        Ok(t) => t,
2825        Err(e) => return err_v(Value::Str(e.into())),
2826    };
2827    if key.len() != 32 {
2828        return err_v(Value::Str(format!(
2829            "chacha20_poly1305_open: key must be exactly 32 bytes, got {}", key.len()
2830        ).into()));
2831    }
2832    if nonce.len() != 12 {
2833        return err_v(Value::Str(format!(
2834            "chacha20_poly1305_open: nonce must be exactly 12 bytes, got {}", nonce.len()
2835        ).into()));
2836    }
2837    if tag.len() != 16 {
2838        return err_v(Value::Str(format!(
2839            "chacha20_poly1305_open: tag must be exactly 16 bytes, got {}", tag.len()
2840        ).into()));
2841    }
2842    let mut combined = Vec::with_capacity(ciphertext.len() + tag.len());
2843    combined.extend_from_slice(ciphertext);
2844    combined.extend_from_slice(tag);
2845    let cipher = ChaCha20Poly1305::new_from_slice(key)
2846        .map_err(|e| format!("chacha20_poly1305_open: {e}"));
2847    let n = Nonce::from_slice(nonce);
2848    let payload = Payload { msg: &combined, aad };
2849    match cipher.and_then(|c| c.decrypt(n, payload).map_err(|e| format!("chacha20_poly1305_open: {e}"))) {
2850        Ok(p) => ok_v(Value::Bytes(p)),
2851        Err(e) => err_v(Value::Str(e.into())),
2852    }
2853}
2854
2855// ── KDFs (#382 KDF slice) ──────────────────────────────────────────────────
2856//
2857// All three primitives return Result[Bytes, Str] so caller-controlled
2858// inputs (iteration count, output length, argon2id work factors) that
2859// violate the underlying crate's contract surface as Err, never as a
2860// VM panic.
2861
2862/// `(password :: Bytes, salt :: Bytes, iterations :: Int, len :: Int)`
2863/// references unpacked from a 4-arg KDF call.
2864type Kdf4<'a> = (&'a Vec<u8>, &'a Vec<u8>, i64, i64);
2865
2866/// `(ikm :: Bytes, salt :: Bytes, info :: Bytes, len :: Int)`
2867/// references unpacked from a 4-arg HKDF call.
2868type Hkdf4<'a> = (&'a Vec<u8>, &'a Vec<u8>, &'a Vec<u8>, i64);
2869
2870/// `(password :: Bytes, salt :: Bytes, t_cost :: Int, m_cost :: Int, len :: Int)`
2871/// for argon2id.
2872type Argon5<'a> = (&'a Vec<u8>, &'a Vec<u8>, i64, i64, i64);
2873
2874fn pick_bytes<'a>(args: &'a [Value], i: usize, op: &str, name: &str)
2875    -> Result<&'a Vec<u8>, String>
2876{
2877    match args.get(i) {
2878        Some(Value::Bytes(b)) => Ok(b),
2879        Some(other) => Err(format!("{op}: {name} must be Bytes, got {other:?}")),
2880        None => Err(format!("{op}: missing {name} argument")),
2881    }
2882}
2883
2884fn pick_int(args: &[Value], i: usize, op: &str, name: &str) -> Result<i64, String> {
2885    match args.get(i) {
2886        Some(Value::Int(n)) => Ok(*n),
2887        Some(other) => Err(format!("{op}: {name} must be Int, got {other:?}")),
2888        None => Err(format!("{op}: missing {name} argument")),
2889    }
2890}
2891
2892fn unpack_kdf4<'a>(args: &'a [Value], op: &str) -> Result<Kdf4<'a>, String> {
2893    Ok((
2894        pick_bytes(args, 0, op, "password")?,
2895        pick_bytes(args, 1, op, "salt")?,
2896        pick_int(args, 2, op, "iterations")?,
2897        pick_int(args, 3, op, "len")?,
2898    ))
2899}
2900
2901fn unpack_hkdf4<'a>(args: &'a [Value], op: &str) -> Result<Hkdf4<'a>, String> {
2902    Ok((
2903        pick_bytes(args, 0, op, "ikm")?,
2904        pick_bytes(args, 1, op, "salt")?,
2905        pick_bytes(args, 2, op, "info")?,
2906        pick_int(args, 3, op, "len")?,
2907    ))
2908}
2909
2910fn unpack_argon5<'a>(args: &'a [Value], op: &str) -> Result<Argon5<'a>, String> {
2911    Ok((
2912        pick_bytes(args, 0, op, "password")?,
2913        pick_bytes(args, 1, op, "salt")?,
2914        pick_int(args, 2, op, "t_cost")?,
2915        pick_int(args, 3, op, "m_cost")?,
2916        pick_int(args, 4, op, "len")?,
2917    ))
2918}
2919
2920/// Output-length sanity check shared by all three KDFs. A negative or
2921/// absurdly large `len` is a programmer error, not a runtime concern;
2922/// we cap at 1 MiB to keep accidental `i64::MAX` calls from OOMing the
2923/// process.
2924const KDF_MAX_LEN: usize = 1024 * 1024;
2925
2926fn check_len(op: &str, len: i64) -> Result<usize, String> {
2927    if len <= 0 {
2928        return Err(format!("{op}: len must be > 0, got {len}"));
2929    }
2930    if (len as u64) > KDF_MAX_LEN as u64 {
2931        return Err(format!(
2932            "{op}: len must be <= {KDF_MAX_LEN}, got {len}"
2933        ));
2934    }
2935    Ok(len as usize)
2936}
2937
2938fn pbkdf2_sha256_impl(args: &[Value]) -> Value {
2939    use hmac::Hmac;
2940    use sha2::Sha256;
2941    let op = "pbkdf2_sha256";
2942    let (password, salt, iterations, len) = match unpack_kdf4(args, op) {
2943        Ok(t) => t,
2944        Err(e) => return err_v(Value::Str(e.into())),
2945    };
2946    if iterations <= 0 {
2947        return err_v(Value::Str(format!(
2948            "{op}: iterations must be > 0, got {iterations}"
2949        ).into()));
2950    }
2951    let out_len = match check_len(op, len) {
2952        Ok(n) => n,
2953        Err(e) => return err_v(Value::Str(e.into())),
2954    };
2955    let rounds = match u32::try_from(iterations) {
2956        Ok(r) => r,
2957        Err(_) => {
2958            return err_v(Value::Str(format!(
2959                "{op}: iterations must fit in u32, got {iterations}"
2960            ).into()))
2961        }
2962    };
2963    let mut out = vec![0u8; out_len];
2964    if let Err(e) = pbkdf2::pbkdf2::<Hmac<Sha256>>(password, salt, rounds, &mut out) {
2965        return err_v(Value::Str(format!("{op}: {e}").into()));
2966    }
2967    ok_v(Value::Bytes(out))
2968}
2969
2970fn hkdf_sha256_impl(args: &[Value]) -> Value {
2971    use hkdf::Hkdf;
2972    use sha2::Sha256;
2973    let op = "hkdf_sha256";
2974    let (ikm, salt, info, len) = match unpack_hkdf4(args, op) {
2975        Ok(t) => t,
2976        Err(e) => return err_v(Value::Str(e.into())),
2977    };
2978    let out_len = match check_len(op, len) {
2979        Ok(n) => n,
2980        Err(e) => return err_v(Value::Str(e.into())),
2981    };
2982    // RFC 5869 caps output at 255 * HashLen; the `expand` call below
2983    // returns InvalidLength when exceeded — surface that as Err.
2984    let salt_opt: Option<&[u8]> = if salt.is_empty() { None } else { Some(salt) };
2985    let hk = Hkdf::<Sha256>::new(salt_opt, ikm);
2986    let mut out = vec![0u8; out_len];
2987    match hk.expand(info, &mut out) {
2988        Ok(()) => ok_v(Value::Bytes(out)),
2989        Err(e) => err_v(Value::Str(format!("{op}: {e}").into())),
2990    }
2991}
2992
2993fn argon2id_impl(args: &[Value]) -> Value {
2994    use argon2::{Algorithm, Argon2, Params, Version};
2995    let op = "argon2id";
2996    let (password, salt, t_cost, m_cost, len) = match unpack_argon5(args, op) {
2997        Ok(t) => t,
2998        Err(e) => return err_v(Value::Str(e.into())),
2999    };
3000    let out_len = match check_len(op, len) {
3001        Ok(n) => n,
3002        Err(e) => return err_v(Value::Str(e.into())),
3003    };
3004    let t = match u32::try_from(t_cost) {
3005        Ok(n) if n >= 1 => n,
3006        _ => return err_v(Value::Str(format!(
3007            "{op}: t_cost must be a u32 >= 1, got {t_cost}"
3008        ).into())),
3009    };
3010    let m = match u32::try_from(m_cost) {
3011        Ok(n) if n >= Params::MIN_M_COST => n,
3012        _ => return err_v(Value::Str(format!(
3013            "{op}: m_cost must be a u32 >= {}, got {m_cost}",
3014            Params::MIN_M_COST
3015        ).into())),
3016    };
3017    // p=1 is the default and what every interop spec assumes (PHC
3018    // string, libsodium's argon2id_str). We don't expose parallelism
3019    // as a knob for now to keep callers from picking a value that
3020    // makes hashes uncomparable across machines.
3021    let params = match Params::new(m, t, 1, Some(out_len)) {
3022        Ok(p) => p,
3023        Err(e) => return err_v(Value::Str(format!("{op}: {e}").into())),
3024    };
3025    let hasher = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
3026    let mut out = vec![0u8; out_len];
3027    if let Err(e) = hasher.hash_password_into(password, salt, &mut out) {
3028        return err_v(Value::Str(format!("{op}: {e}").into()));
3029    }
3030    ok_v(Value::Bytes(out))
3031}