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) -> LuaResult<()> {
366 let card_table = lua.create_table()?;
367
368 let store_create = Arc::clone(&card_store);
370 let create = lua.create_function(move |lua, input: LuaValue| {
371 let json: serde_json::Value = lua.from_value(input)?;
372 let (card_id, path) = store_create.create(json).map_err(LuaError::external)?;
373 let ret = lua.create_table()?;
374 ret.set("card_id", card_id)?;
375 ret.set("path", path.to_string_lossy().to_string())?;
376 Ok(ret)
377 })?;
378
379 let store_get = Arc::clone(&card_store);
381 let get = lua.create_function(move |lua, card_id: String| match store_get.get(&card_id) {
382 Ok(Some(v)) => to_lua_value(lua, &v),
383 Ok(None) => Ok(LuaValue::Nil),
384 Err(e) => Err(LuaError::external(e)),
385 })?;
386
387 let store_list = Arc::clone(&card_store);
389 let list = lua.create_function(move |lua, filter: Option<LuaTable>| {
390 let pkg = match filter {
391 Some(t) => t.get::<Option<String>>("pkg")?,
392 None => None,
393 };
394 let rows = store_list
395 .list(pkg.as_deref())
396 .map_err(LuaError::external)?;
397 to_lua_value(lua, &card::summaries_to_json(&rows))
398 })?;
399
400 let store_append = Arc::clone(&card_store);
402 let append = lua.create_function(move |lua, (card_id, fields): (String, LuaValue)| {
403 let json: serde_json::Value = lua.from_value(fields)?;
404 let merged = store_append
405 .append(&card_id, json)
406 .map_err(LuaError::external)?;
407 to_lua_value(lua, &merged)
408 })?;
409
410 let store_gba = Arc::clone(&card_store);
412 let get_by_alias = lua.create_function(move |lua, name: String| {
413 match store_gba.get_by_alias(&name).map_err(LuaError::external)? {
414 Some(v) => to_lua_value(lua, &v),
415 None => Ok(LuaValue::Nil),
416 }
417 })?;
418
419 let store_aset = Arc::clone(&card_store);
421 let alias_set = lua.create_function(
422 move |lua, (name, card_id, opts): (String, String, Option<LuaTable>)| {
423 let (pkg, note) = match opts {
424 Some(t) => (
425 t.get::<Option<String>>("pkg")?,
426 t.get::<Option<String>>("note")?,
427 ),
428 None => (None, None),
429 };
430 let a = store_aset
431 .alias_set(&name, &card_id, pkg.as_deref(), note.as_deref())
432 .map_err(LuaError::external)?;
433 let arr = card::aliases_to_json(&[a]);
434 let first = match arr {
435 serde_json::Value::Array(mut v) if !v.is_empty() => v.remove(0),
436 other => other,
437 };
438 to_lua_value(lua, &first)
439 },
440 )?;
441
442 let store_alist = Arc::clone(&card_store);
444 let alias_list = lua.create_function(move |lua, filter: Option<LuaTable>| {
445 let pkg = match filter {
446 Some(t) => t.get::<Option<String>>("pkg")?,
447 None => None,
448 };
449 let rows = store_alist
450 .alias_list(pkg.as_deref())
451 .map_err(LuaError::external)?;
452 to_lua_value(lua, &card::aliases_to_json(&rows))
453 })?;
454
455 let store_find = Arc::clone(&card_store);
460 let find = lua.create_function(move |lua, query: Option<LuaTable>| {
461 let q = match query {
462 Some(t) => {
463 let pkg = t.get::<Option<String>>("pkg")?;
464 let limit = t.get::<Option<usize>>("limit")?;
465 let offset = t.get::<Option<usize>>("offset")?;
466
467 let where_parsed = match t.get::<LuaValue>("where")? {
468 LuaValue::Nil => None,
469 v => {
470 let json: serde_json::Value = lua.from_value(v)?;
471 Some(card::parse_where(&json).map_err(LuaError::external)?)
472 }
473 };
474 let order_parsed = match t.get::<LuaValue>("order_by")? {
475 LuaValue::Nil => Vec::new(),
476 v => {
477 let json: serde_json::Value = lua.from_value(v)?;
478 card::parse_order_by(&json).map_err(LuaError::external)?
479 }
480 };
481
482 card::FindQuery {
483 pkg,
484 where_: where_parsed,
485 order_by: order_parsed,
486 limit,
487 offset,
488 }
489 }
490 None => card::FindQuery::default(),
491 };
492 let rows = store_find.find(q).map_err(LuaError::external)?;
493 to_lua_value(lua, &card::summaries_to_json(&rows))
494 })?;
495
496 let store_ws = Arc::clone(&card_store);
498 let write_samples =
499 lua.create_function(move |lua, (card_id, samples): (String, LuaValue)| {
500 let json: serde_json::Value = lua.from_value(samples)?;
501 let arr = match json {
502 serde_json::Value::Array(a) => a,
503 _ => {
504 return Err(LuaError::external(
505 "alc.card.write_samples: samples must be an array",
506 ))
507 }
508 };
509 let count = arr.len();
510 let path = store_ws
511 .write_samples(&card_id, arr)
512 .map_err(LuaError::external)?;
513 let ret = lua.create_table()?;
514 ret.set("path", path.to_string_lossy().to_string())?;
515 ret.set("count", count)?;
516 Ok(ret)
517 })?;
518
519 let store_rs = Arc::clone(&card_store);
524 let read_samples =
525 lua.create_function(move |lua, (card_id, opts): (String, Option<LuaTable>)| {
526 let (offset, limit, where_parsed) = match opts {
527 Some(t) => {
528 let offset = t.get::<Option<usize>>("offset")?.unwrap_or(0);
529 let limit = t.get::<Option<usize>>("limit")?;
530 let where_parsed = match t.get::<LuaValue>("where")? {
531 LuaValue::Nil => None,
532 v => {
533 let json: serde_json::Value = lua.from_value(v)?;
534 Some(card::parse_where(&json).map_err(LuaError::external)?)
535 }
536 };
537 (offset, limit, where_parsed)
538 }
539 None => (0, None, None),
540 };
541 let q = card::SamplesQuery {
542 offset,
543 limit,
544 where_: where_parsed,
545 };
546 let rows = store_rs
547 .read_samples(&card_id, q)
548 .map_err(LuaError::external)?;
549 to_lua_value(lua, &serde_json::Value::Array(rows))
550 })?;
551
552 let store_sb = Arc::clone(&card_store);
557 let sink_backfill = lua.create_function(move |lua, params: LuaTable| {
558 let sink: String = params.get("sink")?;
559 let dry_run: Option<bool> = params.get("dry_run")?;
560 let report = store_sb
561 .card_sink_backfill(&sink, dry_run.unwrap_or(false))
562 .map_err(LuaError::external)?;
563 to_lua_value(lua, &report)
564 })?;
565
566 let store_lin = Arc::clone(&card_store);
571 let lineage = lua.create_function(move |lua, query: LuaTable| {
572 let card_id: String = query.get("card_id")?;
573 let direction_str: Option<String> = query.get("direction")?;
574 let direction = match direction_str.as_deref() {
575 Some(s) => card::LineageDirection::parse(s).map_err(LuaError::external)?,
576 None => card::LineageDirection::Up,
577 };
578 let depth: Option<usize> = query.get("depth")?;
579 let include_stats: Option<bool> = query.get("include_stats")?;
580 let relation_filter: Option<Vec<String>> = match query.get::<LuaValue>("relation_filter")? {
581 LuaValue::Nil => None,
582 v => Some(lua.from_value(v)?),
583 };
584
585 let q = card::LineageQuery {
586 card_id,
587 direction,
588 depth,
589 include_stats: include_stats.unwrap_or(true),
590 relation_filter,
591 };
592 match store_lin.lineage(q).map_err(LuaError::external)? {
593 Some(res) => to_lua_value(lua, &card::lineage_to_json(&res)),
594 None => Ok(LuaValue::Nil),
595 }
596 })?;
597
598 card_table.set("create", create)?;
599 card_table.set("get", get)?;
600 card_table.set("list", list)?;
601 card_table.set("append", append)?;
602 card_table.set("get_by_alias", get_by_alias)?;
603 card_table.set("alias_set", alias_set)?;
604 card_table.set("alias_list", alias_list)?;
605 card_table.set("find", find)?;
606 card_table.set("write_samples", write_samples)?;
607 card_table.set("read_samples", read_samples)?;
608 card_table.set("lineage", lineage)?;
609 card_table.set("sink_backfill", sink_backfill)?;
610
611 alc_table.set("card", card_table)?;
612 Ok(())
613}
614
615pub(super) fn register_stats(
628 lua: &Lua,
629 alc_table: &LuaTable,
630 custom_metrics: CustomMetricsHandle,
631 stats: StatsHandle,
632) -> LuaResult<()> {
633 let stats_table = lua.create_table()?;
634
635 let cm_record = custom_metrics.clone();
637 let record = lua.create_function(move |lua, (key, value): (String, LuaValue)| {
638 let json: serde_json::Value = lua.from_value(value)?;
639 cm_record.record(key, json);
640 Ok(())
641 })?;
642
643 let cm_get = custom_metrics;
645 let get = lua.create_function(move |lua, key: String| match cm_get.get(&key) {
646 Some(v) => to_lua_value(lua, &v),
647 None => Ok(LuaValue::Nil),
648 })?;
649
650 let stats_handle = stats;
652 let llm_calls = lua.create_function(move |_, ()| Ok(stats_handle.llm_calls()))?;
653
654 stats_table.set("record", record)?;
655 stats_table.set("get", get)?;
656 stats_table.set("llm_calls", llm_calls)?;
657
658 alc_table.set("stats", stats_table)?;
659 Ok(())
660}
661
662pub struct AlcEnv(pub Arc<HashMap<String, String>>);
673
674impl mlua::UserData for AlcEnv {
675 fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M) {
676 methods.add_meta_method(mlua::MetaMethod::Index, |_, this, key: String| {
678 Ok(this.0.get(&key).cloned())
679 });
680
681 methods.add_meta_method(
684 mlua::MetaMethod::NewIndex,
685 |_, _, (_k, _v): (mlua::Value, mlua::Value)| {
686 Err::<(), _>(mlua::Error::external("alc.env is readonly"))
687 },
688 );
689
690 methods.add_method(
692 "get",
693 |_, this, (key, default): (String, Option<String>)| {
694 Ok(this.0.get(&key).cloned().or(default))
695 },
696 );
697
698 methods.add_method("use", |lua, this, declared: Vec<String>| {
702 let proxy = lua.create_table()?;
703 for k in &declared {
704 if let Some(v) = this.0.get(k) {
705 proxy.set(k.clone(), v.clone())?;
706 }
707 }
708 Ok(proxy)
709 });
710 }
711}
712
713pub fn register_env(
721 lua: &mlua::Lua,
722 alc_table: &mlua::Table,
723 env_map: Arc<HashMap<String, String>>,
724) -> mlua::Result<()> {
725 alc_table.set("env", AlcEnv(Arc::clone(&env_map)))?;
726 lua.set_app_data(env_map);
727 Ok(())
728}
729
730#[cfg(test)]
731mod tests {
732 use super::*;
733 use algocline_core::ExecutionMetrics;
734
735 fn test_config_with(ns: &str) -> crate::bridge::BridgeConfig {
739 let metrics = ExecutionMetrics::new();
740 let tmp = tempfile::tempdir().expect("test tempdir");
741 let root = tmp.path().to_path_buf();
742 std::mem::forget(tmp);
743 crate::bridge::BridgeConfig {
744 llm_tx: None,
745 ns: ns.into(),
746 custom_metrics: metrics.custom_metrics_handle(),
747 stats: metrics.stats_handle(),
748 budget: metrics.budget_handle(),
749 progress: metrics.progress_handle(),
750 lib_paths: vec![],
751 variant_pkgs: vec![],
752 state_store: Arc::new(JsonFileStore::new(root.join("state"))),
753 card_store: Arc::new(FileCardStore::new(root.join("cards"))),
754 scenarios_dir: root.join("scenarios"),
755 log_sink: None,
756 }
757 }
758
759 fn test_config() -> crate::bridge::BridgeConfig {
760 test_config_with("default")
761 }
762
763 fn test_config_with_ns(ns: &str) -> crate::bridge::BridgeConfig {
764 test_config_with(ns)
765 }
766
767 #[test]
768 fn json_roundtrip() {
769 let lua = Lua::new();
770 let t = lua.create_table().unwrap();
771 crate::bridge::register(&lua, &t, test_config()).unwrap();
772 lua.globals().set("alc", t).unwrap();
773
774 let result: String = lua
775 .load(r#"return alc.json_encode({hello = "world", n = 42})"#)
776 .eval()
777 .unwrap();
778 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
779 assert_eq!(parsed["hello"], "world");
780 assert_eq!(parsed["n"], 42);
781 }
782
783 #[test]
784 fn json_decode_encode() {
785 let lua = Lua::new();
786 let t = lua.create_table().unwrap();
787 crate::bridge::register(&lua, &t, test_config()).unwrap();
788 lua.globals().set("alc", t).unwrap();
789
790 let result: String = lua
791 .load(
792 r#"
793 local val = alc.json_decode('{"a":1,"b":"two"}')
794 val.c = true
795 return alc.json_encode(val)
796 "#,
797 )
798 .eval()
799 .unwrap();
800 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
801 assert_eq!(parsed["a"], 1);
802 assert_eq!(parsed["b"], "two");
803 assert_eq!(parsed["c"], true);
804 }
805
806 #[test]
811 fn json_decode_null_yields_lua_nil() {
812 let lua = Lua::new();
813 let t = lua.create_table().unwrap();
814 crate::bridge::register(&lua, &t, test_config()).unwrap();
815 lua.globals().set("alc", t).unwrap();
816
817 let top_level_truthy: bool = lua
819 .load(r#"local v = alc.json_decode("null"); return v ~= nil"#)
820 .eval()
821 .unwrap();
822 assert!(
823 !top_level_truthy,
824 "alc.json_decode(\"null\") should return Lua nil"
825 );
826
827 let field_truthy: bool = lua
829 .load(
830 r#"
831 local obj = alc.json_decode('{"x": null, "y": 1}')
832 return obj.x ~= nil
833 "#,
834 )
835 .eval()
836 .unwrap();
837 assert!(
838 !field_truthy,
839 "Object field decoded from JSON null should be Lua nil"
840 );
841
842 let field_type: String = lua
844 .load(r#"return type(alc.json_decode('{"x": null}').x)"#)
845 .eval()
846 .unwrap();
847 assert_eq!(
848 field_type, "nil",
849 "type() of null-decoded field must be 'nil', not 'userdata'"
850 );
851
852 let arr_len: i64 = lua
855 .load(r#"return #alc.json_decode('[1, null, 3]')"#)
856 .eval()
857 .unwrap();
858 assert_eq!(
865 arr_len, 3,
866 "JSON array length is preserved across null elements (mlua/Lua 5.4 array part)"
867 );
868
869 let (a, b, c): (Option<i64>, Option<i64>, Option<i64>) = lua
872 .load(
873 r#"
874 local arr = alc.json_decode('[1, null, 3]')
875 return arr[1], arr[2], arr[3]
876 "#,
877 )
878 .eval()
879 .unwrap();
880 assert_eq!(a, Some(1));
881 assert_eq!(b, None, "Array element decoded from JSON null must be nil");
882 assert_eq!(c, Some(3));
883 }
884
885 #[test]
886 fn state_get_set() {
887 let ns = "_test_bridge_state";
890
891 let lua = Lua::new();
892 let t = lua.create_table().unwrap();
893 crate::bridge::register(&lua, &t, test_config_with_ns(ns)).unwrap();
894 lua.globals().set("alc", t).unwrap();
895
896 lua.load(r#"alc.state.set("x", 99)"#).exec().unwrap();
898 let result: i64 = lua.load(r#"return alc.state.get("x")"#).eval().unwrap();
899 assert_eq!(result, 99);
900
901 let result: i64 = lua
903 .load(r#"return alc.state.get("missing", 0)"#)
904 .eval()
905 .unwrap();
906 assert_eq!(result, 0);
907
908 let result: LuaValue = lua
910 .load(r#"return alc.state.get("missing")"#)
911 .eval()
912 .unwrap();
913 assert!(result.is_nil());
914 }
915
916 #[test]
924 fn state_get_object_with_null_field_yields_lua_nil() {
925 let ns = "_test_bridge_state_null_field";
926
927 let lua = Lua::new();
928 let t = lua.create_table().unwrap();
929 crate::bridge::register(&lua, &t, test_config_with_ns(ns)).unwrap();
930 lua.globals().set("alc", t).unwrap();
931
932 lua.load(r#"alc.state.set("obj", alc.json_decode('{"x": null, "y": 1}'))"#)
934 .exec()
935 .unwrap();
936
937 let x_truthy: bool = lua
939 .load(r#"local v = alc.state.get("obj"); return v.x ~= nil"#)
940 .eval()
941 .unwrap();
942 assert!(
943 !x_truthy,
944 "state.get returned object's null field must be Lua nil (truthy check must skip)"
945 );
946
947 let x_type: String = lua
949 .load(r#"local v = alc.state.get("obj"); return type(v.x)"#)
950 .eval()
951 .unwrap();
952 assert_eq!(
953 x_type, "nil",
954 "type() of state.get'd null field must be 'nil', not 'userdata'"
955 );
956
957 let y: i64 = lua.load(r#"return alc.state.get("obj").y"#).eval().unwrap();
959 assert_eq!(y, 1, "Non-null sibling field must round-trip unchanged");
960 }
961
962 #[test]
963 fn state_has_set_nx_incr() {
964 let ns = "_test_bridge_state_t1";
965
966 let lua = Lua::new();
967 let t = lua.create_table().unwrap();
968 crate::bridge::register(&lua, &t, test_config_with_ns(ns)).unwrap();
969 lua.globals().set("alc", t).unwrap();
970
971 let h: bool = lua.load(r#"return alc.state.has("k")"#).eval().unwrap();
973 assert!(!h);
974
975 let ok: bool = lua
977 .load(r#"return alc.state.set_nx("k", "first")"#)
978 .eval()
979 .unwrap();
980 assert!(ok);
981
982 let h: bool = lua.load(r#"return alc.state.has("k")"#).eval().unwrap();
984 assert!(h);
985
986 let ok: bool = lua
988 .load(r#"return alc.state.set_nx("k", "second")"#)
989 .eval()
990 .unwrap();
991 assert!(!ok);
992
993 let v: f64 = lua
995 .load(r#"return alc.state.incr("counter")"#)
996 .eval()
997 .unwrap();
998 assert!((v - 1.0).abs() < f64::EPSILON);
999
1000 let v: f64 = lua
1002 .load(r#"return alc.state.incr("counter", 5)"#)
1003 .eval()
1004 .unwrap();
1005 assert!((v - 6.0).abs() < f64::EPSILON);
1006
1007 let v: f64 = lua
1009 .load(r#"return alc.state.incr("counter", 10, 100)"#)
1010 .eval()
1011 .unwrap();
1012 assert!((v - 16.0).abs() < f64::EPSILON);
1013 }
1014
1015 #[test]
1016 fn card_create_get_list_from_lua() {
1017 let ns = std::time::SystemTime::now()
1019 .duration_since(std::time::UNIX_EPOCH)
1020 .unwrap()
1021 .as_nanos();
1022 let pkg = format!("_test_bridge_card_{ns}");
1023
1024 let lua = Lua::new();
1025 let t = lua.create_table().unwrap();
1026 crate::bridge::register(&lua, &t, test_config()).unwrap();
1027 lua.globals().set("alc", t).unwrap();
1028
1029 let create_script = format!(
1031 r#"
1032 local r = alc.card.create({{
1033 pkg = {{ name = "{pkg}" }},
1034 model = {{ id = "claude-opus-4-6" }},
1035 stats = {{ pass_rate = 0.9 }},
1036 }})
1037 return r.card_id
1038 "#
1039 );
1040 let card_id: String = lua.load(&create_script).eval().unwrap();
1041 assert!(card_id.starts_with(&pkg));
1042
1043 let get_script = format!(r#"return alc.card.get("{card_id}").stats.pass_rate"#);
1045 let rate: f64 = lua.load(&get_script).eval().unwrap();
1046 assert!((rate - 0.9).abs() < 1e-9);
1047
1048 let list_script = format!(
1050 r#"
1051 local rows = alc.card.list({{ pkg = "{pkg}" }})
1052 return #rows
1053 "#
1054 );
1055 let count: i64 = lua.load(&list_script).eval().unwrap();
1056 assert_eq!(count, 1);
1057
1058 }
1060
1061 #[test]
1062 fn stats_record_get() {
1063 let metrics = ExecutionMetrics::new();
1064 let custom_handle = metrics.custom_metrics_handle();
1065 let lua = Lua::new();
1066 let t = lua.create_table().unwrap();
1067 let tmp = tempfile::tempdir().expect("test tempdir");
1068 let root = tmp.path().to_path_buf();
1069 std::mem::forget(tmp);
1070 crate::bridge::register(
1071 &lua,
1072 &t,
1073 crate::bridge::BridgeConfig {
1074 llm_tx: None,
1075 ns: "default".into(),
1076 custom_metrics: custom_handle.clone(),
1077 stats: metrics.stats_handle(),
1078 budget: metrics.budget_handle(),
1079 progress: metrics.progress_handle(),
1080 lib_paths: vec![],
1081 variant_pkgs: vec![],
1082 state_store: Arc::new(JsonFileStore::new(root.join("state"))),
1083 card_store: Arc::new(FileCardStore::new(root.join("cards"))),
1084 scenarios_dir: root.join("scenarios"),
1085 log_sink: None,
1086 },
1087 )
1088 .unwrap();
1089 lua.globals().set("alc", t).unwrap();
1090
1091 lua.load(r#"alc.stats.record("score", 42)"#).exec().unwrap();
1093 let result: i64 = lua.load(r#"return alc.stats.get("score")"#).eval().unwrap();
1094 assert_eq!(result, 42);
1095
1096 assert_eq!(custom_handle.get("score"), Some(serde_json::json!(42)));
1098
1099 let result: LuaValue = lua
1101 .load(r#"return alc.stats.get("missing")"#)
1102 .eval()
1103 .unwrap();
1104 assert!(result.is_nil());
1105 }
1106
1107 #[test]
1112 fn stats_llm_calls_reads_session_status() {
1113 use crate::card::FileCardStore;
1114 use crate::state::JsonFileStore;
1115 use algocline_core::{ExecutionObserver, LlmQuery, QueryId};
1116 use std::sync::Arc;
1117
1118 let metrics = ExecutionMetrics::new();
1119 let observer = metrics.create_observer();
1120
1121 let lua = Lua::new();
1122 let t = lua.create_table().unwrap();
1123 let tmp = tempfile::tempdir().expect("test tempdir");
1124 let root = tmp.path().to_path_buf();
1125 std::mem::forget(tmp);
1126 crate::bridge::register(
1127 &lua,
1128 &t,
1129 crate::bridge::BridgeConfig {
1130 llm_tx: None,
1131 ns: "default".into(),
1132 custom_metrics: metrics.custom_metrics_handle(),
1133 stats: metrics.stats_handle(),
1134 budget: metrics.budget_handle(),
1135 progress: metrics.progress_handle(),
1136 lib_paths: vec![],
1137 variant_pkgs: vec![],
1138 state_store: Arc::new(JsonFileStore::new(root.join("state"))),
1139 card_store: Arc::new(FileCardStore::new(root.join("cards"))),
1140 scenarios_dir: root.join("scenarios"),
1141 log_sink: None,
1142 },
1143 )
1144 .unwrap();
1145 lua.globals().set("alc", t).unwrap();
1146
1147 let initial: u64 = lua.load(r#"return alc.stats.llm_calls()"#).eval().unwrap();
1149 assert_eq!(initial, 0, "fresh session must report llm_calls() == 0");
1150
1151 observer.on_paused(&[LlmQuery {
1153 id: QueryId::parse("q-0"),
1154 prompt: "hi".to_string(),
1155 system: None,
1156 max_tokens: 0,
1157 grounded: false,
1158 underspecified: false,
1159 }]);
1160
1161 let after_one: u64 = lua.load(r#"return alc.stats.llm_calls()"#).eval().unwrap();
1163 assert_eq!(
1164 after_one, 1,
1165 "one paused query must increment llm_calls() to 1"
1166 );
1167
1168 observer.on_paused(&[
1170 LlmQuery {
1171 id: QueryId::parse("q-1"),
1172 prompt: "a".to_string(),
1173 system: None,
1174 max_tokens: 0,
1175 grounded: false,
1176 underspecified: false,
1177 },
1178 LlmQuery {
1179 id: QueryId::parse("q-2"),
1180 prompt: "b".to_string(),
1181 system: None,
1182 max_tokens: 0,
1183 grounded: false,
1184 underspecified: false,
1185 },
1186 ]);
1187
1188 let after_three: u64 = lua.load(r#"return alc.stats.llm_calls()"#).eval().unwrap();
1189 assert_eq!(
1190 after_three, 3,
1191 "two further paused queries (multi-query batch) must bring llm_calls() to 3"
1192 );
1193 }
1194
1195 #[test]
1199 fn register_log_pushes_to_log_sink() {
1200 use algocline_core::LogSink;
1201
1202 let sink = LogSink::new();
1203 let lua = Lua::new();
1204 let t = lua.create_table().unwrap();
1205 register_log(&lua, &t, sink.clone()).unwrap();
1207 lua.globals().set("alc", t).unwrap();
1208
1209 lua.load(r#"alc.log("info", "hello-from-log")"#)
1210 .exec()
1211 .unwrap();
1213
1214 let entries = sink.entries();
1215 assert_eq!(entries.len(), 1);
1216 assert_eq!(entries[0].source, "alc.log");
1217 assert_eq!(entries[0].level, "info");
1218 assert_eq!(entries[0].message, "hello-from-log");
1219 }
1220
1221 #[test]
1223 fn register_log_unknown_level_still_pushes() {
1224 use algocline_core::LogSink;
1225
1226 let sink = LogSink::new();
1227 let lua = Lua::new();
1228 let t = lua.create_table().unwrap();
1229 register_log(&lua, &t, sink.clone()).unwrap();
1231 lua.globals().set("alc", t).unwrap();
1232
1233 lua.load(r#"alc.log("custom", "edge-case")"#)
1234 .exec()
1235 .unwrap();
1237
1238 let entries = sink.entries();
1239 assert_eq!(entries.len(), 1);
1240 assert_eq!(entries[0].source, "alc.log");
1241 assert_eq!(entries[0].level, "custom");
1243 assert_eq!(entries[0].message, "edge-case");
1244 }
1245
1246 #[test]
1248 fn register_log_empty_message() {
1249 use algocline_core::LogSink;
1250
1251 let sink = LogSink::new();
1252 let lua = Lua::new();
1253 let t = lua.create_table().unwrap();
1254 register_log(&lua, &t, sink.clone()).unwrap();
1256 lua.globals().set("alc", t).unwrap();
1257
1258 lua.load(r#"alc.log("warn", "")"#)
1259 .exec()
1260 .unwrap();
1262
1263 let entries = sink.entries();
1264 assert_eq!(entries.len(), 1);
1265 assert_eq!(entries[0].message, "");
1266 }
1267
1268 #[test]
1272 fn register_print_pushes_to_log_sink() {
1273 use algocline_core::LogSink;
1274
1275 let sink = LogSink::new();
1276 let lua = Lua::new();
1277 register_print(&lua, sink.clone()).unwrap();
1279
1280 lua.load(r#"print("hello-print")"#)
1281 .exec()
1282 .unwrap();
1284
1285 let entries = sink.entries();
1286 assert_eq!(entries.len(), 1);
1287 assert_eq!(entries[0].source, "alc.lua.print");
1288 assert_eq!(entries[0].level, "info");
1289 assert_eq!(entries[0].message, "hello-print");
1290 }
1291
1292 #[test]
1294 fn register_print_multiple_args_tab_joined() {
1295 use algocline_core::LogSink;
1296
1297 let sink = LogSink::new();
1298 let lua = Lua::new();
1299 register_print(&lua, sink.clone()).unwrap();
1301
1302 lua.load(r#"print("a", "b", "c")"#)
1303 .exec()
1304 .unwrap();
1306
1307 let entries = sink.entries();
1308 assert_eq!(entries.len(), 1);
1309 assert_eq!(entries[0].message, "a\tb\tc");
1310 }
1311
1312 #[test]
1314 fn register_print_mixed_value_types() {
1315 use algocline_core::LogSink;
1316
1317 let sink = LogSink::new();
1318 let lua = Lua::new();
1319 register_print(&lua, sink.clone()).unwrap();
1321
1322 lua.load(r#"print(nil, true, 42, 3.14)"#)
1323 .exec()
1324 .unwrap();
1326
1327 let entries = sink.entries();
1328 assert_eq!(entries.len(), 1);
1329 let msg = &entries[0].message;
1331 assert!(msg.starts_with("nil\ttrue\t42\t"), "got: {msg}");
1332 }
1333
1334 fn make_env_lua(pairs: &[(&str, &str)]) -> (Lua, Arc<HashMap<String, String>>) {
1337 let mut map = HashMap::new();
1338 for (k, v) in pairs {
1339 map.insert(k.to_string(), v.to_string());
1340 }
1341 let env_map = Arc::new(map);
1342 let lua = Lua::new();
1343 let alc_table = lua.create_table().unwrap();
1344 register_env(&lua, &alc_table, Arc::clone(&env_map)).unwrap();
1345 lua.globals().set("alc", alc_table).unwrap();
1346 (lua, env_map)
1347 }
1348
1349 #[test]
1350 fn env_index_reads_existing_key() {
1351 let (lua, _) = make_env_lua(&[("FOO", "bar")]);
1352 let val: Option<String> = lua.load(r#"return alc.env.FOO"#).eval().unwrap();
1353 assert_eq!(val, Some("bar".to_string()));
1354 }
1355
1356 #[test]
1357 fn env_index_missing_key_returns_nil() {
1358 let (lua, _) = make_env_lua(&[("FOO", "bar")]);
1359 let val: LuaValue = lua.load(r#"return alc.env.MISSING"#).eval().unwrap();
1360 assert!(val.is_nil());
1361 }
1362
1363 #[test]
1364 fn env_newindex_returns_error() {
1365 let (lua, _) = make_env_lua(&[("FOO", "bar")]);
1366 let result: Result<(), _> = lua.load(r#"alc.env.FOO = "x""#).exec();
1367 let err = result.unwrap_err().to_string();
1368 assert!(
1369 err.contains("alc.env is readonly"),
1370 "expected readonly error, got: {err}"
1371 );
1372 }
1373
1374 #[test]
1375 fn env_get_with_default_returns_default_on_miss() {
1376 let (lua, _) = make_env_lua(&[]);
1377 let val: Option<String> = lua
1378 .load(r#"return alc.env:get("MISSING", "fallback")"#)
1379 .eval()
1380 .unwrap();
1381 assert_eq!(val, Some("fallback".to_string()));
1382 }
1383
1384 #[test]
1385 fn env_get_returns_value_when_present() {
1386 let (lua, _) = make_env_lua(&[("KEY", "val")]);
1387 let val: Option<String> = lua
1388 .load(r#"return alc.env:get("KEY", "default")"#)
1389 .eval()
1390 .unwrap();
1391 assert_eq!(val, Some("val".to_string()));
1392 }
1393
1394 #[test]
1395 fn env_use_returns_declared_keys_only() {
1396 let (lua, _) = make_env_lua(&[("FOO", "foo_val"), ("BAR", "bar_val"), ("SECRET", "s")]);
1397 let result: LuaValue = lua
1398 .load(
1399 r#"
1400 local e = alc.env:use{"FOO", "BAR"}
1401 return e
1402 "#,
1403 )
1404 .eval()
1405 .unwrap();
1406 let tbl = result.as_table().unwrap();
1407 assert_eq!(tbl.get::<String>("FOO").unwrap(), "foo_val");
1408 assert_eq!(tbl.get::<String>("BAR").unwrap(), "bar_val");
1409 let secret: LuaValue = tbl.get("SECRET").unwrap();
1411 assert!(secret.is_nil(), "SECRET should be nil in proxy");
1412 }
1413
1414 #[test]
1415 fn env_use_undeclared_key_is_nil() {
1416 let (lua, _) = make_env_lua(&[("FOO", "foo_val")]);
1417 let val: LuaValue = lua
1418 .load(
1419 r#"
1420 local e = alc.env:use{"FOO"}
1421 return e.UNDECLARED
1422 "#,
1423 )
1424 .eval()
1425 .unwrap();
1426 assert!(val.is_nil());
1427 }
1428
1429 #[test]
1430 fn register_env_sets_app_data() {
1431 let mut map = HashMap::new();
1432 map.insert("X".to_string(), "1".to_string());
1433 let env_map = Arc::new(map);
1434 let lua = Lua::new();
1435 let alc_table = lua.create_table().unwrap();
1436 register_env(&lua, &alc_table, Arc::clone(&env_map)).unwrap();
1437 let retrieved = lua.app_data_ref::<Arc<HashMap<String, String>>>().unwrap();
1439 assert_eq!(retrieved.get("X").unwrap(), "1");
1440 }
1441
1442 mod state_dispatched_lua {
1443 use super::*;
1444 use mlua::Lua;
1445 use std::sync::Arc;
1446 use tempfile::TempDir;
1447
1448 fn setup() -> (Lua, Arc<JsonFileStore>, TempDir) {
1449 let tmp = tempfile::tempdir().unwrap();
1450 let store = Arc::new(JsonFileStore::new(tmp.path().to_path_buf()));
1451 let lua = Lua::new();
1452 let alc = lua.create_table().unwrap();
1453 register_state(&lua, &alc, "default".to_string(), Arc::clone(&store)).unwrap();
1454 lua.globals().set("alc", alc).unwrap();
1455 (lua, store, tmp)
1456 }
1457
1458 #[test]
1459 fn list_returns_sorted_keys() {
1460 let (lua, _store, tmp) = setup();
1461 std::fs::create_dir_all(tmp.path().join("testns")).unwrap();
1463 std::fs::write(
1464 tmp.path().join("testns/beta.json"),
1465 r#"{"data": {"completed_steps": [], "x": 1}}"#,
1466 )
1467 .unwrap();
1468 std::fs::write(
1469 tmp.path().join("testns/alpha.json"),
1470 r#"{"data": {"completed_steps": [], "y": 2}}"#,
1471 )
1472 .unwrap();
1473 lua.load(
1474 r#"
1475 local result = alc.state.list("testns")
1476 assert(#result == 2, "expected 2 keys, got " .. #result)
1477 assert(result[1] == "alpha", "first key should be alpha, got " .. tostring(result[1]))
1478 assert(result[2] == "beta", "second key should be beta, got " .. tostring(result[2]))
1479 "#,
1480 )
1481 .exec()
1482 .unwrap();
1483 }
1484
1485 #[test]
1486 fn show_returns_full_table() {
1487 let (lua, _store, tmp) = setup();
1488 std::fs::create_dir_all(tmp.path().join("testns")).unwrap();
1489 std::fs::write(
1490 tmp.path().join("testns/alpha.json"),
1491 r#"{"data": {"completed_steps": ["a", "b", "c"], "x": 1, "y": 2}}"#,
1492 )
1493 .unwrap();
1494 lua.load(
1495 r#"
1496 local result = alc.state.show("testns", "alpha")
1497 assert(type(result) == "table", "expected table")
1498 assert(type(result.data) == "table", "expected result.data to be a table")
1499 assert(result.data.x == 1, "expected x=1")
1500 assert(result.data.y == 2, "expected y=2")
1501 assert(#result.data.completed_steps == 3, "expected 3 steps")
1502 "#,
1503 )
1504 .exec()
1505 .unwrap();
1506 }
1507
1508 #[test]
1509 fn show_missing_returns_not_found_error() {
1510 let (lua, _store, _tmp) = setup();
1511 lua.load(
1512 r#"
1513 local ok, err = pcall(alc.state.show, "testns", "missing")
1514 assert(not ok, "expected error but got success")
1515 local msg = tostring(err)
1516 assert(string.find(msg, "not found"), "error message should contain 'not found', got: " .. msg)
1517 "#,
1518 )
1519 .exec()
1520 .unwrap();
1521 }
1522
1523 #[test]
1524 fn reset_removes_steps_and_fields_with_backup() {
1525 let (lua, _store, tmp) = setup();
1526 std::fs::create_dir_all(tmp.path().join("testns")).unwrap();
1527 let file_path = tmp.path().join("testns/alpha.json");
1528 std::fs::write(
1529 &file_path,
1530 r#"{"data": {"completed_steps": ["a", "b", "c"], "x": 1, "y": 2}}"#,
1531 )
1532 .unwrap();
1533 let tmp_path_str = tmp.path().to_string_lossy().to_string();
1535 lua.globals().set("TMP_PATH", tmp_path_str.clone()).unwrap();
1536 lua.load(
1537 r#"
1538 local r = alc.state.reset("testns", "alpha", {steps={"b"}, fields={"x"}})
1539 assert(r.ok == true, "expected ok=true")
1540 assert(type(r.backup_path) == "string", "backup_path should be a string")
1541 assert(r.steps_removed == 1, "expected steps_removed=1, got " .. tostring(r.steps_removed))
1542 assert(r.fields_removed == 1, "expected fields_removed=1, got " .. tostring(r.fields_removed))
1543 "#,
1544 )
1545 .exec()
1546 .unwrap();
1547 let bak_path = tmp.path().join("testns/alpha.json.bak");
1549 assert!(
1550 bak_path.exists(),
1551 "backup file should exist at {:?}",
1552 bak_path
1553 );
1554 let bak_content = std::fs::read_to_string(&bak_path).unwrap();
1555 assert!(
1556 bak_content.contains("\"b\""),
1557 "backup should contain original 'b' step"
1558 );
1559 let live_content = std::fs::read_to_string(&file_path).unwrap();
1561 let live: serde_json::Value = serde_json::from_str(&live_content).unwrap();
1562 let steps = live["data"]["completed_steps"].as_array().unwrap();
1563 assert!(
1564 !steps.iter().any(|s| s.as_str() == Some("b")),
1565 "step 'b' should be removed from completed_steps"
1566 );
1567 assert!(
1568 live["data"]["x"].is_null() || live["data"].get("x").is_none(),
1569 "field 'x' should be removed from data"
1570 );
1571 }
1572
1573 #[test]
1574 fn unsafe_namespace_rejected() {
1575 let (lua, _store, _tmp) = setup();
1576 lua.load(
1577 r#"
1578 local ok, err = pcall(alc.state.list, "../evil")
1579 assert(not ok, "expected error for unsafe namespace")
1580 local msg = tostring(err)
1581 assert(string.find(msg, "unsafe"), "error should contain 'unsafe', got: " .. msg)
1582 "#,
1583 )
1584 .exec()
1585 .unwrap();
1586 lua.load(
1587 r#"
1588 local ok, err = pcall(alc.state.show, "../evil", "key")
1589 assert(not ok, "expected error for unsafe namespace in show")
1590 local msg = tostring(err)
1591 assert(string.find(msg, "unsafe"), "error should contain 'unsafe', got: " .. msg)
1592 "#,
1593 )
1594 .exec()
1595 .unwrap();
1596 lua.load(
1597 r#"
1598 local ok, err = pcall(alc.state.reset, "../evil", "key", {})
1599 assert(not ok, "expected error for unsafe namespace in reset")
1600 local msg = tostring(err)
1601 assert(string.find(msg, "unsafe"), "error should contain 'unsafe', got: " .. msg)
1602 "#,
1603 )
1604 .exec()
1605 .unwrap();
1606 }
1607 }
1608}