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