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