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
12fn to_lua_value<T: serde::Serialize + ?Sized>(lua: &Lua, value: &T) -> LuaResult<LuaValue> {
32 let options = SerializeOptions::new()
33 .serialize_none_to_null(false)
34 .serialize_unit_to_null(false);
35 lua.to_value_with(value, options)
36}
37
38pub(super) fn register_json(lua: &Lua, alc_table: &LuaTable) -> LuaResult<()> {
39 let encode = lua.create_function(|lua, value: LuaValue| {
40 let json: serde_json::Value = lua.from_value(value)?;
41 serde_json::to_string(&json).map_err(LuaError::external)
42 })?;
43
44 let decode = lua.create_function(|lua, s: String| {
47 let value: serde_json::Value = serde_json::from_str(&s).map_err(LuaError::external)?;
48 to_lua_value(lua, &value)
49 })?;
50
51 alc_table.set("json_encode", encode)?;
52 alc_table.set("json_decode", decode)?;
53 Ok(())
54}
55
56pub(super) fn register_log(lua: &Lua, alc_table: &LuaTable, log_sink: LogSink) -> LuaResult<()> {
70 let log = lua.create_function(move |_, (level, msg): (String, String)| {
71 match level.as_str() {
73 "error" => tracing::error!(target: "alc.log", "{}", msg),
74 "warn" => tracing::warn!(target: "alc.log", "{}", msg),
75 "info" => tracing::info!(target: "alc.log", "{}", msg),
76 "debug" => tracing::debug!(target: "alc.log", "{}", msg),
77 _ => tracing::info!(target: "alc.log", "{}", msg),
78 }
79 log_sink.push(LogEntry::new(level.clone(), "alc.log", msg));
81 Ok(())
82 })?;
83
84 alc_table.set("log", log)?;
85 Ok(())
86}
87
88pub(super) fn register_print(lua: &Lua, log_sink: LogSink) -> LuaResult<()> {
108 let print_fn = lua.create_function(move |lua_inner, args: mlua::MultiValue| {
109 let parts: Vec<String> = args
110 .iter()
111 .map(|v| match v {
112 LuaValue::Nil => "nil".to_string(),
113 LuaValue::Boolean(b) => b.to_string(),
114 LuaValue::Integer(n) => n.to_string(),
115 LuaValue::Number(n) => {
116 if n.fract() == 0.0 && n.abs() < 1e15_f64 {
119 format!("{n:.1}")
120 } else {
121 format!("{n}")
122 }
123 }
124 other => lua_inner
125 .coerce_string(other.clone())
126 .ok()
127 .flatten()
128 .and_then(|s| s.to_str().ok().map(|r| r.to_string()))
129 .unwrap_or_else(|| format!("{other:?}")),
130 })
131 .collect();
132 let line = parts.join("\t");
133 tracing::info!(target: "alc.lua.print", "{}", line);
135 let message = line.trim_end_matches('\n').to_string();
137 log_sink.push(LogEntry::new("info", "alc.lua.print", message));
138 Ok(())
139 })?;
140 lua.globals().set("print", print_fn)?;
141 Ok(())
142}
143
144pub(super) fn register_state(
162 lua: &Lua,
163 alc_table: &LuaTable,
164 ns: String,
165 state_store: Arc<JsonFileStore>,
166) -> LuaResult<()> {
167 let state_table = lua.create_table()?;
168
169 let ns_get = ns.clone();
171 let store_get = Arc::clone(&state_store);
172 let get =
173 lua.create_function(
174 move |lua, (key, default): (String, Option<LuaValue>)| match store_get
175 .get(&ns_get, &key)
176 {
177 Ok(Some(v)) => to_lua_value(lua, &v),
178 Ok(None) => Ok(default.unwrap_or(LuaValue::Nil)),
179 Err(e) => Err(LuaError::external(e)),
180 },
181 )?;
182
183 let ns_set = ns.clone();
185 let store_set = Arc::clone(&state_store);
186 let set = lua.create_function(move |lua, (key, value): (String, LuaValue)| {
187 let json: serde_json::Value = lua.from_value(value)?;
188 store_set
189 .set(&ns_set, &key, json)
190 .map_err(LuaError::external)
191 })?;
192
193 let ns_keys = ns.clone();
195 let store_keys = Arc::clone(&state_store);
196 let keys = lua.create_function(move |lua, ()| {
197 let k = store_keys.keys(&ns_keys).map_err(LuaError::external)?;
198 to_lua_value(lua, &k)
199 })?;
200
201 let ns_del = ns.clone();
203 let store_del = Arc::clone(&state_store);
204 let delete = lua.create_function(move |_, key: String| {
205 store_del.delete(&ns_del, &key).map_err(LuaError::external)
206 })?;
207
208 let ns_has = ns.clone();
210 let store_has = Arc::clone(&state_store);
211 let has = lua.create_function(move |_, key: String| {
212 store_has.has(&ns_has, &key).map_err(LuaError::external)
213 })?;
214
215 let ns_snx = ns.clone();
217 let store_snx = Arc::clone(&state_store);
218 let set_nx = lua.create_function(move |lua, (key, value): (String, LuaValue)| {
219 let json: serde_json::Value = lua.from_value(value)?;
220 store_snx
221 .set_nx(&ns_snx, &key, json)
222 .map_err(LuaError::external)
223 })?;
224
225 let ns_incr = ns;
227 let store_incr = Arc::clone(&state_store);
228 let incr = lua.create_function(
229 move |_, (key, delta, default): (String, Option<f64>, Option<f64>)| {
230 store_incr
231 .incr(&ns_incr, &key, delta.unwrap_or(1.0), default.unwrap_or(0.0))
232 .map_err(LuaError::external)
233 },
234 )?;
235
236 let store_list = Arc::clone(&state_store);
238 let list = lua.create_function(move |lua, namespace: String| {
239 let keys = store_list
240 .list_dispatched(&namespace)
241 .map_err(LuaError::external)?;
242 to_lua_value(lua, &keys)
243 })?;
244
245 let store_show = Arc::clone(&state_store);
247 let show = lua.create_function(move |lua, (namespace, key): (String, String)| {
248 let v = store_show
249 .show_dispatched(&namespace, &key)
250 .map_err(LuaError::external)?;
251 to_lua_value(lua, &v)
252 })?;
253
254 let store_reset = Arc::clone(&state_store);
256 let reset = lua.create_function(
257 move |lua, (namespace, key, opts): (String, String, Option<LuaTable>)| {
258 let (steps, fields) = match opts {
259 Some(t) => {
260 let s = t.get::<Option<Vec<String>>>("steps")?.unwrap_or_default();
261 let f = t.get::<Option<Vec<String>>>("fields")?.unwrap_or_default();
262 (s, f)
263 }
264 None => (Vec::new(), Vec::new()),
265 };
266 let report = store_reset
267 .reset_dispatched_with_backup(&namespace, &key, &steps, &fields)
268 .map_err(LuaError::external)?;
269 let ret = lua.create_table()?;
270 ret.set("ok", true)?;
271 ret.set(
272 "backup_path",
273 report.backup_path.to_string_lossy().to_string(),
274 )?;
275 ret.set("steps_removed", report.steps_removed)?;
276 ret.set("fields_removed", report.fields_removed)?;
277 Ok(ret)
278 },
279 )?;
280
281 let store_set_dispatched = Arc::clone(&state_store);
283 let set_dispatched = lua.create_function(
284 move |lua, (namespace, key, value): (String, String, LuaValue)| {
285 let json: serde_json::Value = lua.from_value(value)?;
286 store_set_dispatched
287 .set_dispatched(&namespace, &key, &json)
288 .map_err(LuaError::external)
289 },
290 )?;
291
292 let store_delete_dispatched = Arc::clone(&state_store);
294 let delete_dispatched = lua.create_function(move |_, (namespace, key): (String, String)| {
295 store_delete_dispatched
296 .delete_dispatched(&namespace, &key)
297 .map_err(LuaError::external)
298 })?;
299
300 state_table.set("get", get)?;
301 state_table.set("set", set)?;
302 state_table.set("keys", keys)?;
303 state_table.set("delete", delete)?;
304 state_table.set("has", has)?;
305 state_table.set("set_nx", set_nx)?;
306 state_table.set("incr", incr)?;
307 state_table.set("list", list)?;
308 state_table.set("show", show)?;
309 state_table.set("reset", reset)?;
310 state_table.set("set_dispatched", set_dispatched)?;
311 state_table.set("delete_dispatched", delete_dispatched)?;
312
313 alc_table.set("state", state_table)?;
314 Ok(())
315}
316
317pub(super) fn register_dirs(
323 lua: &Lua,
324 alc_table: &LuaTable,
325 state_dir: &Path,
326 cards_dir: &Path,
327 scenarios_dir: &Path,
328) -> LuaResult<()> {
329 let dirs = lua.create_table()?;
330 dirs.set("state", state_dir.to_string_lossy().into_owned())?;
331 dirs.set("cards", cards_dir.to_string_lossy().into_owned())?;
332 dirs.set("scenarios", scenarios_dir.to_string_lossy().into_owned())?;
333 alc_table.set("_dirs", dirs)?;
334 Ok(())
335}
336
337pub(super) fn register_card(
362 lua: &Lua,
363 alc_table: &LuaTable,
364 card_store: Arc<FileCardStore>,
365 card_run_enabled: bool,
366) -> LuaResult<()> {
367 let card_table = lua.create_table()?;
368
369 let store_create = Arc::clone(&card_store);
377 let create = lua.create_function(move |lua, input: LuaValue| -> LuaResult<LuaValue> {
378 let json: serde_json::Value = lua.from_value(input)?;
379 let run_section = card::RunSection::from_json(&json).map_err(LuaError::external)?;
380 if run_section.is_some() && !card_run_enabled {
381 return Ok(LuaValue::Nil);
382 }
383 let (card_id, path) = store_create.create(json).map_err(LuaError::external)?;
384 let ret = lua.create_table()?;
385 ret.set("card_id", card_id)?;
386 ret.set("path", path.to_string_lossy().to_string())?;
387 Ok(LuaValue::Table(ret))
388 })?;
389
390 let store_get = Arc::clone(&card_store);
392 let get = lua.create_function(move |lua, card_id: String| match store_get.get(&card_id) {
393 Ok(Some(v)) => to_lua_value(lua, &v),
394 Ok(None) => Ok(LuaValue::Nil),
395 Err(e) => Err(LuaError::external(e)),
396 })?;
397
398 let store_list = Arc::clone(&card_store);
400 let list = lua.create_function(move |lua, filter: Option<LuaTable>| {
401 let pkg = match filter {
402 Some(t) => t.get::<Option<String>>("pkg")?,
403 None => None,
404 };
405 let rows = store_list
406 .list(pkg.as_deref())
407 .map_err(LuaError::external)?;
408 to_lua_value(lua, &card::summaries_to_json(&rows))
409 })?;
410
411 let store_append = Arc::clone(&card_store);
418 let append = lua.create_function(
419 move |lua, (card_id, fields): (String, LuaValue)| -> LuaResult<LuaValue> {
420 let json: serde_json::Value = lua.from_value(fields)?;
421 let run_section = card::RunSection::from_json(&json).map_err(LuaError::external)?;
422 if run_section.is_some() && !card_run_enabled {
423 return Ok(LuaValue::Nil);
424 }
425 let merged = store_append
426 .append(&card_id, json)
427 .map_err(LuaError::external)?;
428 to_lua_value(lua, &merged)
429 },
430 )?;
431
432 let store_gba = Arc::clone(&card_store);
434 let get_by_alias = lua.create_function(move |lua, name: String| {
435 match store_gba.get_by_alias(&name).map_err(LuaError::external)? {
436 Some(v) => to_lua_value(lua, &v),
437 None => Ok(LuaValue::Nil),
438 }
439 })?;
440
441 let store_aset = Arc::clone(&card_store);
443 let alias_set = lua.create_function(
444 move |lua, (name, card_id, opts): (String, String, Option<LuaTable>)| {
445 let (pkg, note) = match opts {
446 Some(t) => (
447 t.get::<Option<String>>("pkg")?,
448 t.get::<Option<String>>("note")?,
449 ),
450 None => (None, None),
451 };
452 let a = store_aset
453 .alias_set(&name, &card_id, pkg.as_deref(), note.as_deref())
454 .map_err(LuaError::external)?;
455 let arr = card::aliases_to_json(&[a]);
456 let first = match arr {
457 serde_json::Value::Array(mut v) if !v.is_empty() => v.remove(0),
458 other => other,
459 };
460 to_lua_value(lua, &first)
461 },
462 )?;
463
464 let store_alist = Arc::clone(&card_store);
466 let alias_list = lua.create_function(move |lua, filter: Option<LuaTable>| {
467 let pkg = match filter {
468 Some(t) => t.get::<Option<String>>("pkg")?,
469 None => None,
470 };
471 let rows = store_alist
472 .alias_list(pkg.as_deref())
473 .map_err(LuaError::external)?;
474 to_lua_value(lua, &card::aliases_to_json(&rows))
475 })?;
476
477 let store_find = Arc::clone(&card_store);
482 let find = lua.create_function(move |lua, query: Option<LuaTable>| {
483 let q = match query {
484 Some(t) => {
485 let pkg = t.get::<Option<String>>("pkg")?;
486 let limit = t.get::<Option<usize>>("limit")?;
487 let offset = t.get::<Option<usize>>("offset")?;
488
489 let where_parsed = match t.get::<LuaValue>("where")? {
490 LuaValue::Nil => None,
491 v => {
492 let json: serde_json::Value = lua.from_value(v)?;
493 Some(card::parse_where(&json).map_err(LuaError::external)?)
494 }
495 };
496 let order_parsed = match t.get::<LuaValue>("order_by")? {
497 LuaValue::Nil => Vec::new(),
498 v => {
499 let json: serde_json::Value = lua.from_value(v)?;
500 card::parse_order_by(&json).map_err(LuaError::external)?
501 }
502 };
503
504 card::FindQuery {
505 pkg,
506 where_: where_parsed,
507 order_by: order_parsed,
508 limit,
509 offset,
510 }
511 }
512 None => card::FindQuery::default(),
513 };
514 let rows = store_find.find(q).map_err(LuaError::external)?;
515 to_lua_value(lua, &card::summaries_to_json(&rows))
516 })?;
517
518 let store_ws = Arc::clone(&card_store);
520 let write_samples =
521 lua.create_function(move |lua, (card_id, samples): (String, LuaValue)| {
522 let json: serde_json::Value = lua.from_value(samples)?;
523 let arr = match json {
524 serde_json::Value::Array(a) => a,
525 _ => {
526 return Err(LuaError::external(
527 "alc.card.write_samples: samples must be an array",
528 ))
529 }
530 };
531 let count = arr.len();
532 let path = store_ws
533 .write_samples(&card_id, arr)
534 .map_err(LuaError::external)?;
535 let ret = lua.create_table()?;
536 ret.set("path", path.to_string_lossy().to_string())?;
537 ret.set("count", count)?;
538 Ok(ret)
539 })?;
540
541 let store_rs = Arc::clone(&card_store);
546 let read_samples =
547 lua.create_function(move |lua, (card_id, opts): (String, Option<LuaTable>)| {
548 let (offset, limit, where_parsed) = match opts {
549 Some(t) => {
550 let offset = t.get::<Option<usize>>("offset")?.unwrap_or(0);
551 let limit = t.get::<Option<usize>>("limit")?;
552 let where_parsed = match t.get::<LuaValue>("where")? {
553 LuaValue::Nil => None,
554 v => {
555 let json: serde_json::Value = lua.from_value(v)?;
556 Some(card::parse_where(&json).map_err(LuaError::external)?)
557 }
558 };
559 (offset, limit, where_parsed)
560 }
561 None => (0, None, None),
562 };
563 let q = card::SamplesQuery {
564 offset,
565 limit,
566 where_: where_parsed,
567 };
568 let rows = store_rs
569 .read_samples(&card_id, q)
570 .map_err(LuaError::external)?;
571 to_lua_value(lua, &serde_json::Value::Array(rows))
572 })?;
573
574 let store_sb = Arc::clone(&card_store);
579 let sink_backfill = lua.create_function(move |lua, params: LuaTable| {
580 let sink: String = params.get("sink")?;
581 let dry_run: Option<bool> = params.get("dry_run")?;
582 let report = store_sb
583 .card_sink_backfill(&sink, dry_run.unwrap_or(false))
584 .map_err(LuaError::external)?;
585 to_lua_value(lua, &report)
586 })?;
587
588 let store_lin = Arc::clone(&card_store);
593 let lineage = lua.create_function(move |lua, query: LuaTable| {
594 let card_id: String = query.get("card_id")?;
595 let direction_str: Option<String> = query.get("direction")?;
596 let direction = match direction_str.as_deref() {
597 Some(s) => card::LineageDirection::parse(s).map_err(LuaError::external)?,
598 None => card::LineageDirection::Up,
599 };
600 let depth: Option<usize> = query.get("depth")?;
601 let include_stats: Option<bool> = query.get("include_stats")?;
602 let relation_filter: Option<Vec<String>> = match query.get::<LuaValue>("relation_filter")? {
603 LuaValue::Nil => None,
604 v => Some(lua.from_value(v)?),
605 };
606
607 let q = card::LineageQuery {
608 card_id,
609 direction,
610 depth,
611 include_stats: include_stats.unwrap_or(true),
612 relation_filter,
613 };
614 match store_lin.lineage(q).map_err(LuaError::external)? {
615 Some(res) => to_lua_value(lua, &card::lineage_to_json(&res)),
616 None => Ok(LuaValue::Nil),
617 }
618 })?;
619
620 card_table.set("create", create)?;
621 card_table.set("get", get)?;
622 card_table.set("list", list)?;
623 card_table.set("append", append)?;
624 card_table.set("get_by_alias", get_by_alias)?;
625 card_table.set("alias_set", alias_set)?;
626 card_table.set("alias_list", alias_list)?;
627 card_table.set("find", find)?;
628 card_table.set("write_samples", write_samples)?;
629 card_table.set("read_samples", read_samples)?;
630 card_table.set("lineage", lineage)?;
631 card_table.set("sink_backfill", sink_backfill)?;
632
633 alc_table.set("card", card_table)?;
634 Ok(())
635}
636
637pub(super) fn register_stats(
650 lua: &Lua,
651 alc_table: &LuaTable,
652 custom_metrics: CustomMetricsHandle,
653 stats: StatsHandle,
654) -> LuaResult<()> {
655 let stats_table = lua.create_table()?;
656
657 let cm_record = custom_metrics.clone();
659 let record = lua.create_function(move |lua, (key, value): (String, LuaValue)| {
660 let json: serde_json::Value = lua.from_value(value)?;
661 cm_record.record(key, json);
662 Ok(())
663 })?;
664
665 let cm_get = custom_metrics;
667 let get = lua.create_function(move |lua, key: String| match cm_get.get(&key) {
668 Some(v) => to_lua_value(lua, &v),
669 None => Ok(LuaValue::Nil),
670 })?;
671
672 let stats_handle = stats;
674 let llm_calls = lua.create_function(move |_, ()| Ok(stats_handle.llm_calls()))?;
675
676 stats_table.set("record", record)?;
677 stats_table.set("get", get)?;
678 stats_table.set("llm_calls", llm_calls)?;
679
680 alc_table.set("stats", stats_table)?;
681 Ok(())
682}
683
684pub struct AlcEnv(pub Arc<HashMap<String, String>>);
695
696impl mlua::UserData for AlcEnv {
697 fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M) {
698 methods.add_meta_method(mlua::MetaMethod::Index, |_, this, key: String| {
700 Ok(this.0.get(&key).cloned())
701 });
702
703 methods.add_meta_method(
706 mlua::MetaMethod::NewIndex,
707 |_, _, (_k, _v): (mlua::Value, mlua::Value)| {
708 Err::<(), _>(mlua::Error::external("alc.env is readonly"))
709 },
710 );
711
712 methods.add_method(
714 "get",
715 |_, this, (key, default): (String, Option<String>)| {
716 Ok(this.0.get(&key).cloned().or(default))
717 },
718 );
719
720 methods.add_method("use", |lua, this, declared: Vec<String>| {
724 let proxy = lua.create_table()?;
725 for k in &declared {
726 if let Some(v) = this.0.get(k) {
727 proxy.set(k.clone(), v.clone())?;
728 }
729 }
730 Ok(proxy)
731 });
732 }
733}
734
735pub fn register_env(
743 lua: &mlua::Lua,
744 alc_table: &mlua::Table,
745 env_map: Arc<HashMap<String, String>>,
746) -> mlua::Result<()> {
747 alc_table.set("env", AlcEnv(Arc::clone(&env_map)))?;
748 lua.set_app_data(env_map);
749 Ok(())
750}
751
752#[cfg(test)]
753mod tests {
754 use super::*;
755 use algocline_core::ExecutionMetrics;
756
757 fn test_config_with(ns: &str) -> crate::bridge::BridgeConfig {
761 let metrics = ExecutionMetrics::new();
762 let tmp = tempfile::tempdir().expect("test tempdir");
763 let root = tmp.path().to_path_buf();
764 std::mem::forget(tmp);
765 crate::bridge::BridgeConfig {
766 llm_tx: None,
767 ns: ns.into(),
768 custom_metrics: metrics.custom_metrics_handle(),
769 stats: metrics.stats_handle(),
770 budget: metrics.budget_handle(),
771 progress: metrics.progress_handle(),
772 lib_paths: vec![],
773 variant_pkgs: vec![],
774 state_store: Arc::new(JsonFileStore::new(root.join("state"))),
775 card_store: Arc::new(FileCardStore::new(root.join("cards"))),
776 card_run_enabled: false,
777 scenarios_dir: root.join("scenarios"),
778 nn_dir: root.join("nn"),
779 log_sink: None,
780 }
781 }
782
783 fn test_config() -> crate::bridge::BridgeConfig {
784 test_config_with("default")
785 }
786
787 fn test_config_with_ns(ns: &str) -> crate::bridge::BridgeConfig {
788 test_config_with(ns)
789 }
790
791 #[test]
792 fn json_roundtrip() {
793 let lua = Lua::new();
794 let t = lua.create_table().unwrap();
795 crate::bridge::register(&lua, &t, test_config()).unwrap();
796 lua.globals().set("alc", t).unwrap();
797
798 let result: String = lua
799 .load(r#"return alc.json_encode({hello = "world", n = 42})"#)
800 .eval()
801 .unwrap();
802 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
803 assert_eq!(parsed["hello"], "world");
804 assert_eq!(parsed["n"], 42);
805 }
806
807 #[test]
808 fn json_decode_encode() {
809 let lua = Lua::new();
810 let t = lua.create_table().unwrap();
811 crate::bridge::register(&lua, &t, test_config()).unwrap();
812 lua.globals().set("alc", t).unwrap();
813
814 let result: String = lua
815 .load(
816 r#"
817 local val = alc.json_decode('{"a":1,"b":"two"}')
818 val.c = true
819 return alc.json_encode(val)
820 "#,
821 )
822 .eval()
823 .unwrap();
824 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
825 assert_eq!(parsed["a"], 1);
826 assert_eq!(parsed["b"], "two");
827 assert_eq!(parsed["c"], true);
828 }
829
830 #[test]
835 fn json_decode_null_yields_lua_nil() {
836 let lua = Lua::new();
837 let t = lua.create_table().unwrap();
838 crate::bridge::register(&lua, &t, test_config()).unwrap();
839 lua.globals().set("alc", t).unwrap();
840
841 let top_level_truthy: bool = lua
843 .load(r#"local v = alc.json_decode("null"); return v ~= nil"#)
844 .eval()
845 .unwrap();
846 assert!(
847 !top_level_truthy,
848 "alc.json_decode(\"null\") should return Lua nil"
849 );
850
851 let field_truthy: bool = lua
853 .load(
854 r#"
855 local obj = alc.json_decode('{"x": null, "y": 1}')
856 return obj.x ~= nil
857 "#,
858 )
859 .eval()
860 .unwrap();
861 assert!(
862 !field_truthy,
863 "Object field decoded from JSON null should be Lua nil"
864 );
865
866 let field_type: String = lua
868 .load(r#"return type(alc.json_decode('{"x": null}').x)"#)
869 .eval()
870 .unwrap();
871 assert_eq!(
872 field_type, "nil",
873 "type() of null-decoded field must be 'nil', not 'userdata'"
874 );
875
876 let arr_len: i64 = lua
879 .load(r#"return #alc.json_decode('[1, null, 3]')"#)
880 .eval()
881 .unwrap();
882 assert_eq!(
889 arr_len, 3,
890 "JSON array length is preserved across null elements (mlua/Lua 5.4 array part)"
891 );
892
893 let (a, b, c): (Option<i64>, Option<i64>, Option<i64>) = lua
896 .load(
897 r#"
898 local arr = alc.json_decode('[1, null, 3]')
899 return arr[1], arr[2], arr[3]
900 "#,
901 )
902 .eval()
903 .unwrap();
904 assert_eq!(a, Some(1));
905 assert_eq!(b, None, "Array element decoded from JSON null must be nil");
906 assert_eq!(c, Some(3));
907 }
908
909 #[test]
910 fn state_get_set() {
911 let ns = "_test_bridge_state";
914
915 let lua = Lua::new();
916 let t = lua.create_table().unwrap();
917 crate::bridge::register(&lua, &t, test_config_with_ns(ns)).unwrap();
918 lua.globals().set("alc", t).unwrap();
919
920 lua.load(r#"alc.state.set("x", 99)"#).exec().unwrap();
922 let result: i64 = lua.load(r#"return alc.state.get("x")"#).eval().unwrap();
923 assert_eq!(result, 99);
924
925 let result: i64 = lua
927 .load(r#"return alc.state.get("missing", 0)"#)
928 .eval()
929 .unwrap();
930 assert_eq!(result, 0);
931
932 let result: LuaValue = lua
934 .load(r#"return alc.state.get("missing")"#)
935 .eval()
936 .unwrap();
937 assert!(result.is_nil());
938 }
939
940 #[test]
948 fn state_get_object_with_null_field_yields_lua_nil() {
949 let ns = "_test_bridge_state_null_field";
950
951 let lua = Lua::new();
952 let t = lua.create_table().unwrap();
953 crate::bridge::register(&lua, &t, test_config_with_ns(ns)).unwrap();
954 lua.globals().set("alc", t).unwrap();
955
956 lua.load(r#"alc.state.set("obj", alc.json_decode('{"x": null, "y": 1}'))"#)
958 .exec()
959 .unwrap();
960
961 let x_truthy: bool = lua
963 .load(r#"local v = alc.state.get("obj"); return v.x ~= nil"#)
964 .eval()
965 .unwrap();
966 assert!(
967 !x_truthy,
968 "state.get returned object's null field must be Lua nil (truthy check must skip)"
969 );
970
971 let x_type: String = lua
973 .load(r#"local v = alc.state.get("obj"); return type(v.x)"#)
974 .eval()
975 .unwrap();
976 assert_eq!(
977 x_type, "nil",
978 "type() of state.get'd null field must be 'nil', not 'userdata'"
979 );
980
981 let y: i64 = lua.load(r#"return alc.state.get("obj").y"#).eval().unwrap();
983 assert_eq!(y, 1, "Non-null sibling field must round-trip unchanged");
984 }
985
986 #[test]
987 fn state_has_set_nx_incr() {
988 let ns = "_test_bridge_state_t1";
989
990 let lua = Lua::new();
991 let t = lua.create_table().unwrap();
992 crate::bridge::register(&lua, &t, test_config_with_ns(ns)).unwrap();
993 lua.globals().set("alc", t).unwrap();
994
995 let h: bool = lua.load(r#"return alc.state.has("k")"#).eval().unwrap();
997 assert!(!h);
998
999 let ok: bool = lua
1001 .load(r#"return alc.state.set_nx("k", "first")"#)
1002 .eval()
1003 .unwrap();
1004 assert!(ok);
1005
1006 let h: bool = lua.load(r#"return alc.state.has("k")"#).eval().unwrap();
1008 assert!(h);
1009
1010 let ok: bool = lua
1012 .load(r#"return alc.state.set_nx("k", "second")"#)
1013 .eval()
1014 .unwrap();
1015 assert!(!ok);
1016
1017 let v: f64 = lua
1019 .load(r#"return alc.state.incr("counter")"#)
1020 .eval()
1021 .unwrap();
1022 assert!((v - 1.0).abs() < f64::EPSILON);
1023
1024 let v: f64 = lua
1026 .load(r#"return alc.state.incr("counter", 5)"#)
1027 .eval()
1028 .unwrap();
1029 assert!((v - 6.0).abs() < f64::EPSILON);
1030
1031 let v: f64 = lua
1033 .load(r#"return alc.state.incr("counter", 10, 100)"#)
1034 .eval()
1035 .unwrap();
1036 assert!((v - 16.0).abs() < f64::EPSILON);
1037 }
1038
1039 #[test]
1040 fn card_create_get_list_from_lua() {
1041 let ns = std::time::SystemTime::now()
1043 .duration_since(std::time::UNIX_EPOCH)
1044 .unwrap()
1045 .as_nanos();
1046 let pkg = format!("_test_bridge_card_{ns}");
1047
1048 let lua = Lua::new();
1049 let t = lua.create_table().unwrap();
1050 crate::bridge::register(&lua, &t, test_config()).unwrap();
1051 lua.globals().set("alc", t).unwrap();
1052
1053 let create_script = format!(
1055 r#"
1056 local r = alc.card.create({{
1057 pkg = {{ name = "{pkg}" }},
1058 model = {{ id = "claude-opus-4-6" }},
1059 stats = {{ pass_rate = 0.9 }},
1060 }})
1061 return r.card_id
1062 "#
1063 );
1064 let card_id: String = lua.load(&create_script).eval().unwrap();
1065 assert!(card_id.starts_with(&pkg));
1066
1067 let get_script = format!(r#"return alc.card.get("{card_id}").stats.pass_rate"#);
1069 let rate: f64 = lua.load(&get_script).eval().unwrap();
1070 assert!((rate - 0.9).abs() < 1e-9);
1071
1072 let list_script = format!(
1074 r#"
1075 local rows = alc.card.list({{ pkg = "{pkg}" }})
1076 return #rows
1077 "#
1078 );
1079 let count: i64 = lua.load(&list_script).eval().unwrap();
1080 assert_eq!(count, 1);
1081
1082 }
1084
1085 #[test]
1086 fn stats_record_get() {
1087 let metrics = ExecutionMetrics::new();
1088 let custom_handle = metrics.custom_metrics_handle();
1089 let lua = Lua::new();
1090 let t = lua.create_table().unwrap();
1091 let tmp = tempfile::tempdir().expect("test tempdir");
1092 let root = tmp.path().to_path_buf();
1093 std::mem::forget(tmp);
1094 crate::bridge::register(
1095 &lua,
1096 &t,
1097 crate::bridge::BridgeConfig {
1098 llm_tx: None,
1099 ns: "default".into(),
1100 custom_metrics: custom_handle.clone(),
1101 stats: metrics.stats_handle(),
1102 budget: metrics.budget_handle(),
1103 progress: metrics.progress_handle(),
1104 lib_paths: vec![],
1105 variant_pkgs: vec![],
1106 state_store: Arc::new(JsonFileStore::new(root.join("state"))),
1107 card_store: Arc::new(FileCardStore::new(root.join("cards"))),
1108 card_run_enabled: false,
1109 scenarios_dir: root.join("scenarios"),
1110 nn_dir: root.join("nn"),
1111 log_sink: None,
1112 },
1113 )
1114 .unwrap();
1115 lua.globals().set("alc", t).unwrap();
1116
1117 lua.load(r#"alc.stats.record("score", 42)"#).exec().unwrap();
1119 let result: i64 = lua.load(r#"return alc.stats.get("score")"#).eval().unwrap();
1120 assert_eq!(result, 42);
1121
1122 assert_eq!(custom_handle.get("score"), Some(serde_json::json!(42)));
1124
1125 let result: LuaValue = lua
1127 .load(r#"return alc.stats.get("missing")"#)
1128 .eval()
1129 .unwrap();
1130 assert!(result.is_nil());
1131 }
1132
1133 #[test]
1138 fn stats_llm_calls_reads_session_status() {
1139 use crate::card::FileCardStore;
1140 use crate::state::JsonFileStore;
1141 use algocline_core::{ExecutionObserver, LlmQuery, QueryId};
1142 use std::sync::Arc;
1143
1144 let metrics = ExecutionMetrics::new();
1145 let observer = metrics.create_observer();
1146
1147 let lua = Lua::new();
1148 let t = lua.create_table().unwrap();
1149 let tmp = tempfile::tempdir().expect("test tempdir");
1150 let root = tmp.path().to_path_buf();
1151 std::mem::forget(tmp);
1152 crate::bridge::register(
1153 &lua,
1154 &t,
1155 crate::bridge::BridgeConfig {
1156 llm_tx: None,
1157 ns: "default".into(),
1158 custom_metrics: metrics.custom_metrics_handle(),
1159 stats: metrics.stats_handle(),
1160 budget: metrics.budget_handle(),
1161 progress: metrics.progress_handle(),
1162 lib_paths: vec![],
1163 variant_pkgs: vec![],
1164 state_store: Arc::new(JsonFileStore::new(root.join("state"))),
1165 card_store: Arc::new(FileCardStore::new(root.join("cards"))),
1166 card_run_enabled: false,
1167 scenarios_dir: root.join("scenarios"),
1168 nn_dir: root.join("nn"),
1169 log_sink: None,
1170 },
1171 )
1172 .unwrap();
1173 lua.globals().set("alc", t).unwrap();
1174
1175 let initial: u64 = lua.load(r#"return alc.stats.llm_calls()"#).eval().unwrap();
1177 assert_eq!(initial, 0, "fresh session must report llm_calls() == 0");
1178
1179 observer.on_paused(&[LlmQuery {
1181 id: QueryId::parse("q-0"),
1182 prompt: "hi".to_string(),
1183 system: None,
1184 max_tokens: 0,
1185 grounded: false,
1186 underspecified: false,
1187 cache_breakpoint: None,
1188 role: None,
1189 }]);
1190
1191 let after_one: u64 = lua.load(r#"return alc.stats.llm_calls()"#).eval().unwrap();
1193 assert_eq!(
1194 after_one, 1,
1195 "one paused query must increment llm_calls() to 1"
1196 );
1197
1198 observer.on_paused(&[
1200 LlmQuery {
1201 id: QueryId::parse("q-1"),
1202 prompt: "a".to_string(),
1203 system: None,
1204 max_tokens: 0,
1205 grounded: false,
1206 underspecified: false,
1207 cache_breakpoint: None,
1208 role: None,
1209 },
1210 LlmQuery {
1211 id: QueryId::parse("q-2"),
1212 prompt: "b".to_string(),
1213 system: None,
1214 max_tokens: 0,
1215 grounded: false,
1216 underspecified: false,
1217 cache_breakpoint: None,
1218 role: None,
1219 },
1220 ]);
1221
1222 let after_three: u64 = lua.load(r#"return alc.stats.llm_calls()"#).eval().unwrap();
1223 assert_eq!(
1224 after_three, 3,
1225 "two further paused queries (multi-query batch) must bring llm_calls() to 3"
1226 );
1227 }
1228
1229 #[test]
1233 fn register_log_pushes_to_log_sink() {
1234 use algocline_core::LogSink;
1235
1236 let sink = LogSink::new();
1237 let lua = Lua::new();
1238 let t = lua.create_table().unwrap();
1239 register_log(&lua, &t, sink.clone()).unwrap();
1241 lua.globals().set("alc", t).unwrap();
1242
1243 lua.load(r#"alc.log("info", "hello-from-log")"#)
1244 .exec()
1245 .unwrap();
1247
1248 let entries = sink.entries();
1249 assert_eq!(entries.len(), 1);
1250 assert_eq!(entries[0].source, "alc.log");
1251 assert_eq!(entries[0].level, "info");
1252 assert_eq!(entries[0].message, "hello-from-log");
1253 }
1254
1255 #[test]
1257 fn register_log_unknown_level_still_pushes() {
1258 use algocline_core::LogSink;
1259
1260 let sink = LogSink::new();
1261 let lua = Lua::new();
1262 let t = lua.create_table().unwrap();
1263 register_log(&lua, &t, sink.clone()).unwrap();
1265 lua.globals().set("alc", t).unwrap();
1266
1267 lua.load(r#"alc.log("custom", "edge-case")"#)
1268 .exec()
1269 .unwrap();
1271
1272 let entries = sink.entries();
1273 assert_eq!(entries.len(), 1);
1274 assert_eq!(entries[0].source, "alc.log");
1275 assert_eq!(entries[0].level, "custom");
1277 assert_eq!(entries[0].message, "edge-case");
1278 }
1279
1280 #[test]
1282 fn register_log_empty_message() {
1283 use algocline_core::LogSink;
1284
1285 let sink = LogSink::new();
1286 let lua = Lua::new();
1287 let t = lua.create_table().unwrap();
1288 register_log(&lua, &t, sink.clone()).unwrap();
1290 lua.globals().set("alc", t).unwrap();
1291
1292 lua.load(r#"alc.log("warn", "")"#)
1293 .exec()
1294 .unwrap();
1296
1297 let entries = sink.entries();
1298 assert_eq!(entries.len(), 1);
1299 assert_eq!(entries[0].message, "");
1300 }
1301
1302 #[test]
1306 fn register_print_pushes_to_log_sink() {
1307 use algocline_core::LogSink;
1308
1309 let sink = LogSink::new();
1310 let lua = Lua::new();
1311 register_print(&lua, sink.clone()).unwrap();
1313
1314 lua.load(r#"print("hello-print")"#)
1315 .exec()
1316 .unwrap();
1318
1319 let entries = sink.entries();
1320 assert_eq!(entries.len(), 1);
1321 assert_eq!(entries[0].source, "alc.lua.print");
1322 assert_eq!(entries[0].level, "info");
1323 assert_eq!(entries[0].message, "hello-print");
1324 }
1325
1326 #[test]
1328 fn register_print_multiple_args_tab_joined() {
1329 use algocline_core::LogSink;
1330
1331 let sink = LogSink::new();
1332 let lua = Lua::new();
1333 register_print(&lua, sink.clone()).unwrap();
1335
1336 lua.load(r#"print("a", "b", "c")"#)
1337 .exec()
1338 .unwrap();
1340
1341 let entries = sink.entries();
1342 assert_eq!(entries.len(), 1);
1343 assert_eq!(entries[0].message, "a\tb\tc");
1344 }
1345
1346 #[test]
1348 fn register_print_mixed_value_types() {
1349 use algocline_core::LogSink;
1350
1351 let sink = LogSink::new();
1352 let lua = Lua::new();
1353 register_print(&lua, sink.clone()).unwrap();
1355
1356 lua.load(r#"print(nil, true, 42, 3.14)"#)
1357 .exec()
1358 .unwrap();
1360
1361 let entries = sink.entries();
1362 assert_eq!(entries.len(), 1);
1363 let msg = &entries[0].message;
1365 assert!(msg.starts_with("nil\ttrue\t42\t"), "got: {msg}");
1366 }
1367
1368 fn make_env_lua(pairs: &[(&str, &str)]) -> (Lua, Arc<HashMap<String, String>>) {
1371 let mut map = HashMap::new();
1372 for (k, v) in pairs {
1373 map.insert(k.to_string(), v.to_string());
1374 }
1375 let env_map = Arc::new(map);
1376 let lua = Lua::new();
1377 let alc_table = lua.create_table().unwrap();
1378 register_env(&lua, &alc_table, Arc::clone(&env_map)).unwrap();
1379 lua.globals().set("alc", alc_table).unwrap();
1380 (lua, env_map)
1381 }
1382
1383 #[test]
1384 fn env_index_reads_existing_key() {
1385 let (lua, _) = make_env_lua(&[("FOO", "bar")]);
1386 let val: Option<String> = lua.load(r#"return alc.env.FOO"#).eval().unwrap();
1387 assert_eq!(val, Some("bar".to_string()));
1388 }
1389
1390 #[test]
1391 fn env_index_missing_key_returns_nil() {
1392 let (lua, _) = make_env_lua(&[("FOO", "bar")]);
1393 let val: LuaValue = lua.load(r#"return alc.env.MISSING"#).eval().unwrap();
1394 assert!(val.is_nil());
1395 }
1396
1397 #[test]
1398 fn env_newindex_returns_error() {
1399 let (lua, _) = make_env_lua(&[("FOO", "bar")]);
1400 let result: Result<(), _> = lua.load(r#"alc.env.FOO = "x""#).exec();
1401 let err = result.unwrap_err().to_string();
1402 assert!(
1403 err.contains("alc.env is readonly"),
1404 "expected readonly error, got: {err}"
1405 );
1406 }
1407
1408 #[test]
1409 fn env_get_with_default_returns_default_on_miss() {
1410 let (lua, _) = make_env_lua(&[]);
1411 let val: Option<String> = lua
1412 .load(r#"return alc.env:get("MISSING", "fallback")"#)
1413 .eval()
1414 .unwrap();
1415 assert_eq!(val, Some("fallback".to_string()));
1416 }
1417
1418 #[test]
1419 fn env_get_returns_value_when_present() {
1420 let (lua, _) = make_env_lua(&[("KEY", "val")]);
1421 let val: Option<String> = lua
1422 .load(r#"return alc.env:get("KEY", "default")"#)
1423 .eval()
1424 .unwrap();
1425 assert_eq!(val, Some("val".to_string()));
1426 }
1427
1428 #[test]
1429 fn env_use_returns_declared_keys_only() {
1430 let (lua, _) = make_env_lua(&[("FOO", "foo_val"), ("BAR", "bar_val"), ("SECRET", "s")]);
1431 let result: LuaValue = lua
1432 .load(
1433 r#"
1434 local e = alc.env:use{"FOO", "BAR"}
1435 return e
1436 "#,
1437 )
1438 .eval()
1439 .unwrap();
1440 let tbl = result.as_table().unwrap();
1441 assert_eq!(tbl.get::<String>("FOO").unwrap(), "foo_val");
1442 assert_eq!(tbl.get::<String>("BAR").unwrap(), "bar_val");
1443 let secret: LuaValue = tbl.get("SECRET").unwrap();
1445 assert!(secret.is_nil(), "SECRET should be nil in proxy");
1446 }
1447
1448 #[test]
1449 fn env_use_undeclared_key_is_nil() {
1450 let (lua, _) = make_env_lua(&[("FOO", "foo_val")]);
1451 let val: LuaValue = lua
1452 .load(
1453 r#"
1454 local e = alc.env:use{"FOO"}
1455 return e.UNDECLARED
1456 "#,
1457 )
1458 .eval()
1459 .unwrap();
1460 assert!(val.is_nil());
1461 }
1462
1463 #[test]
1464 fn register_env_sets_app_data() {
1465 let mut map = HashMap::new();
1466 map.insert("X".to_string(), "1".to_string());
1467 let env_map = Arc::new(map);
1468 let lua = Lua::new();
1469 let alc_table = lua.create_table().unwrap();
1470 register_env(&lua, &alc_table, Arc::clone(&env_map)).unwrap();
1471 let retrieved = lua.app_data_ref::<Arc<HashMap<String, String>>>().unwrap();
1473 assert_eq!(retrieved.get("X").unwrap(), "1");
1474 }
1475
1476 mod state_dispatched_lua {
1477 use super::*;
1478 use mlua::Lua;
1479 use std::sync::Arc;
1480 use tempfile::TempDir;
1481
1482 fn setup() -> (Lua, Arc<JsonFileStore>, TempDir) {
1483 let tmp = tempfile::tempdir().unwrap();
1484 let store = Arc::new(JsonFileStore::new(tmp.path().to_path_buf()));
1485 let lua = Lua::new();
1486 let alc = lua.create_table().unwrap();
1487 register_state(&lua, &alc, "default".to_string(), Arc::clone(&store)).unwrap();
1488 lua.globals().set("alc", alc).unwrap();
1489 (lua, store, tmp)
1490 }
1491
1492 #[test]
1493 fn list_returns_sorted_keys() {
1494 let (lua, _store, tmp) = setup();
1495 std::fs::create_dir_all(tmp.path().join("testns")).unwrap();
1497 std::fs::write(
1498 tmp.path().join("testns/beta.json"),
1499 r#"{"data": {"completed_steps": [], "x": 1}}"#,
1500 )
1501 .unwrap();
1502 std::fs::write(
1503 tmp.path().join("testns/alpha.json"),
1504 r#"{"data": {"completed_steps": [], "y": 2}}"#,
1505 )
1506 .unwrap();
1507 lua.load(
1508 r#"
1509 local result = alc.state.list("testns")
1510 assert(#result == 2, "expected 2 keys, got " .. #result)
1511 assert(result[1] == "alpha", "first key should be alpha, got " .. tostring(result[1]))
1512 assert(result[2] == "beta", "second key should be beta, got " .. tostring(result[2]))
1513 "#,
1514 )
1515 .exec()
1516 .unwrap();
1517 }
1518
1519 #[test]
1520 fn show_returns_full_table() {
1521 let (lua, _store, tmp) = setup();
1522 std::fs::create_dir_all(tmp.path().join("testns")).unwrap();
1523 std::fs::write(
1524 tmp.path().join("testns/alpha.json"),
1525 r#"{"data": {"completed_steps": ["a", "b", "c"], "x": 1, "y": 2}}"#,
1526 )
1527 .unwrap();
1528 lua.load(
1529 r#"
1530 local result = alc.state.show("testns", "alpha")
1531 assert(type(result) == "table", "expected table")
1532 assert(type(result.data) == "table", "expected result.data to be a table")
1533 assert(result.data.x == 1, "expected x=1")
1534 assert(result.data.y == 2, "expected y=2")
1535 assert(#result.data.completed_steps == 3, "expected 3 steps")
1536 "#,
1537 )
1538 .exec()
1539 .unwrap();
1540 }
1541
1542 #[test]
1543 fn show_missing_returns_not_found_error() {
1544 let (lua, _store, _tmp) = setup();
1545 lua.load(
1546 r#"
1547 local ok, err = pcall(alc.state.show, "testns", "missing")
1548 assert(not ok, "expected error but got success")
1549 local msg = tostring(err)
1550 assert(string.find(msg, "not found"), "error message should contain 'not found', got: " .. msg)
1551 "#,
1552 )
1553 .exec()
1554 .unwrap();
1555 }
1556
1557 #[test]
1558 fn reset_removes_steps_and_fields_with_backup() {
1559 let (lua, _store, tmp) = setup();
1560 std::fs::create_dir_all(tmp.path().join("testns")).unwrap();
1561 let file_path = tmp.path().join("testns/alpha.json");
1562 std::fs::write(
1563 &file_path,
1564 r#"{"data": {"completed_steps": ["a", "b", "c"], "x": 1, "y": 2}}"#,
1565 )
1566 .unwrap();
1567 let tmp_path_str = tmp.path().to_string_lossy().to_string();
1569 lua.globals().set("TMP_PATH", tmp_path_str.clone()).unwrap();
1570 lua.load(
1571 r#"
1572 local r = alc.state.reset("testns", "alpha", {steps={"b"}, fields={"x"}})
1573 assert(r.ok == true, "expected ok=true")
1574 assert(type(r.backup_path) == "string", "backup_path should be a string")
1575 assert(r.steps_removed == 1, "expected steps_removed=1, got " .. tostring(r.steps_removed))
1576 assert(r.fields_removed == 1, "expected fields_removed=1, got " .. tostring(r.fields_removed))
1577 "#,
1578 )
1579 .exec()
1580 .unwrap();
1581 let bak_path = tmp.path().join("testns/alpha.json.bak");
1583 assert!(
1584 bak_path.exists(),
1585 "backup file should exist at {:?}",
1586 bak_path
1587 );
1588 let bak_content = std::fs::read_to_string(&bak_path).unwrap();
1589 assert!(
1590 bak_content.contains("\"b\""),
1591 "backup should contain original 'b' step"
1592 );
1593 let live_content = std::fs::read_to_string(&file_path).unwrap();
1595 let live: serde_json::Value = serde_json::from_str(&live_content).unwrap();
1596 let steps = live["data"]["completed_steps"].as_array().unwrap();
1597 assert!(
1598 !steps.iter().any(|s| s.as_str() == Some("b")),
1599 "step 'b' should be removed from completed_steps"
1600 );
1601 assert!(
1602 live["data"]["x"].is_null() || live["data"].get("x").is_none(),
1603 "field 'x' should be removed from data"
1604 );
1605 }
1606
1607 #[test]
1608 fn unsafe_namespace_rejected() {
1609 let (lua, _store, _tmp) = setup();
1610 lua.load(
1611 r#"
1612 local ok, err = pcall(alc.state.list, "../evil")
1613 assert(not ok, "expected error for unsafe namespace")
1614 local msg = tostring(err)
1615 assert(string.find(msg, "unsafe"), "error should contain 'unsafe', got: " .. msg)
1616 "#,
1617 )
1618 .exec()
1619 .unwrap();
1620 lua.load(
1621 r#"
1622 local ok, err = pcall(alc.state.show, "../evil", "key")
1623 assert(not ok, "expected error for unsafe namespace in show")
1624 local msg = tostring(err)
1625 assert(string.find(msg, "unsafe"), "error should contain 'unsafe', got: " .. msg)
1626 "#,
1627 )
1628 .exec()
1629 .unwrap();
1630 lua.load(
1631 r#"
1632 local ok, err = pcall(alc.state.reset, "../evil", "key", {})
1633 assert(not ok, "expected error for unsafe namespace in reset")
1634 local msg = tostring(err)
1635 assert(string.find(msg, "unsafe"), "error should contain 'unsafe', got: " .. msg)
1636 "#,
1637 )
1638 .exec()
1639 .unwrap();
1640 }
1641 }
1642}