Skip to main content

algocline_engine/bridge/
data.rs

1use std::collections::HashMap;
2use std::path::Path;
3use std::sync::Arc;
4
5use algocline_core::{CustomMetricsHandle, LogEntry, LogSink, StatsHandle};
6use mlua::prelude::*;
7use mlua::{LuaSerdeExt, SerializeOptions};
8
9use crate::card::{self, FileCardStore};
10use crate::state::{JsonFileStore, StateStore};
11
12/// Converts a `serde_json::Value` to a Lua value with JSON null surfacing as
13/// Lua nil (not the mlua default lightuserdata sentinel).
14///
15/// All `bridge::data` accessors that expose serde JSON to Lua funnel through
16/// this helper so the null-handling contract is uniform across `alc.json_decode`,
17/// `alc.state.*`, `alc.card.*`, `alc.stats.*`, and similar bridge surfaces.
18/// JSON `null` always decodes to Lua `nil`, preventing `if obj.x then ...`
19/// truthy checks from passing through nullable fields and crashing downstream
20/// operations (e.g. `io.open(value)`).
21///
22/// See issues db041966 (originating fix for `alc.json_decode`) and ff6372af
23/// (sweep that unified all 16 callsites within `bridge/data.rs`).
24///
25/// Generic over `T: serde::Serialize` so the same contract applies to
26/// `serde_json::Value` callers (which can carry `Value::Null`) and to typed
27/// structs that may contain `Option<_>` fields (e.g. `SinkBackfillReport`).
28/// Plain types without any nullable shape (`Vec<String>`, etc.) are still
29/// safe to funnel through this helper — the option toggles only affect
30/// `None` / `()` paths.
31fn to_lua_value<T: serde::Serialize + ?Sized>(lua: &Lua, value: &T) -> LuaResult<LuaValue> {
32    let options = SerializeOptions::new()
33        .serialize_none_to_null(false)
34        .serialize_unit_to_null(false);
35    lua.to_value_with(value, options)
36}
37
38pub(super) fn register_json(lua: &Lua, alc_table: &LuaTable) -> LuaResult<()> {
39    let encode = lua.create_function(|lua, value: LuaValue| {
40        let json: serde_json::Value = lua.from_value(value)?;
41        serde_json::to_string(&json).map_err(LuaError::external)
42    })?;
43
44    // alc.json_decode funnels through `to_lua_value` for the canonical
45    // JSON null -> Lua nil contract. See issue db041966.
46    let decode = lua.create_function(|lua, s: String| {
47        let value: serde_json::Value = serde_json::from_str(&s).map_err(LuaError::external)?;
48        to_lua_value(lua, &value)
49    })?;
50
51    alc_table.set("json_encode", encode)?;
52    alc_table.set("json_decode", decode)?;
53    Ok(())
54}
55
56/// Register `alc.log(level, msg)` — routes Lua log calls to tracing and to the
57/// per-session [`LogSink`] ring buffer.
58///
59/// # Arguments
60///
61/// - `lua` — The Lua VM.
62/// - `alc_table` — The `alc` table to register the function on.
63/// - `log_sink` — Shared ring buffer; the entry is pushed in addition to the
64///   existing tracing output so stderr tail is unaffected.
65///
66/// # Errors
67///
68/// Returns `LuaError` only if function or table registration fails (mlua infra).
69pub(super) fn register_log(lua: &Lua, alc_table: &LuaTable, log_sink: LogSink) -> LuaResult<()> {
70    let log = lua.create_function(move |_, (level, msg): (String, String)| {
71        // Existing tracing path — preserves stderr/log-file output.
72        match level.as_str() {
73            "error" => tracing::error!(target: "alc.log", "{}", msg),
74            "warn" => tracing::warn!(target: "alc.log", "{}", msg),
75            "info" => tracing::info!(target: "alc.log", "{}", msg),
76            "debug" => tracing::debug!(target: "alc.log", "{}", msg),
77            _ => tracing::info!(target: "alc.log", "{}", msg),
78        }
79        // Push to per-session ring buffer for alc_status recent_logs.
80        log_sink.push(LogEntry::new(level.clone(), "alc.log", msg));
81        Ok(())
82    })?;
83
84    alc_table.set("log", log)?;
85    Ok(())
86}
87
88/// Register a Lua `print()` override that routes output to the per-session
89/// [`LogSink`] ring buffer and to `tracing::info!(target: "alc.lua.print")`.
90///
91/// Behaviour mirrors the standard `print`:
92/// - Multiple arguments are joined with `"\t"`.
93/// - Each argument is coerced to a string.
94/// - Trailing newlines are stripped before storing in the ring buffer.
95///
96/// The existing tracing path is preserved so operator `tail -f` workflows
97/// are unaffected.  `io.write` is intentionally left unchanged.
98///
99/// # Arguments
100///
101/// - `lua` — The Lua VM.
102/// - `log_sink` — Shared ring buffer for this session.
103///
104/// # Errors
105///
106/// Returns `LuaError` only if function or global registration fails (mlua infra).
107pub(super) fn register_print(lua: &Lua, log_sink: LogSink) -> LuaResult<()> {
108    let print_fn = lua.create_function(move |lua_inner, args: mlua::MultiValue| {
109        let parts: Vec<String> = args
110            .iter()
111            .map(|v| match v {
112                LuaValue::Nil => "nil".to_string(),
113                LuaValue::Boolean(b) => b.to_string(),
114                LuaValue::Integer(n) => n.to_string(),
115                LuaValue::Number(n) => {
116                    // Reproduce Lua's default float formatting: no trailing zeros
117                    // for whole-number values.
118                    if n.fract() == 0.0 && n.abs() < 1e15_f64 {
119                        format!("{n:.1}")
120                    } else {
121                        format!("{n}")
122                    }
123                }
124                other => lua_inner
125                    .coerce_string(other.clone())
126                    .ok()
127                    .flatten()
128                    .and_then(|s| s.to_str().ok().map(|r| r.to_string()))
129                    .unwrap_or_else(|| format!("{other:?}")),
130            })
131            .collect();
132        let line = parts.join("\t");
133        // Emit to tracing — operator log-file / stderr path preserved.
134        tracing::info!(target: "alc.lua.print", "{}", line);
135        // Push trimmed message to per-session ring buffer.
136        let message = line.trim_end_matches('\n').to_string();
137        log_sink.push(LogEntry::new("info", "alc.lua.print", message));
138        Ok(())
139    })?;
140    lua.globals().set("print", print_fn)?;
141    Ok(())
142}
143
144/// Register `alc.state` table with get/set/keys/delete/has/set_nx/incr/list/show/reset.
145///
146/// Lua usage:
147///   alc.state.set("score", 42)
148///   local v = alc.state.get("score")       -- 42
149///   local v = alc.state.get("missing", 0)  -- 0 (default)
150///   local k = alc.state.keys()             -- {"score"}
151///   alc.state.delete("score")
152///   alc.state.has("score")                 -- false
153///   alc.state.set_nx("score", 100)         -- true (set because absent)
154///   alc.state.incr("counter")              -- 1 (init 0 + delta 1)
155///   alc.state.incr("counter", 5)           -- 6
156///   alc.state.incr("counter", 10, 100)     -- 16 (default ignored)
157///   alc.state.list("my_ns")               -- {"task_a", "task_b"} (sorted)
158///   alc.state.show("my_ns", "task_a")     -- full JSON table
159///   alc.state.reset("my_ns", "task_a", {steps={"1b_X"}, fields={"x"}})
160///                                          -- { ok=true, backup_path="...", steps_removed=1, fields_removed=1 }
161pub(super) fn register_state(
162    lua: &Lua,
163    alc_table: &LuaTable,
164    ns: String,
165    state_store: Arc<JsonFileStore>,
166) -> LuaResult<()> {
167    let state_table = lua.create_table()?;
168
169    // alc.state.get(key, default?)
170    let ns_get = ns.clone();
171    let store_get = Arc::clone(&state_store);
172    let get =
173        lua.create_function(
174            move |lua, (key, default): (String, Option<LuaValue>)| match store_get
175                .get(&ns_get, &key)
176            {
177                Ok(Some(v)) => to_lua_value(lua, &v),
178                Ok(None) => Ok(default.unwrap_or(LuaValue::Nil)),
179                Err(e) => Err(LuaError::external(e)),
180            },
181        )?;
182
183    // alc.state.set(key, value)
184    let ns_set = ns.clone();
185    let store_set = Arc::clone(&state_store);
186    let set = lua.create_function(move |lua, (key, value): (String, LuaValue)| {
187        let json: serde_json::Value = lua.from_value(value)?;
188        store_set
189            .set(&ns_set, &key, json)
190            .map_err(LuaError::external)
191    })?;
192
193    // alc.state.keys()
194    let ns_keys = ns.clone();
195    let store_keys = Arc::clone(&state_store);
196    let keys = lua.create_function(move |lua, ()| {
197        let k = store_keys.keys(&ns_keys).map_err(LuaError::external)?;
198        to_lua_value(lua, &k)
199    })?;
200
201    // alc.state.delete(key)
202    let ns_del = ns.clone();
203    let store_del = Arc::clone(&state_store);
204    let delete = lua.create_function(move |_, key: String| {
205        store_del.delete(&ns_del, &key).map_err(LuaError::external)
206    })?;
207
208    // alc.state.has(key) -> bool
209    let ns_has = ns.clone();
210    let store_has = Arc::clone(&state_store);
211    let has = lua.create_function(move |_, key: String| {
212        store_has.has(&ns_has, &key).map_err(LuaError::external)
213    })?;
214
215    // alc.state.set_nx(key, value) -> bool
216    let ns_snx = ns.clone();
217    let store_snx = Arc::clone(&state_store);
218    let set_nx = lua.create_function(move |lua, (key, value): (String, LuaValue)| {
219        let json: serde_json::Value = lua.from_value(value)?;
220        store_snx
221            .set_nx(&ns_snx, &key, json)
222            .map_err(LuaError::external)
223    })?;
224
225    // alc.state.incr(key, delta?, default?) -> number
226    let ns_incr = ns;
227    let store_incr = Arc::clone(&state_store);
228    let incr = lua.create_function(
229        move |_, (key, delta, default): (String, Option<f64>, Option<f64>)| {
230            store_incr
231                .incr(&ns_incr, &key, delta.unwrap_or(1.0), default.unwrap_or(0.0))
232                .map_err(LuaError::external)
233        },
234    )?;
235
236    // alc.state.list(namespace) -> string[]
237    let store_list = Arc::clone(&state_store);
238    let list = lua.create_function(move |lua, namespace: String| {
239        let keys = store_list
240            .list_dispatched(&namespace)
241            .map_err(LuaError::external)?;
242        to_lua_value(lua, &keys)
243    })?;
244
245    // alc.state.show(namespace, key) -> table
246    let store_show = Arc::clone(&state_store);
247    let show = lua.create_function(move |lua, (namespace, key): (String, String)| {
248        let v = store_show
249            .show_dispatched(&namespace, &key)
250            .map_err(LuaError::external)?;
251        to_lua_value(lua, &v)
252    })?;
253
254    // alc.state.reset(namespace, key, opts?) -> { ok, backup_path, steps_removed, fields_removed }
255    let store_reset = Arc::clone(&state_store);
256    let reset = lua.create_function(
257        move |lua, (namespace, key, opts): (String, String, Option<LuaTable>)| {
258            let (steps, fields) = match opts {
259                Some(t) => {
260                    let s = t.get::<Option<Vec<String>>>("steps")?.unwrap_or_default();
261                    let f = t.get::<Option<Vec<String>>>("fields")?.unwrap_or_default();
262                    (s, f)
263                }
264                None => (Vec::new(), Vec::new()),
265            };
266            let report = store_reset
267                .reset_dispatched_with_backup(&namespace, &key, &steps, &fields)
268                .map_err(LuaError::external)?;
269            let ret = lua.create_table()?;
270            ret.set("ok", true)?;
271            ret.set(
272                "backup_path",
273                report.backup_path.to_string_lossy().to_string(),
274            )?;
275            ret.set("steps_removed", report.steps_removed)?;
276            ret.set("fields_removed", report.fields_removed)?;
277            Ok(ret)
278        },
279    )?;
280
281    // alc.state.set_dispatched(namespace, key, value) -> nil  (explicit-namespace set)
282    let store_set_dispatched = Arc::clone(&state_store);
283    let set_dispatched = lua.create_function(
284        move |lua, (namespace, key, value): (String, String, LuaValue)| {
285            let json: serde_json::Value = lua.from_value(value)?;
286            store_set_dispatched
287                .set_dispatched(&namespace, &key, &json)
288                .map_err(LuaError::external)
289        },
290    )?;
291
292    // alc.state.delete_dispatched(namespace, key) -> bool  (explicit-namespace delete, existed flag)
293    let store_delete_dispatched = Arc::clone(&state_store);
294    let delete_dispatched = lua.create_function(move |_, (namespace, key): (String, String)| {
295        store_delete_dispatched
296            .delete_dispatched(&namespace, &key)
297            .map_err(LuaError::external)
298    })?;
299
300    state_table.set("get", get)?;
301    state_table.set("set", set)?;
302    state_table.set("keys", keys)?;
303    state_table.set("delete", delete)?;
304    state_table.set("has", has)?;
305    state_table.set("set_nx", set_nx)?;
306    state_table.set("incr", incr)?;
307    state_table.set("list", list)?;
308    state_table.set("show", show)?;
309    state_table.set("reset", reset)?;
310    state_table.set("set_dispatched", set_dispatched)?;
311    state_table.set("delete_dispatched", delete_dispatched)?;
312
313    alc_table.set("state", state_table)?;
314    Ok(())
315}
316
317/// Register `alc._dirs` — absolute paths that Lua prelude helpers
318/// (`alc.eval` scenario resolution, etc.) need from the service layer.
319///
320/// Values are plain strings so Lua can concat/`io.open` them without
321/// additional userdata binding.
322pub(super) fn register_dirs(
323    lua: &Lua,
324    alc_table: &LuaTable,
325    state_dir: &Path,
326    cards_dir: &Path,
327    scenarios_dir: &Path,
328) -> LuaResult<()> {
329    let dirs = lua.create_table()?;
330    dirs.set("state", state_dir.to_string_lossy().into_owned())?;
331    dirs.set("cards", cards_dir.to_string_lossy().into_owned())?;
332    dirs.set("scenarios", scenarios_dir.to_string_lossy().into_owned())?;
333    alc_table.set("_dirs", dirs)?;
334    Ok(())
335}
336
337/// Register `alc.card` table with v0 P0+P1 API.
338///
339/// P0 (minimum viable): create / get / list
340/// P1 (observation-driven additions): append / alias_set / alias_list / find
341///
342/// Lua usage:
343///   local c = alc.card.create({ pkg = { name = "cot" }, model = {...}, stats = {...} })
344///   local card = alc.card.get("cot_opus46_20260412_a3f9c1")
345///   alc.card.list({ pkg = "cot" })
346///   alc.card.append("cot_...", { caveats = { notes = "rescored" } })
347///   alc.card.alias_set("best_on_gsm8k", "cot_...", { pkg = "cot", note = "..." })
348///   alc.card.alias_list({ pkg = "cot" })
349///   alc.card.find({
350///       pkg = "cot",
351///       where = {
352///           scenario = { name = "gsm8k" },
353///           stats = { pass_rate = { gte = 0.8 } },
354///       },
355///       order_by = "-stats.pass_rate",
356///       limit = 5,
357///   })
358///   alc.card.get_by_alias("best_on_gsm8k")  -- resolve alias → full Card
359///   alc.card.write_samples("cot_...", { {case="c0", passed=true}, ... })  -- write-once
360///   alc.card.read_samples("cot_...", { offset = 0, limit = 100 })
361pub(super) fn register_card(
362    lua: &Lua,
363    alc_table: &LuaTable,
364    card_store: Arc<FileCardStore>,
365    card_run_enabled: bool,
366) -> LuaResult<()> {
367    let card_table = lua.create_table()?;
368
369    // alc.card.create(table) -> { card_id, path } | nil
370    //
371    // Validates any optional top-level `run` field before touching the
372    // store — invalid statuses surface as Lua errors regardless of the
373    // Enable gate.  When `run` is present and `card_run_enabled` is
374    // false, the closure short-circuits with `nil` so no card file is
375    // written and no `CardEvent::Created` is published (Phase 1-B gate).
376    let store_create = Arc::clone(&card_store);
377    let create = lua.create_function(move |lua, input: LuaValue| -> LuaResult<LuaValue> {
378        let json: serde_json::Value = lua.from_value(input)?;
379        let run_section = card::RunSection::from_json(&json).map_err(LuaError::external)?;
380        if run_section.is_some() && !card_run_enabled {
381            return Ok(LuaValue::Nil);
382        }
383        let (card_id, path) = store_create.create(json).map_err(LuaError::external)?;
384        let ret = lua.create_table()?;
385        ret.set("card_id", card_id)?;
386        ret.set("path", path.to_string_lossy().to_string())?;
387        Ok(LuaValue::Table(ret))
388    })?;
389
390    // alc.card.get(card_id) -> table | nil
391    let store_get = Arc::clone(&card_store);
392    let get = lua.create_function(move |lua, card_id: String| match store_get.get(&card_id) {
393        Ok(Some(v)) => to_lua_value(lua, &v),
394        Ok(None) => Ok(LuaValue::Nil),
395        Err(e) => Err(LuaError::external(e)),
396    })?;
397
398    // alc.card.list(filter?) -> [summary]
399    let store_list = Arc::clone(&card_store);
400    let list = lua.create_function(move |lua, filter: Option<LuaTable>| {
401        let pkg = match filter {
402            Some(t) => t.get::<Option<String>>("pkg")?,
403            None => None,
404        };
405        let rows = store_list
406            .list(pkg.as_deref())
407            .map_err(LuaError::external)?;
408        to_lua_value(lua, &card::summaries_to_json(&rows))
409    })?;
410
411    // alc.card.append(card_id, fields) -> merged_card | nil
412    //
413    // Symmetric to `create`: any `run` field inside `fields` is validated
414    // first, then gated by `card_run_enabled`.  A disabled gate causes
415    // the append call to no-op with `nil` — no store rewrite, no
416    // `CardEvent::Appended` publication.
417    let store_append = Arc::clone(&card_store);
418    let append = lua.create_function(
419        move |lua, (card_id, fields): (String, LuaValue)| -> LuaResult<LuaValue> {
420            let json: serde_json::Value = lua.from_value(fields)?;
421            let run_section = card::RunSection::from_json(&json).map_err(LuaError::external)?;
422            if run_section.is_some() && !card_run_enabled {
423                return Ok(LuaValue::Nil);
424            }
425            let merged = store_append
426                .append(&card_id, json)
427                .map_err(LuaError::external)?;
428            to_lua_value(lua, &merged)
429        },
430    )?;
431
432    // alc.card.get_by_alias(name) -> table | nil
433    let store_gba = Arc::clone(&card_store);
434    let get_by_alias = lua.create_function(move |lua, name: String| {
435        match store_gba.get_by_alias(&name).map_err(LuaError::external)? {
436            Some(v) => to_lua_value(lua, &v),
437            None => Ok(LuaValue::Nil),
438        }
439    })?;
440
441    // alc.card.alias_set(name, card_id, opts?) -> alias
442    let store_aset = Arc::clone(&card_store);
443    let alias_set = lua.create_function(
444        move |lua, (name, card_id, opts): (String, String, Option<LuaTable>)| {
445            let (pkg, note) = match opts {
446                Some(t) => (
447                    t.get::<Option<String>>("pkg")?,
448                    t.get::<Option<String>>("note")?,
449                ),
450                None => (None, None),
451            };
452            let a = store_aset
453                .alias_set(&name, &card_id, pkg.as_deref(), note.as_deref())
454                .map_err(LuaError::external)?;
455            let arr = card::aliases_to_json(&[a]);
456            let first = match arr {
457                serde_json::Value::Array(mut v) if !v.is_empty() => v.remove(0),
458                other => other,
459            };
460            to_lua_value(lua, &first)
461        },
462    )?;
463
464    // alc.card.alias_list(filter?) -> [alias]
465    let store_alist = Arc::clone(&card_store);
466    let alias_list = lua.create_function(move |lua, filter: Option<LuaTable>| {
467        let pkg = match filter {
468            Some(t) => t.get::<Option<String>>("pkg")?,
469            None => None,
470        };
471        let rows = store_alist
472            .alias_list(pkg.as_deref())
473            .map_err(LuaError::external)?;
474        to_lua_value(lua, &card::aliases_to_json(&rows))
475    })?;
476
477    // alc.card.find(query?) -> [summary]
478    //
479    // Accepts a Prisma-style `where` DSL + dotted-path `order_by`.
480    // See `card::parse_where` / `card::parse_order_by` for semantics.
481    let store_find = Arc::clone(&card_store);
482    let find = lua.create_function(move |lua, query: Option<LuaTable>| {
483        let q = match query {
484            Some(t) => {
485                let pkg = t.get::<Option<String>>("pkg")?;
486                let limit = t.get::<Option<usize>>("limit")?;
487                let offset = t.get::<Option<usize>>("offset")?;
488
489                let where_parsed = match t.get::<LuaValue>("where")? {
490                    LuaValue::Nil => None,
491                    v => {
492                        let json: serde_json::Value = lua.from_value(v)?;
493                        Some(card::parse_where(&json).map_err(LuaError::external)?)
494                    }
495                };
496                let order_parsed = match t.get::<LuaValue>("order_by")? {
497                    LuaValue::Nil => Vec::new(),
498                    v => {
499                        let json: serde_json::Value = lua.from_value(v)?;
500                        card::parse_order_by(&json).map_err(LuaError::external)?
501                    }
502                };
503
504                card::FindQuery {
505                    pkg,
506                    where_: where_parsed,
507                    order_by: order_parsed,
508                    limit,
509                    offset,
510                }
511            }
512            None => card::FindQuery::default(),
513        };
514        let rows = store_find.find(q).map_err(LuaError::external)?;
515        to_lua_value(lua, &card::summaries_to_json(&rows))
516    })?;
517
518    // alc.card.write_samples(card_id, samples) -> { path, count }
519    let store_ws = Arc::clone(&card_store);
520    let write_samples =
521        lua.create_function(move |lua, (card_id, samples): (String, LuaValue)| {
522            let json: serde_json::Value = lua.from_value(samples)?;
523            let arr = match json {
524                serde_json::Value::Array(a) => a,
525                _ => {
526                    return Err(LuaError::external(
527                        "alc.card.write_samples: samples must be an array",
528                    ))
529                }
530            };
531            let count = arr.len();
532            let path = store_ws
533                .write_samples(&card_id, arr)
534                .map_err(LuaError::external)?;
535            let ret = lua.create_table()?;
536            ret.set("path", path.to_string_lossy().to_string())?;
537            ret.set("count", count)?;
538            Ok(ret)
539        })?;
540
541    // alc.card.read_samples(card_id, opts?) -> [sample]
542    //
543    // opts.where applies the Prisma-style DSL to each row; offset/limit
544    // page the post-filter stream. See `card::parse_where`.
545    let store_rs = Arc::clone(&card_store);
546    let read_samples =
547        lua.create_function(move |lua, (card_id, opts): (String, Option<LuaTable>)| {
548            let (offset, limit, where_parsed) = match opts {
549                Some(t) => {
550                    let offset = t.get::<Option<usize>>("offset")?.unwrap_or(0);
551                    let limit = t.get::<Option<usize>>("limit")?;
552                    let where_parsed = match t.get::<LuaValue>("where")? {
553                        LuaValue::Nil => None,
554                        v => {
555                            let json: serde_json::Value = lua.from_value(v)?;
556                            Some(card::parse_where(&json).map_err(LuaError::external)?)
557                        }
558                    };
559                    (offset, limit, where_parsed)
560                }
561                None => (0, None, None),
562            };
563            let q = card::SamplesQuery {
564                offset,
565                limit,
566                where_: where_parsed,
567            };
568            let rows = store_rs
569                .read_samples(&card_id, q)
570                .map_err(LuaError::external)?;
571            to_lua_value(lua, &serde_json::Value::Array(rows))
572        })?;
573
574    // alc.card.sink_backfill({ sink, dry_run }) -> report
575    //
576    // Backfill one subscriber with all cards from the primary store.
577    // Drift-safe: existing cards on the subscriber are skipped.
578    let store_sb = Arc::clone(&card_store);
579    let sink_backfill = lua.create_function(move |lua, params: LuaTable| {
580        let sink: String = params.get("sink")?;
581        let dry_run: Option<bool> = params.get("dry_run")?;
582        let report = store_sb
583            .card_sink_backfill(&sink, dry_run.unwrap_or(false))
584            .map_err(LuaError::external)?;
585        to_lua_value(lua, &report)
586    })?;
587
588    // alc.card.lineage(query) -> { root, nodes, edges, truncated }
589    //
590    // Walks `metadata.prior_card_id` ancestors (default), descendants, or
591    // both. Relation filter and depth cap are both optional.
592    let store_lin = Arc::clone(&card_store);
593    let lineage = lua.create_function(move |lua, query: LuaTable| {
594        let card_id: String = query.get("card_id")?;
595        let direction_str: Option<String> = query.get("direction")?;
596        let direction = match direction_str.as_deref() {
597            Some(s) => card::LineageDirection::parse(s).map_err(LuaError::external)?,
598            None => card::LineageDirection::Up,
599        };
600        let depth: Option<usize> = query.get("depth")?;
601        let include_stats: Option<bool> = query.get("include_stats")?;
602        let relation_filter: Option<Vec<String>> = match query.get::<LuaValue>("relation_filter")? {
603            LuaValue::Nil => None,
604            v => Some(lua.from_value(v)?),
605        };
606
607        let q = card::LineageQuery {
608            card_id,
609            direction,
610            depth,
611            include_stats: include_stats.unwrap_or(true),
612            relation_filter,
613        };
614        match store_lin.lineage(q).map_err(LuaError::external)? {
615            Some(res) => to_lua_value(lua, &card::lineage_to_json(&res)),
616            None => Ok(LuaValue::Nil),
617        }
618    })?;
619
620    card_table.set("create", create)?;
621    card_table.set("get", get)?;
622    card_table.set("list", list)?;
623    card_table.set("append", append)?;
624    card_table.set("get_by_alias", get_by_alias)?;
625    card_table.set("alias_set", alias_set)?;
626    card_table.set("alias_list", alias_list)?;
627    card_table.set("find", find)?;
628    card_table.set("write_samples", write_samples)?;
629    card_table.set("read_samples", read_samples)?;
630    card_table.set("lineage", lineage)?;
631    card_table.set("sink_backfill", sink_backfill)?;
632
633    alc_table.set("card", card_table)?;
634    Ok(())
635}
636
637/// Register `alc.stats` table with record/get + auto-counted llm_calls.
638///
639/// Lua usage:
640///   alc.stats.record("accuracy", 0.95)
641///   local v = alc.stats.get("accuracy")  -- 0.95
642///   local n = alc.stats.llm_calls()      -- session-level cumulative count
643///
644/// `llm_calls()` reads the engine-maintained `SessionStatus.llm_calls`
645/// counter (incremented on every paused-cycle complete in
646/// `MetricsObserver`). Recipes / ingredients can compute scoped deltas
647/// via `local before = alc.stats.llm_calls(); ... ; local n = alc.stats.llm_calls() - before`
648/// without manually tracking calls per branch.
649pub(super) fn register_stats(
650    lua: &Lua,
651    alc_table: &LuaTable,
652    custom_metrics: CustomMetricsHandle,
653    stats: StatsHandle,
654) -> LuaResult<()> {
655    let stats_table = lua.create_table()?;
656
657    // alc.stats.record(key, value)
658    let cm_record = custom_metrics.clone();
659    let record = lua.create_function(move |lua, (key, value): (String, LuaValue)| {
660        let json: serde_json::Value = lua.from_value(value)?;
661        cm_record.record(key, json);
662        Ok(())
663    })?;
664
665    // alc.stats.get(key)
666    let cm_get = custom_metrics;
667    let get = lua.create_function(move |lua, key: String| match cm_get.get(&key) {
668        Some(v) => to_lua_value(lua, &v),
669        None => Ok(LuaValue::Nil),
670    })?;
671
672    // alc.stats.llm_calls() — auto-counted session-level LLM call total
673    let stats_handle = stats;
674    let llm_calls = lua.create_function(move |_, ()| Ok(stats_handle.llm_calls()))?;
675
676    stats_table.set("record", record)?;
677    stats_table.set("get", get)?;
678    stats_table.set("llm_calls", llm_calls)?;
679
680    alc_table.set("stats", stats_table)?;
681    Ok(())
682}
683
684// ─── alc.env ─────────────────────────────────────────────────────────────────
685
686/// Read-only Lua UserData view of the frozen env snapshot.
687///
688/// The underlying `HashMap` is owned by the host (Rust) and wrapped in an
689/// `Arc` so it can be shared across the parent session and fork children
690/// without copying.  Guest Lua code may only read keys via `alc.env.KEY`
691/// (`__index`) or `alc.env:get(key, default)`.  Any write attempt
692/// (`alc.env.KEY = value`) returns a hard runtime error — this is the SPACE
693/// boundary that keeps host env state immutable from the Lua side.
694pub struct AlcEnv(pub Arc<HashMap<String, String>>);
695
696impl mlua::UserData for AlcEnv {
697    fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M) {
698        // __index: read a key from the frozen snapshot.
699        methods.add_meta_method(mlua::MetaMethod::Index, |_, this, key: String| {
700            Ok(this.0.get(&key).cloned())
701        });
702
703        // __newindex: hard runtime error on any write attempt.
704        // CRUX must_not_simplify: never silently ignore writes.
705        methods.add_meta_method(
706            mlua::MetaMethod::NewIndex,
707            |_, _, (_k, _v): (mlua::Value, mlua::Value)| {
708                Err::<(), _>(mlua::Error::external("alc.env is readonly"))
709            },
710        );
711
712        // get(key [, default]) — explicit lookup with optional fallback.
713        methods.add_method(
714            "get",
715            |_, this, (key, default): (String, Option<String>)| {
716                Ok(this.0.get(&key).cloned().or(default))
717            },
718        );
719
720        // use({key1, key2, ...}) — declare-at-use: returns a plain Lua table
721        // containing only the declared keys that exist in the snapshot.
722        // Undeclared keys are absent (nil when accessed).
723        methods.add_method("use", |lua, this, declared: Vec<String>| {
724            let proxy = lua.create_table()?;
725            for k in &declared {
726                if let Some(v) = this.0.get(k) {
727                    proxy.set(k.clone(), v.clone())?;
728                }
729            }
730            Ok(proxy)
731        });
732    }
733}
734
735/// Register `alc.env` on the given `alc` table and store the snapshot as
736/// side-band app-data on `lua` so fork children can inherit it via
737/// `lua.app_data_ref::<Arc<HashMap<String,String>>>()`.
738///
739/// This function is intentionally `pub` (not `pub(super)`) because it is
740/// re-exported from `bridge::mod.rs` and called from `executor.rs` and
741/// `fork.rs` outside this module.
742pub fn register_env(
743    lua: &mlua::Lua,
744    alc_table: &mlua::Table,
745    env_map: Arc<HashMap<String, String>>,
746) -> mlua::Result<()> {
747    alc_table.set("env", AlcEnv(Arc::clone(&env_map)))?;
748    lua.set_app_data(env_map);
749    Ok(())
750}
751
752#[cfg(test)]
753mod tests {
754    use super::*;
755    use algocline_core::ExecutionMetrics;
756
757    /// Build a fresh [`BridgeConfig`] plus its owning state/card
758    /// tempdir stores. Returned together so callers can re-use the
759    /// store handles (e.g. for assertions / cleanup) after register.
760    fn test_config_with(ns: &str) -> crate::bridge::BridgeConfig {
761        let metrics = ExecutionMetrics::new();
762        let tmp = tempfile::tempdir().expect("test tempdir");
763        let root = tmp.path().to_path_buf();
764        std::mem::forget(tmp);
765        crate::bridge::BridgeConfig {
766            llm_tx: None,
767            ns: ns.into(),
768            custom_metrics: metrics.custom_metrics_handle(),
769            stats: metrics.stats_handle(),
770            budget: metrics.budget_handle(),
771            progress: metrics.progress_handle(),
772            lib_paths: vec![],
773            variant_pkgs: vec![],
774            state_store: Arc::new(JsonFileStore::new(root.join("state"))),
775            card_store: Arc::new(FileCardStore::new(root.join("cards"))),
776            card_run_enabled: false,
777            scenarios_dir: root.join("scenarios"),
778            nn_dir: root.join("nn"),
779            log_sink: None,
780        }
781    }
782
783    fn test_config() -> crate::bridge::BridgeConfig {
784        test_config_with("default")
785    }
786
787    fn test_config_with_ns(ns: &str) -> crate::bridge::BridgeConfig {
788        test_config_with(ns)
789    }
790
791    #[test]
792    fn json_roundtrip() {
793        let lua = Lua::new();
794        let t = lua.create_table().unwrap();
795        crate::bridge::register(&lua, &t, test_config()).unwrap();
796        lua.globals().set("alc", t).unwrap();
797
798        let result: String = lua
799            .load(r#"return alc.json_encode({hello = "world", n = 42})"#)
800            .eval()
801            .unwrap();
802        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
803        assert_eq!(parsed["hello"], "world");
804        assert_eq!(parsed["n"], 42);
805    }
806
807    #[test]
808    fn json_decode_encode() {
809        let lua = Lua::new();
810        let t = lua.create_table().unwrap();
811        crate::bridge::register(&lua, &t, test_config()).unwrap();
812        lua.globals().set("alc", t).unwrap();
813
814        let result: String = lua
815            .load(
816                r#"
817                local val = alc.json_decode('{"a":1,"b":"two"}')
818                val.c = true
819                return alc.json_encode(val)
820            "#,
821            )
822            .eval()
823            .unwrap();
824        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
825        assert_eq!(parsed["a"], 1);
826        assert_eq!(parsed["b"], "two");
827        assert_eq!(parsed["c"], true);
828    }
829
830    /// Regression: JSON `null` must decode to Lua `nil` (not an mlua
831    /// lightuserdata sentinel that bypasses `if value then ...` truthy checks).
832    /// Covers top-level null, nullable object fields, and array elements.
833    /// See issue db041966.
834    #[test]
835    fn json_decode_null_yields_lua_nil() {
836        let lua = Lua::new();
837        let t = lua.create_table().unwrap();
838        crate::bridge::register(&lua, &t, test_config()).unwrap();
839        lua.globals().set("alc", t).unwrap();
840
841        // Top-level null: must be Lua nil so `if v then ...` skips the branch.
842        let top_level_truthy: bool = lua
843            .load(r#"local v = alc.json_decode("null"); return v ~= nil"#)
844            .eval()
845            .unwrap();
846        assert!(
847            !top_level_truthy,
848            "alc.json_decode(\"null\") should return Lua nil"
849        );
850
851        // Object field null: `obj.x` must be nil; `if obj.x then ...` skips.
852        let field_truthy: bool = lua
853            .load(
854                r#"
855                local obj = alc.json_decode('{"x": null, "y": 1}')
856                return obj.x ~= nil
857            "#,
858            )
859            .eval()
860            .unwrap();
861        assert!(
862            !field_truthy,
863            "Object field decoded from JSON null should be Lua nil"
864        );
865
866        // Object field null: type must be "nil", not "userdata" (sentinel).
867        let field_type: String = lua
868            .load(r#"return type(alc.json_decode('{"x": null}').x)"#)
869            .eval()
870            .unwrap();
871        assert_eq!(
872            field_type, "nil",
873            "type() of null-decoded field must be 'nil', not 'userdata'"
874        );
875
876        // Array element null: observe how the table is shaped. Document the
877        // resulting `#arr` length so consumers can rely on a stable contract.
878        let arr_len: i64 = lua
879            .load(r#"return #alc.json_decode('[1, null, 3]')"#)
880            .eval()
881            .unwrap();
882        // mlua/Lua 5.4 length: nil holes inside the array part do not
883        // truncate `#arr`; it returns the original JSON array length (3 for
884        // `[1, null, 3]`). Indexed access still yields nil at the hole.
885        // Consumers iterating with `for i = 1, #arr do ... if arr[i] then` are
886        // safe; `ipairs()` will stop at the first nil. Document the contract
887        // here so downstream packages can rely on it.
888        assert_eq!(
889            arr_len, 3,
890            "JSON array length is preserved across null elements (mlua/Lua 5.4 array part)"
891        );
892
893        // Array element null: explicit indexed access surfaces nil for the
894        // hole and the original values at the surrounding indices.
895        let (a, b, c): (Option<i64>, Option<i64>, Option<i64>) = lua
896            .load(
897                r#"
898                local arr = alc.json_decode('[1, null, 3]')
899                return arr[1], arr[2], arr[3]
900            "#,
901            )
902            .eval()
903            .unwrap();
904        assert_eq!(a, Some(1));
905        assert_eq!(b, None, "Array element decoded from JSON null must be nil");
906        assert_eq!(c, Some(3));
907    }
908
909    #[test]
910    fn state_get_set() {
911        // Each BridgeConfig comes with its own tempdir-rooted
912        // JsonFileStore so no cross-test cleanup is needed.
913        let ns = "_test_bridge_state";
914
915        let lua = Lua::new();
916        let t = lua.create_table().unwrap();
917        crate::bridge::register(&lua, &t, test_config_with_ns(ns)).unwrap();
918        lua.globals().set("alc", t).unwrap();
919
920        // Set and get
921        lua.load(r#"alc.state.set("x", 99)"#).exec().unwrap();
922        let result: i64 = lua.load(r#"return alc.state.get("x")"#).eval().unwrap();
923        assert_eq!(result, 99);
924
925        // Default value
926        let result: i64 = lua
927            .load(r#"return alc.state.get("missing", 0)"#)
928            .eval()
929            .unwrap();
930        assert_eq!(result, 0);
931
932        // Nil for missing without default
933        let result: LuaValue = lua
934            .load(r#"return alc.state.get("missing")"#)
935            .eval()
936            .unwrap();
937        assert!(result.is_nil());
938    }
939
940    /// Sibling regression for the `to_lua_value` helper sweep (issue ff6372af):
941    /// when state stores a value containing a JSON null field, retrieval must
942    /// surface that field as Lua nil (not the mlua lightuserdata sentinel) so
943    /// that `if v.x then ...` truthy checks behave correctly. Verifies the
944    /// helper's null-handling contract via the `alc.state.get` call path,
945    /// representative of the other accessors that funnel through the same
946    /// helper (`alc.card.*`, `alc.stats.*`, etc.).
947    #[test]
948    fn state_get_object_with_null_field_yields_lua_nil() {
949        let ns = "_test_bridge_state_null_field";
950
951        let lua = Lua::new();
952        let t = lua.create_table().unwrap();
953        crate::bridge::register(&lua, &t, test_config_with_ns(ns)).unwrap();
954        lua.globals().set("alc", t).unwrap();
955
956        // Set state to an object containing a null field via JSON decode round-trip.
957        lua.load(r#"alc.state.set("obj", alc.json_decode('{"x": null, "y": 1}'))"#)
958            .exec()
959            .unwrap();
960
961        // Retrieved object's `x` field must be Lua nil (not lightuserdata sentinel).
962        let x_truthy: bool = lua
963            .load(r#"local v = alc.state.get("obj"); return v.x ~= nil"#)
964            .eval()
965            .unwrap();
966        assert!(
967            !x_truthy,
968            "state.get returned object's null field must be Lua nil (truthy check must skip)"
969        );
970
971        // type() of the null field must be "nil", not "userdata" (sentinel).
972        let x_type: String = lua
973            .load(r#"local v = alc.state.get("obj"); return type(v.x)"#)
974            .eval()
975            .unwrap();
976        assert_eq!(
977            x_type, "nil",
978            "type() of state.get'd null field must be 'nil', not 'userdata'"
979        );
980
981        // Sibling field `y` should still surface as the original value.
982        let y: i64 = lua.load(r#"return alc.state.get("obj").y"#).eval().unwrap();
983        assert_eq!(y, 1, "Non-null sibling field must round-trip unchanged");
984    }
985
986    #[test]
987    fn state_has_set_nx_incr() {
988        let ns = "_test_bridge_state_t1";
989
990        let lua = Lua::new();
991        let t = lua.create_table().unwrap();
992        crate::bridge::register(&lua, &t, test_config_with_ns(ns)).unwrap();
993        lua.globals().set("alc", t).unwrap();
994
995        // has: false for missing key
996        let h: bool = lua.load(r#"return alc.state.has("k")"#).eval().unwrap();
997        assert!(!h);
998
999        // set_nx: true when absent
1000        let ok: bool = lua
1001            .load(r#"return alc.state.set_nx("k", "first")"#)
1002            .eval()
1003            .unwrap();
1004        assert!(ok);
1005
1006        // has: true after set
1007        let h: bool = lua.load(r#"return alc.state.has("k")"#).eval().unwrap();
1008        assert!(h);
1009
1010        // set_nx: false when present
1011        let ok: bool = lua
1012            .load(r#"return alc.state.set_nx("k", "second")"#)
1013            .eval()
1014            .unwrap();
1015        assert!(!ok);
1016
1017        // incr: init + delta
1018        let v: f64 = lua
1019            .load(r#"return alc.state.incr("counter")"#)
1020            .eval()
1021            .unwrap();
1022        assert!((v - 1.0).abs() < f64::EPSILON);
1023
1024        // incr: with explicit delta
1025        let v: f64 = lua
1026            .load(r#"return alc.state.incr("counter", 5)"#)
1027            .eval()
1028            .unwrap();
1029        assert!((v - 6.0).abs() < f64::EPSILON);
1030
1031        // incr: with custom default (ignored since key exists)
1032        let v: f64 = lua
1033            .load(r#"return alc.state.incr("counter", 10, 100)"#)
1034            .eval()
1035            .unwrap();
1036        assert!((v - 16.0).abs() < f64::EPSILON);
1037    }
1038
1039    #[test]
1040    fn card_create_get_list_from_lua() {
1041        // Use a unique pkg name per-run to avoid clobbering real cards.
1042        let ns = std::time::SystemTime::now()
1043            .duration_since(std::time::UNIX_EPOCH)
1044            .unwrap()
1045            .as_nanos();
1046        let pkg = format!("_test_bridge_card_{ns}");
1047
1048        let lua = Lua::new();
1049        let t = lua.create_table().unwrap();
1050        crate::bridge::register(&lua, &t, test_config()).unwrap();
1051        lua.globals().set("alc", t).unwrap();
1052
1053        // create
1054        let create_script = format!(
1055            r#"
1056            local r = alc.card.create({{
1057                pkg = {{ name = "{pkg}" }},
1058                model = {{ id = "claude-opus-4-6" }},
1059                stats = {{ pass_rate = 0.9 }},
1060            }})
1061            return r.card_id
1062        "#
1063        );
1064        let card_id: String = lua.load(&create_script).eval().unwrap();
1065        assert!(card_id.starts_with(&pkg));
1066
1067        // get
1068        let get_script = format!(r#"return alc.card.get("{card_id}").stats.pass_rate"#);
1069        let rate: f64 = lua.load(&get_script).eval().unwrap();
1070        assert!((rate - 0.9).abs() < 1e-9);
1071
1072        // list (filtered by pkg)
1073        let list_script = format!(
1074            r#"
1075            local rows = alc.card.list({{ pkg = "{pkg}" }})
1076            return #rows
1077        "#
1078        );
1079        let count: i64 = lua.load(&list_script).eval().unwrap();
1080        assert_eq!(count, 1);
1081
1082        // No cleanup needed: the card_store is tempdir-rooted via test_config().
1083    }
1084
1085    #[test]
1086    fn stats_record_get() {
1087        let metrics = ExecutionMetrics::new();
1088        let custom_handle = metrics.custom_metrics_handle();
1089        let lua = Lua::new();
1090        let t = lua.create_table().unwrap();
1091        let tmp = tempfile::tempdir().expect("test tempdir");
1092        let root = tmp.path().to_path_buf();
1093        std::mem::forget(tmp);
1094        crate::bridge::register(
1095            &lua,
1096            &t,
1097            crate::bridge::BridgeConfig {
1098                llm_tx: None,
1099                ns: "default".into(),
1100                custom_metrics: custom_handle.clone(),
1101                stats: metrics.stats_handle(),
1102                budget: metrics.budget_handle(),
1103                progress: metrics.progress_handle(),
1104                lib_paths: vec![],
1105                variant_pkgs: vec![],
1106                state_store: Arc::new(JsonFileStore::new(root.join("state"))),
1107                card_store: Arc::new(FileCardStore::new(root.join("cards"))),
1108                card_run_enabled: false,
1109                scenarios_dir: root.join("scenarios"),
1110                nn_dir: root.join("nn"),
1111                log_sink: None,
1112            },
1113        )
1114        .unwrap();
1115        lua.globals().set("alc", t).unwrap();
1116
1117        // Record from Lua
1118        lua.load(r#"alc.stats.record("score", 42)"#).exec().unwrap();
1119        let result: i64 = lua.load(r#"return alc.stats.get("score")"#).eval().unwrap();
1120        assert_eq!(result, 42);
1121
1122        // Verify via Handle
1123        assert_eq!(custom_handle.get("score"), Some(serde_json::json!(42)));
1124
1125        // Missing key returns nil
1126        let result: LuaValue = lua
1127            .load(r#"return alc.stats.get("missing")"#)
1128            .eval()
1129            .unwrap();
1130        assert!(result.is_nil());
1131    }
1132
1133    /// `alc.stats.llm_calls()` reads the engine-maintained
1134    /// `SessionStatus.llm_calls` counter and returns 0 for a fresh session.
1135    /// After driving the counter via `MetricsObserver::on_paused`, the
1136    /// Lua-side function reflects the new value.
1137    #[test]
1138    fn stats_llm_calls_reads_session_status() {
1139        use crate::card::FileCardStore;
1140        use crate::state::JsonFileStore;
1141        use algocline_core::{ExecutionObserver, LlmQuery, QueryId};
1142        use std::sync::Arc;
1143
1144        let metrics = ExecutionMetrics::new();
1145        let observer = metrics.create_observer();
1146
1147        let lua = Lua::new();
1148        let t = lua.create_table().unwrap();
1149        let tmp = tempfile::tempdir().expect("test tempdir");
1150        let root = tmp.path().to_path_buf();
1151        std::mem::forget(tmp);
1152        crate::bridge::register(
1153            &lua,
1154            &t,
1155            crate::bridge::BridgeConfig {
1156                llm_tx: None,
1157                ns: "default".into(),
1158                custom_metrics: metrics.custom_metrics_handle(),
1159                stats: metrics.stats_handle(),
1160                budget: metrics.budget_handle(),
1161                progress: metrics.progress_handle(),
1162                lib_paths: vec![],
1163                variant_pkgs: vec![],
1164                state_store: Arc::new(JsonFileStore::new(root.join("state"))),
1165                card_store: Arc::new(FileCardStore::new(root.join("cards"))),
1166                card_run_enabled: false,
1167                scenarios_dir: root.join("scenarios"),
1168                nn_dir: root.join("nn"),
1169                log_sink: None,
1170            },
1171        )
1172        .unwrap();
1173        lua.globals().set("alc", t).unwrap();
1174
1175        // Initial value: 0
1176        let initial: u64 = lua.load(r#"return alc.stats.llm_calls()"#).eval().unwrap();
1177        assert_eq!(initial, 0, "fresh session must report llm_calls() == 0");
1178
1179        // Drive the observer to simulate a paused-cycle (one LLM call).
1180        observer.on_paused(&[LlmQuery {
1181            id: QueryId::parse("q-0"),
1182            prompt: "hi".to_string(),
1183            system: None,
1184            max_tokens: 0,
1185            grounded: false,
1186            underspecified: false,
1187            cache_breakpoint: None,
1188            role: None,
1189        }]);
1190
1191        // Lua side now sees the increment.
1192        let after_one: u64 = lua.load(r#"return alc.stats.llm_calls()"#).eval().unwrap();
1193        assert_eq!(
1194            after_one, 1,
1195            "one paused query must increment llm_calls() to 1"
1196        );
1197
1198        // Two more queries in a single paused-cycle.
1199        observer.on_paused(&[
1200            LlmQuery {
1201                id: QueryId::parse("q-1"),
1202                prompt: "a".to_string(),
1203                system: None,
1204                max_tokens: 0,
1205                grounded: false,
1206                underspecified: false,
1207                cache_breakpoint: None,
1208                role: None,
1209            },
1210            LlmQuery {
1211                id: QueryId::parse("q-2"),
1212                prompt: "b".to_string(),
1213                system: None,
1214                max_tokens: 0,
1215                grounded: false,
1216                underspecified: false,
1217                cache_breakpoint: None,
1218                role: None,
1219            },
1220        ]);
1221
1222        let after_three: u64 = lua.load(r#"return alc.stats.llm_calls()"#).eval().unwrap();
1223        assert_eq!(
1224            after_three, 3,
1225            "two further paused queries (multi-query batch) must bring llm_calls() to 3"
1226        );
1227    }
1228
1229    // ─── register_log tests ───
1230
1231    // T1: alc.log routes entry to LogSink with correct fields
1232    #[test]
1233    fn register_log_pushes_to_log_sink() {
1234        use algocline_core::LogSink;
1235
1236        let sink = LogSink::new();
1237        let lua = Lua::new();
1238        let t = lua.create_table().unwrap();
1239        // Safety: unwrap is acceptable in test code.
1240        register_log(&lua, &t, sink.clone()).unwrap();
1241        lua.globals().set("alc", t).unwrap();
1242
1243        lua.load(r#"alc.log("info", "hello-from-log")"#)
1244            .exec()
1245            // Safety: unwrap in test code — propagates Lua errors as test failure.
1246            .unwrap();
1247
1248        let entries = sink.entries();
1249        assert_eq!(entries.len(), 1);
1250        assert_eq!(entries[0].source, "alc.log");
1251        assert_eq!(entries[0].level, "info");
1252        assert_eq!(entries[0].message, "hello-from-log");
1253    }
1254
1255    // T2: alc.log with unknown level falls back to "info" entry source
1256    #[test]
1257    fn register_log_unknown_level_still_pushes() {
1258        use algocline_core::LogSink;
1259
1260        let sink = LogSink::new();
1261        let lua = Lua::new();
1262        let t = lua.create_table().unwrap();
1263        // Safety: unwrap in test code.
1264        register_log(&lua, &t, sink.clone()).unwrap();
1265        lua.globals().set("alc", t).unwrap();
1266
1267        lua.load(r#"alc.log("custom", "edge-case")"#)
1268            .exec()
1269            // Safety: unwrap in test code.
1270            .unwrap();
1271
1272        let entries = sink.entries();
1273        assert_eq!(entries.len(), 1);
1274        assert_eq!(entries[0].source, "alc.log");
1275        // level is passed through verbatim regardless of tracing fallback path
1276        assert_eq!(entries[0].level, "custom");
1277        assert_eq!(entries[0].message, "edge-case");
1278    }
1279
1280    // T3: alc.log with empty message — edge case, should still push
1281    #[test]
1282    fn register_log_empty_message() {
1283        use algocline_core::LogSink;
1284
1285        let sink = LogSink::new();
1286        let lua = Lua::new();
1287        let t = lua.create_table().unwrap();
1288        // Safety: unwrap in test code.
1289        register_log(&lua, &t, sink.clone()).unwrap();
1290        lua.globals().set("alc", t).unwrap();
1291
1292        lua.load(r#"alc.log("warn", "")"#)
1293            .exec()
1294            // Safety: unwrap in test code.
1295            .unwrap();
1296
1297        let entries = sink.entries();
1298        assert_eq!(entries.len(), 1);
1299        assert_eq!(entries[0].message, "");
1300    }
1301
1302    // ─── register_print tests ───
1303
1304    // T1: print() override pushes to LogSink with source alc.lua.print
1305    #[test]
1306    fn register_print_pushes_to_log_sink() {
1307        use algocline_core::LogSink;
1308
1309        let sink = LogSink::new();
1310        let lua = Lua::new();
1311        // Safety: unwrap in test code.
1312        register_print(&lua, sink.clone()).unwrap();
1313
1314        lua.load(r#"print("hello-print")"#)
1315            .exec()
1316            // Safety: unwrap in test code.
1317            .unwrap();
1318
1319        let entries = sink.entries();
1320        assert_eq!(entries.len(), 1);
1321        assert_eq!(entries[0].source, "alc.lua.print");
1322        assert_eq!(entries[0].level, "info");
1323        assert_eq!(entries[0].message, "hello-print");
1324    }
1325
1326    // T2: print() with multiple arguments joins with tab
1327    #[test]
1328    fn register_print_multiple_args_tab_joined() {
1329        use algocline_core::LogSink;
1330
1331        let sink = LogSink::new();
1332        let lua = Lua::new();
1333        // Safety: unwrap in test code.
1334        register_print(&lua, sink.clone()).unwrap();
1335
1336        lua.load(r#"print("a", "b", "c")"#)
1337            .exec()
1338            // Safety: unwrap in test code.
1339            .unwrap();
1340
1341        let entries = sink.entries();
1342        assert_eq!(entries.len(), 1);
1343        assert_eq!(entries[0].message, "a\tb\tc");
1344    }
1345
1346    // T3: print() with nil/bool/number args — no panic, correct string coercion
1347    #[test]
1348    fn register_print_mixed_value_types() {
1349        use algocline_core::LogSink;
1350
1351        let sink = LogSink::new();
1352        let lua = Lua::new();
1353        // Safety: unwrap in test code.
1354        register_print(&lua, sink.clone()).unwrap();
1355
1356        lua.load(r#"print(nil, true, 42, 3.14)"#)
1357            .exec()
1358            // Safety: unwrap in test code.
1359            .unwrap();
1360
1361        let entries = sink.entries();
1362        assert_eq!(entries.len(), 1);
1363        // nil → "nil", bool → "true", int → "42", float → formatted per Lua convention
1364        let msg = &entries[0].message;
1365        assert!(msg.starts_with("nil\ttrue\t42\t"), "got: {msg}");
1366    }
1367
1368    // ─── alc.env unit tests ───────────────────────────────────────────────────
1369
1370    fn make_env_lua(pairs: &[(&str, &str)]) -> (Lua, Arc<HashMap<String, String>>) {
1371        let mut map = HashMap::new();
1372        for (k, v) in pairs {
1373            map.insert(k.to_string(), v.to_string());
1374        }
1375        let env_map = Arc::new(map);
1376        let lua = Lua::new();
1377        let alc_table = lua.create_table().unwrap();
1378        register_env(&lua, &alc_table, Arc::clone(&env_map)).unwrap();
1379        lua.globals().set("alc", alc_table).unwrap();
1380        (lua, env_map)
1381    }
1382
1383    #[test]
1384    fn env_index_reads_existing_key() {
1385        let (lua, _) = make_env_lua(&[("FOO", "bar")]);
1386        let val: Option<String> = lua.load(r#"return alc.env.FOO"#).eval().unwrap();
1387        assert_eq!(val, Some("bar".to_string()));
1388    }
1389
1390    #[test]
1391    fn env_index_missing_key_returns_nil() {
1392        let (lua, _) = make_env_lua(&[("FOO", "bar")]);
1393        let val: LuaValue = lua.load(r#"return alc.env.MISSING"#).eval().unwrap();
1394        assert!(val.is_nil());
1395    }
1396
1397    #[test]
1398    fn env_newindex_returns_error() {
1399        let (lua, _) = make_env_lua(&[("FOO", "bar")]);
1400        let result: Result<(), _> = lua.load(r#"alc.env.FOO = "x""#).exec();
1401        let err = result.unwrap_err().to_string();
1402        assert!(
1403            err.contains("alc.env is readonly"),
1404            "expected readonly error, got: {err}"
1405        );
1406    }
1407
1408    #[test]
1409    fn env_get_with_default_returns_default_on_miss() {
1410        let (lua, _) = make_env_lua(&[]);
1411        let val: Option<String> = lua
1412            .load(r#"return alc.env:get("MISSING", "fallback")"#)
1413            .eval()
1414            .unwrap();
1415        assert_eq!(val, Some("fallback".to_string()));
1416    }
1417
1418    #[test]
1419    fn env_get_returns_value_when_present() {
1420        let (lua, _) = make_env_lua(&[("KEY", "val")]);
1421        let val: Option<String> = lua
1422            .load(r#"return alc.env:get("KEY", "default")"#)
1423            .eval()
1424            .unwrap();
1425        assert_eq!(val, Some("val".to_string()));
1426    }
1427
1428    #[test]
1429    fn env_use_returns_declared_keys_only() {
1430        let (lua, _) = make_env_lua(&[("FOO", "foo_val"), ("BAR", "bar_val"), ("SECRET", "s")]);
1431        let result: LuaValue = lua
1432            .load(
1433                r#"
1434                local e = alc.env:use{"FOO", "BAR"}
1435                return e
1436            "#,
1437            )
1438            .eval()
1439            .unwrap();
1440        let tbl = result.as_table().unwrap();
1441        assert_eq!(tbl.get::<String>("FOO").unwrap(), "foo_val");
1442        assert_eq!(tbl.get::<String>("BAR").unwrap(), "bar_val");
1443        // SECRET was not declared — should be absent (nil)
1444        let secret: LuaValue = tbl.get("SECRET").unwrap();
1445        assert!(secret.is_nil(), "SECRET should be nil in proxy");
1446    }
1447
1448    #[test]
1449    fn env_use_undeclared_key_is_nil() {
1450        let (lua, _) = make_env_lua(&[("FOO", "foo_val")]);
1451        let val: LuaValue = lua
1452            .load(
1453                r#"
1454                local e = alc.env:use{"FOO"}
1455                return e.UNDECLARED
1456            "#,
1457            )
1458            .eval()
1459            .unwrap();
1460        assert!(val.is_nil());
1461    }
1462
1463    #[test]
1464    fn register_env_sets_app_data() {
1465        let mut map = HashMap::new();
1466        map.insert("X".to_string(), "1".to_string());
1467        let env_map = Arc::new(map);
1468        let lua = Lua::new();
1469        let alc_table = lua.create_table().unwrap();
1470        register_env(&lua, &alc_table, Arc::clone(&env_map)).unwrap();
1471        // Verify app_data is set and accessible
1472        let retrieved = lua.app_data_ref::<Arc<HashMap<String, String>>>().unwrap();
1473        assert_eq!(retrieved.get("X").unwrap(), "1");
1474    }
1475
1476    mod state_dispatched_lua {
1477        use super::*;
1478        use mlua::Lua;
1479        use std::sync::Arc;
1480        use tempfile::TempDir;
1481
1482        fn setup() -> (Lua, Arc<JsonFileStore>, TempDir) {
1483            let tmp = tempfile::tempdir().unwrap();
1484            let store = Arc::new(JsonFileStore::new(tmp.path().to_path_buf()));
1485            let lua = Lua::new();
1486            let alc = lua.create_table().unwrap();
1487            register_state(&lua, &alc, "default".to_string(), Arc::clone(&store)).unwrap();
1488            lua.globals().set("alc", alc).unwrap();
1489            (lua, store, tmp)
1490        }
1491
1492        #[test]
1493        fn list_returns_sorted_keys() {
1494            let (lua, _store, tmp) = setup();
1495            // Seed two files directly into the dispatched layout.
1496            std::fs::create_dir_all(tmp.path().join("testns")).unwrap();
1497            std::fs::write(
1498                tmp.path().join("testns/beta.json"),
1499                r#"{"data": {"completed_steps": [], "x": 1}}"#,
1500            )
1501            .unwrap();
1502            std::fs::write(
1503                tmp.path().join("testns/alpha.json"),
1504                r#"{"data": {"completed_steps": [], "y": 2}}"#,
1505            )
1506            .unwrap();
1507            lua.load(
1508                r#"
1509                    local result = alc.state.list("testns")
1510                    assert(#result == 2, "expected 2 keys, got " .. #result)
1511                    assert(result[1] == "alpha", "first key should be alpha, got " .. tostring(result[1]))
1512                    assert(result[2] == "beta", "second key should be beta, got " .. tostring(result[2]))
1513                "#,
1514            )
1515            .exec()
1516            .unwrap();
1517        }
1518
1519        #[test]
1520        fn show_returns_full_table() {
1521            let (lua, _store, tmp) = setup();
1522            std::fs::create_dir_all(tmp.path().join("testns")).unwrap();
1523            std::fs::write(
1524                tmp.path().join("testns/alpha.json"),
1525                r#"{"data": {"completed_steps": ["a", "b", "c"], "x": 1, "y": 2}}"#,
1526            )
1527            .unwrap();
1528            lua.load(
1529                r#"
1530                    local result = alc.state.show("testns", "alpha")
1531                    assert(type(result) == "table", "expected table")
1532                    assert(type(result.data) == "table", "expected result.data to be a table")
1533                    assert(result.data.x == 1, "expected x=1")
1534                    assert(result.data.y == 2, "expected y=2")
1535                    assert(#result.data.completed_steps == 3, "expected 3 steps")
1536                "#,
1537            )
1538            .exec()
1539            .unwrap();
1540        }
1541
1542        #[test]
1543        fn show_missing_returns_not_found_error() {
1544            let (lua, _store, _tmp) = setup();
1545            lua.load(
1546                r#"
1547                    local ok, err = pcall(alc.state.show, "testns", "missing")
1548                    assert(not ok, "expected error but got success")
1549                    local msg = tostring(err)
1550                    assert(string.find(msg, "not found"), "error message should contain 'not found', got: " .. msg)
1551                "#,
1552            )
1553            .exec()
1554            .unwrap();
1555        }
1556
1557        #[test]
1558        fn reset_removes_steps_and_fields_with_backup() {
1559            let (lua, _store, tmp) = setup();
1560            std::fs::create_dir_all(tmp.path().join("testns")).unwrap();
1561            let file_path = tmp.path().join("testns/alpha.json");
1562            std::fs::write(
1563                &file_path,
1564                r#"{"data": {"completed_steps": ["a", "b", "c"], "x": 1, "y": 2}}"#,
1565            )
1566            .unwrap();
1567            // Store tmp path as a string for Lua assertions.
1568            let tmp_path_str = tmp.path().to_string_lossy().to_string();
1569            lua.globals().set("TMP_PATH", tmp_path_str.clone()).unwrap();
1570            lua.load(
1571                r#"
1572                    local r = alc.state.reset("testns", "alpha", {steps={"b"}, fields={"x"}})
1573                    assert(r.ok == true, "expected ok=true")
1574                    assert(type(r.backup_path) == "string", "backup_path should be a string")
1575                    assert(r.steps_removed == 1, "expected steps_removed=1, got " .. tostring(r.steps_removed))
1576                    assert(r.fields_removed == 1, "expected fields_removed=1, got " .. tostring(r.fields_removed))
1577                "#,
1578            )
1579            .exec()
1580            .unwrap();
1581            // Assert .bak exists with original content.
1582            let bak_path = tmp.path().join("testns/alpha.json.bak");
1583            assert!(
1584                bak_path.exists(),
1585                "backup file should exist at {:?}",
1586                bak_path
1587            );
1588            let bak_content = std::fs::read_to_string(&bak_path).unwrap();
1589            assert!(
1590                bak_content.contains("\"b\""),
1591                "backup should contain original 'b' step"
1592            );
1593            // Assert live file was mutated: "b" removed from steps, "x" removed from data.
1594            let live_content = std::fs::read_to_string(&file_path).unwrap();
1595            let live: serde_json::Value = serde_json::from_str(&live_content).unwrap();
1596            let steps = live["data"]["completed_steps"].as_array().unwrap();
1597            assert!(
1598                !steps.iter().any(|s| s.as_str() == Some("b")),
1599                "step 'b' should be removed from completed_steps"
1600            );
1601            assert!(
1602                live["data"]["x"].is_null() || live["data"].get("x").is_none(),
1603                "field 'x' should be removed from data"
1604            );
1605        }
1606
1607        #[test]
1608        fn unsafe_namespace_rejected() {
1609            let (lua, _store, _tmp) = setup();
1610            lua.load(
1611                r#"
1612                    local ok, err = pcall(alc.state.list, "../evil")
1613                    assert(not ok, "expected error for unsafe namespace")
1614                    local msg = tostring(err)
1615                    assert(string.find(msg, "unsafe"), "error should contain 'unsafe', got: " .. msg)
1616                "#,
1617            )
1618            .exec()
1619            .unwrap();
1620            lua.load(
1621                r#"
1622                    local ok, err = pcall(alc.state.show, "../evil", "key")
1623                    assert(not ok, "expected error for unsafe namespace in show")
1624                    local msg = tostring(err)
1625                    assert(string.find(msg, "unsafe"), "error should contain 'unsafe', got: " .. msg)
1626                "#,
1627            )
1628            .exec()
1629            .unwrap();
1630            lua.load(
1631                r#"
1632                    local ok, err = pcall(alc.state.reset, "../evil", "key", {})
1633                    assert(not ok, "expected error for unsafe namespace in reset")
1634                    local msg = tostring(err)
1635                    assert(string.find(msg, "unsafe"), "error should contain 'unsafe', got: " .. msg)
1636                "#,
1637            )
1638            .exec()
1639            .unwrap();
1640        }
1641    }
1642}