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