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