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