1use std::collections::HashMap;
2use std::path::Path;
3use std::sync::Arc;
4
5use algocline_core::{CustomMetricsHandle, LogEntry, LogSink, StatsHandle};
6use mlua::prelude::*;
7use mlua::{LuaSerdeExt, SerializeOptions};
8
9use crate::card::{self, FileCardStore};
10use crate::state::{JsonFileStore, StateStore};
11
12pub(super) fn register_json(lua: &Lua, alc_table: &LuaTable) -> LuaResult<()> {
13 let encode = lua.create_function(|lua, value: LuaValue| {
14 let json: serde_json::Value = lua.from_value(value)?;
15 serde_json::to_string(&json).map_err(LuaError::external)
16 })?;
17
18 let decode_options = SerializeOptions::new()
24 .serialize_none_to_null(false)
25 .serialize_unit_to_null(false);
26 let decode = lua.create_function(move |lua, s: String| {
27 let value: serde_json::Value = serde_json::from_str(&s).map_err(LuaError::external)?;
28 lua.to_value_with(&value, decode_options)
29 })?;
30
31 alc_table.set("json_encode", encode)?;
32 alc_table.set("json_decode", decode)?;
33 Ok(())
34}
35
36pub(super) fn register_log(lua: &Lua, alc_table: &LuaTable, log_sink: LogSink) -> LuaResult<()> {
50 let log = lua.create_function(move |_, (level, msg): (String, String)| {
51 match level.as_str() {
53 "error" => tracing::error!(target: "alc.log", "{}", msg),
54 "warn" => tracing::warn!(target: "alc.log", "{}", msg),
55 "info" => tracing::info!(target: "alc.log", "{}", msg),
56 "debug" => tracing::debug!(target: "alc.log", "{}", msg),
57 _ => tracing::info!(target: "alc.log", "{}", msg),
58 }
59 log_sink.push(LogEntry::new(level.clone(), "alc.log", msg));
61 Ok(())
62 })?;
63
64 alc_table.set("log", log)?;
65 Ok(())
66}
67
68pub(super) fn register_print(lua: &Lua, log_sink: LogSink) -> LuaResult<()> {
88 let print_fn = lua.create_function(move |lua_inner, args: mlua::MultiValue| {
89 let parts: Vec<String> = args
90 .iter()
91 .map(|v| match v {
92 LuaValue::Nil => "nil".to_string(),
93 LuaValue::Boolean(b) => b.to_string(),
94 LuaValue::Integer(n) => n.to_string(),
95 LuaValue::Number(n) => {
96 if n.fract() == 0.0 && n.abs() < 1e15_f64 {
99 format!("{n:.1}")
100 } else {
101 format!("{n}")
102 }
103 }
104 other => lua_inner
105 .coerce_string(other.clone())
106 .ok()
107 .flatten()
108 .and_then(|s| s.to_str().ok().map(|r| r.to_string()))
109 .unwrap_or_else(|| format!("{other:?}")),
110 })
111 .collect();
112 let line = parts.join("\t");
113 tracing::info!(target: "alc.lua.print", "{}", line);
115 let message = line.trim_end_matches('\n').to_string();
117 log_sink.push(LogEntry::new("info", "alc.lua.print", message));
118 Ok(())
119 })?;
120 lua.globals().set("print", print_fn)?;
121 Ok(())
122}
123
124pub(super) fn register_state(
142 lua: &Lua,
143 alc_table: &LuaTable,
144 ns: String,
145 state_store: Arc<JsonFileStore>,
146) -> LuaResult<()> {
147 let state_table = lua.create_table()?;
148
149 let ns_get = ns.clone();
151 let store_get = Arc::clone(&state_store);
152 let get =
153 lua.create_function(
154 move |lua, (key, default): (String, Option<LuaValue>)| match store_get
155 .get(&ns_get, &key)
156 {
157 Ok(Some(v)) => lua.to_value(&v),
158 Ok(None) => Ok(default.unwrap_or(LuaValue::Nil)),
159 Err(e) => Err(LuaError::external(e)),
160 },
161 )?;
162
163 let ns_set = ns.clone();
165 let store_set = Arc::clone(&state_store);
166 let set = lua.create_function(move |lua, (key, value): (String, LuaValue)| {
167 let json: serde_json::Value = lua.from_value(value)?;
168 store_set
169 .set(&ns_set, &key, json)
170 .map_err(LuaError::external)
171 })?;
172
173 let ns_keys = ns.clone();
175 let store_keys = Arc::clone(&state_store);
176 let keys = lua.create_function(move |lua, ()| {
177 let k = store_keys.keys(&ns_keys).map_err(LuaError::external)?;
178 lua.to_value(&k)
179 })?;
180
181 let ns_del = ns.clone();
183 let store_del = Arc::clone(&state_store);
184 let delete = lua.create_function(move |_, key: String| {
185 store_del.delete(&ns_del, &key).map_err(LuaError::external)
186 })?;
187
188 let ns_has = ns.clone();
190 let store_has = Arc::clone(&state_store);
191 let has = lua.create_function(move |_, key: String| {
192 store_has.has(&ns_has, &key).map_err(LuaError::external)
193 })?;
194
195 let ns_snx = ns.clone();
197 let store_snx = Arc::clone(&state_store);
198 let set_nx = lua.create_function(move |lua, (key, value): (String, LuaValue)| {
199 let json: serde_json::Value = lua.from_value(value)?;
200 store_snx
201 .set_nx(&ns_snx, &key, json)
202 .map_err(LuaError::external)
203 })?;
204
205 let ns_incr = ns;
207 let store_incr = Arc::clone(&state_store);
208 let incr = lua.create_function(
209 move |_, (key, delta, default): (String, Option<f64>, Option<f64>)| {
210 store_incr
211 .incr(&ns_incr, &key, delta.unwrap_or(1.0), default.unwrap_or(0.0))
212 .map_err(LuaError::external)
213 },
214 )?;
215
216 let store_list = Arc::clone(&state_store);
218 let list = lua.create_function(move |lua, namespace: String| {
219 let keys = store_list
220 .list_dispatched(&namespace)
221 .map_err(LuaError::external)?;
222 lua.to_value(&keys)
223 })?;
224
225 let store_show = Arc::clone(&state_store);
227 let show = lua.create_function(move |lua, (namespace, key): (String, String)| {
228 let v = store_show
229 .show_dispatched(&namespace, &key)
230 .map_err(LuaError::external)?;
231 lua.to_value(&v)
232 })?;
233
234 let store_reset = Arc::clone(&state_store);
236 let reset = lua.create_function(
237 move |lua, (namespace, key, opts): (String, String, Option<LuaTable>)| {
238 let (steps, fields) = match opts {
239 Some(t) => {
240 let s = t.get::<Option<Vec<String>>>("steps")?.unwrap_or_default();
241 let f = t.get::<Option<Vec<String>>>("fields")?.unwrap_or_default();
242 (s, f)
243 }
244 None => (Vec::new(), Vec::new()),
245 };
246 let report = store_reset
247 .reset_dispatched_with_backup(&namespace, &key, &steps, &fields)
248 .map_err(LuaError::external)?;
249 let ret = lua.create_table()?;
250 ret.set("ok", true)?;
251 ret.set(
252 "backup_path",
253 report.backup_path.to_string_lossy().to_string(),
254 )?;
255 ret.set("steps_removed", report.steps_removed)?;
256 ret.set("fields_removed", report.fields_removed)?;
257 Ok(ret)
258 },
259 )?;
260
261 let store_set_dispatched = Arc::clone(&state_store);
263 let set_dispatched = lua.create_function(
264 move |lua, (namespace, key, value): (String, String, LuaValue)| {
265 let json: serde_json::Value = lua.from_value(value)?;
266 store_set_dispatched
267 .set_dispatched(&namespace, &key, &json)
268 .map_err(LuaError::external)
269 },
270 )?;
271
272 let store_delete_dispatched = Arc::clone(&state_store);
274 let delete_dispatched = lua.create_function(move |_, (namespace, key): (String, String)| {
275 store_delete_dispatched
276 .delete_dispatched(&namespace, &key)
277 .map_err(LuaError::external)
278 })?;
279
280 state_table.set("get", get)?;
281 state_table.set("set", set)?;
282 state_table.set("keys", keys)?;
283 state_table.set("delete", delete)?;
284 state_table.set("has", has)?;
285 state_table.set("set_nx", set_nx)?;
286 state_table.set("incr", incr)?;
287 state_table.set("list", list)?;
288 state_table.set("show", show)?;
289 state_table.set("reset", reset)?;
290 state_table.set("set_dispatched", set_dispatched)?;
291 state_table.set("delete_dispatched", delete_dispatched)?;
292
293 alc_table.set("state", state_table)?;
294 Ok(())
295}
296
297pub(super) fn register_dirs(
303 lua: &Lua,
304 alc_table: &LuaTable,
305 state_dir: &Path,
306 cards_dir: &Path,
307 scenarios_dir: &Path,
308) -> LuaResult<()> {
309 let dirs = lua.create_table()?;
310 dirs.set("state", state_dir.to_string_lossy().into_owned())?;
311 dirs.set("cards", cards_dir.to_string_lossy().into_owned())?;
312 dirs.set("scenarios", scenarios_dir.to_string_lossy().into_owned())?;
313 alc_table.set("_dirs", dirs)?;
314 Ok(())
315}
316
317pub(super) fn register_card(
342 lua: &Lua,
343 alc_table: &LuaTable,
344 card_store: Arc<FileCardStore>,
345) -> LuaResult<()> {
346 let card_table = lua.create_table()?;
347
348 let store_create = Arc::clone(&card_store);
350 let create = lua.create_function(move |lua, input: LuaValue| {
351 let json: serde_json::Value = lua.from_value(input)?;
352 let (card_id, path) = store_create.create(json).map_err(LuaError::external)?;
353 let ret = lua.create_table()?;
354 ret.set("card_id", card_id)?;
355 ret.set("path", path.to_string_lossy().to_string())?;
356 Ok(ret)
357 })?;
358
359 let store_get = Arc::clone(&card_store);
361 let get = lua.create_function(move |lua, card_id: String| match store_get.get(&card_id) {
362 Ok(Some(v)) => lua.to_value(&v),
363 Ok(None) => Ok(LuaValue::Nil),
364 Err(e) => Err(LuaError::external(e)),
365 })?;
366
367 let store_list = Arc::clone(&card_store);
369 let list = lua.create_function(move |lua, filter: Option<LuaTable>| {
370 let pkg = match filter {
371 Some(t) => t.get::<Option<String>>("pkg")?,
372 None => None,
373 };
374 let rows = store_list
375 .list(pkg.as_deref())
376 .map_err(LuaError::external)?;
377 lua.to_value(&card::summaries_to_json(&rows))
378 })?;
379
380 let store_append = Arc::clone(&card_store);
382 let append = lua.create_function(move |lua, (card_id, fields): (String, LuaValue)| {
383 let json: serde_json::Value = lua.from_value(fields)?;
384 let merged = store_append
385 .append(&card_id, json)
386 .map_err(LuaError::external)?;
387 lua.to_value(&merged)
388 })?;
389
390 let store_gba = Arc::clone(&card_store);
392 let get_by_alias = lua.create_function(move |lua, name: String| {
393 match store_gba.get_by_alias(&name).map_err(LuaError::external)? {
394 Some(v) => lua.to_value(&v),
395 None => Ok(LuaValue::Nil),
396 }
397 })?;
398
399 let store_aset = Arc::clone(&card_store);
401 let alias_set = lua.create_function(
402 move |lua, (name, card_id, opts): (String, String, Option<LuaTable>)| {
403 let (pkg, note) = match opts {
404 Some(t) => (
405 t.get::<Option<String>>("pkg")?,
406 t.get::<Option<String>>("note")?,
407 ),
408 None => (None, None),
409 };
410 let a = store_aset
411 .alias_set(&name, &card_id, pkg.as_deref(), note.as_deref())
412 .map_err(LuaError::external)?;
413 let arr = card::aliases_to_json(&[a]);
414 let first = match arr {
415 serde_json::Value::Array(mut v) if !v.is_empty() => v.remove(0),
416 other => other,
417 };
418 lua.to_value(&first)
419 },
420 )?;
421
422 let store_alist = Arc::clone(&card_store);
424 let alias_list = lua.create_function(move |lua, filter: Option<LuaTable>| {
425 let pkg = match filter {
426 Some(t) => t.get::<Option<String>>("pkg")?,
427 None => None,
428 };
429 let rows = store_alist
430 .alias_list(pkg.as_deref())
431 .map_err(LuaError::external)?;
432 lua.to_value(&card::aliases_to_json(&rows))
433 })?;
434
435 let store_find = Arc::clone(&card_store);
440 let find = lua.create_function(move |lua, query: Option<LuaTable>| {
441 let q = match query {
442 Some(t) => {
443 let pkg = t.get::<Option<String>>("pkg")?;
444 let limit = t.get::<Option<usize>>("limit")?;
445 let offset = t.get::<Option<usize>>("offset")?;
446
447 let where_parsed = match t.get::<LuaValue>("where")? {
448 LuaValue::Nil => None,
449 v => {
450 let json: serde_json::Value = lua.from_value(v)?;
451 Some(card::parse_where(&json).map_err(LuaError::external)?)
452 }
453 };
454 let order_parsed = match t.get::<LuaValue>("order_by")? {
455 LuaValue::Nil => Vec::new(),
456 v => {
457 let json: serde_json::Value = lua.from_value(v)?;
458 card::parse_order_by(&json).map_err(LuaError::external)?
459 }
460 };
461
462 card::FindQuery {
463 pkg,
464 where_: where_parsed,
465 order_by: order_parsed,
466 limit,
467 offset,
468 }
469 }
470 None => card::FindQuery::default(),
471 };
472 let rows = store_find.find(q).map_err(LuaError::external)?;
473 lua.to_value(&card::summaries_to_json(&rows))
474 })?;
475
476 let store_ws = Arc::clone(&card_store);
478 let write_samples =
479 lua.create_function(move |lua, (card_id, samples): (String, LuaValue)| {
480 let json: serde_json::Value = lua.from_value(samples)?;
481 let arr = match json {
482 serde_json::Value::Array(a) => a,
483 _ => {
484 return Err(LuaError::external(
485 "alc.card.write_samples: samples must be an array",
486 ))
487 }
488 };
489 let count = arr.len();
490 let path = store_ws
491 .write_samples(&card_id, arr)
492 .map_err(LuaError::external)?;
493 let ret = lua.create_table()?;
494 ret.set("path", path.to_string_lossy().to_string())?;
495 ret.set("count", count)?;
496 Ok(ret)
497 })?;
498
499 let store_rs = Arc::clone(&card_store);
504 let read_samples =
505 lua.create_function(move |lua, (card_id, opts): (String, Option<LuaTable>)| {
506 let (offset, limit, where_parsed) = match opts {
507 Some(t) => {
508 let offset = t.get::<Option<usize>>("offset")?.unwrap_or(0);
509 let limit = t.get::<Option<usize>>("limit")?;
510 let where_parsed = match t.get::<LuaValue>("where")? {
511 LuaValue::Nil => None,
512 v => {
513 let json: serde_json::Value = lua.from_value(v)?;
514 Some(card::parse_where(&json).map_err(LuaError::external)?)
515 }
516 };
517 (offset, limit, where_parsed)
518 }
519 None => (0, None, None),
520 };
521 let q = card::SamplesQuery {
522 offset,
523 limit,
524 where_: where_parsed,
525 };
526 let rows = store_rs
527 .read_samples(&card_id, q)
528 .map_err(LuaError::external)?;
529 lua.to_value(&serde_json::Value::Array(rows))
530 })?;
531
532 let store_sb = Arc::clone(&card_store);
537 let sink_backfill = lua.create_function(move |lua, params: LuaTable| {
538 let sink: String = params.get("sink")?;
539 let dry_run: Option<bool> = params.get("dry_run")?;
540 let report = store_sb
541 .card_sink_backfill(&sink, dry_run.unwrap_or(false))
542 .map_err(LuaError::external)?;
543 lua.to_value(&report)
544 })?;
545
546 let store_lin = Arc::clone(&card_store);
551 let lineage = lua.create_function(move |lua, query: LuaTable| {
552 let card_id: String = query.get("card_id")?;
553 let direction_str: Option<String> = query.get("direction")?;
554 let direction = match direction_str.as_deref() {
555 Some(s) => card::LineageDirection::parse(s).map_err(LuaError::external)?,
556 None => card::LineageDirection::Up,
557 };
558 let depth: Option<usize> = query.get("depth")?;
559 let include_stats: Option<bool> = query.get("include_stats")?;
560 let relation_filter: Option<Vec<String>> = match query.get::<LuaValue>("relation_filter")? {
561 LuaValue::Nil => None,
562 v => Some(lua.from_value(v)?),
563 };
564
565 let q = card::LineageQuery {
566 card_id,
567 direction,
568 depth,
569 include_stats: include_stats.unwrap_or(true),
570 relation_filter,
571 };
572 match store_lin.lineage(q).map_err(LuaError::external)? {
573 Some(res) => lua.to_value(&card::lineage_to_json(&res)),
574 None => Ok(LuaValue::Nil),
575 }
576 })?;
577
578 card_table.set("create", create)?;
579 card_table.set("get", get)?;
580 card_table.set("list", list)?;
581 card_table.set("append", append)?;
582 card_table.set("get_by_alias", get_by_alias)?;
583 card_table.set("alias_set", alias_set)?;
584 card_table.set("alias_list", alias_list)?;
585 card_table.set("find", find)?;
586 card_table.set("write_samples", write_samples)?;
587 card_table.set("read_samples", read_samples)?;
588 card_table.set("lineage", lineage)?;
589 card_table.set("sink_backfill", sink_backfill)?;
590
591 alc_table.set("card", card_table)?;
592 Ok(())
593}
594
595pub(super) fn register_stats(
608 lua: &Lua,
609 alc_table: &LuaTable,
610 custom_metrics: CustomMetricsHandle,
611 stats: StatsHandle,
612) -> LuaResult<()> {
613 let stats_table = lua.create_table()?;
614
615 let cm_record = custom_metrics.clone();
617 let record = lua.create_function(move |lua, (key, value): (String, LuaValue)| {
618 let json: serde_json::Value = lua.from_value(value)?;
619 cm_record.record(key, json);
620 Ok(())
621 })?;
622
623 let cm_get = custom_metrics;
625 let get = lua.create_function(move |lua, key: String| match cm_get.get(&key) {
626 Some(v) => lua.to_value(&v),
627 None => Ok(LuaValue::Nil),
628 })?;
629
630 let stats_handle = stats;
632 let llm_calls = lua.create_function(move |_, ()| Ok(stats_handle.llm_calls()))?;
633
634 stats_table.set("record", record)?;
635 stats_table.set("get", get)?;
636 stats_table.set("llm_calls", llm_calls)?;
637
638 alc_table.set("stats", stats_table)?;
639 Ok(())
640}
641
642pub struct AlcEnv(pub Arc<HashMap<String, String>>);
653
654impl mlua::UserData for AlcEnv {
655 fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M) {
656 methods.add_meta_method(mlua::MetaMethod::Index, |_, this, key: String| {
658 Ok(this.0.get(&key).cloned())
659 });
660
661 methods.add_meta_method(
664 mlua::MetaMethod::NewIndex,
665 |_, _, (_k, _v): (mlua::Value, mlua::Value)| {
666 Err::<(), _>(mlua::Error::external("alc.env is readonly"))
667 },
668 );
669
670 methods.add_method(
672 "get",
673 |_, this, (key, default): (String, Option<String>)| {
674 Ok(this.0.get(&key).cloned().or(default))
675 },
676 );
677
678 methods.add_method("use", |lua, this, declared: Vec<String>| {
682 let proxy = lua.create_table()?;
683 for k in &declared {
684 if let Some(v) = this.0.get(k) {
685 proxy.set(k.clone(), v.clone())?;
686 }
687 }
688 Ok(proxy)
689 });
690 }
691}
692
693pub fn register_env(
701 lua: &mlua::Lua,
702 alc_table: &mlua::Table,
703 env_map: Arc<HashMap<String, String>>,
704) -> mlua::Result<()> {
705 alc_table.set("env", AlcEnv(Arc::clone(&env_map)))?;
706 lua.set_app_data(env_map);
707 Ok(())
708}
709
710#[cfg(test)]
711mod tests {
712 use super::*;
713 use algocline_core::ExecutionMetrics;
714
715 fn test_config_with(ns: &str) -> crate::bridge::BridgeConfig {
719 let metrics = ExecutionMetrics::new();
720 let tmp = tempfile::tempdir().expect("test tempdir");
721 let root = tmp.path().to_path_buf();
722 std::mem::forget(tmp);
723 crate::bridge::BridgeConfig {
724 llm_tx: None,
725 ns: ns.into(),
726 custom_metrics: metrics.custom_metrics_handle(),
727 stats: metrics.stats_handle(),
728 budget: metrics.budget_handle(),
729 progress: metrics.progress_handle(),
730 lib_paths: vec![],
731 variant_pkgs: vec![],
732 state_store: Arc::new(JsonFileStore::new(root.join("state"))),
733 card_store: Arc::new(FileCardStore::new(root.join("cards"))),
734 scenarios_dir: root.join("scenarios"),
735 log_sink: None,
736 }
737 }
738
739 fn test_config() -> crate::bridge::BridgeConfig {
740 test_config_with("default")
741 }
742
743 fn test_config_with_ns(ns: &str) -> crate::bridge::BridgeConfig {
744 test_config_with(ns)
745 }
746
747 #[test]
748 fn json_roundtrip() {
749 let lua = Lua::new();
750 let t = lua.create_table().unwrap();
751 crate::bridge::register(&lua, &t, test_config()).unwrap();
752 lua.globals().set("alc", t).unwrap();
753
754 let result: String = lua
755 .load(r#"return alc.json_encode({hello = "world", n = 42})"#)
756 .eval()
757 .unwrap();
758 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
759 assert_eq!(parsed["hello"], "world");
760 assert_eq!(parsed["n"], 42);
761 }
762
763 #[test]
764 fn json_decode_encode() {
765 let lua = Lua::new();
766 let t = lua.create_table().unwrap();
767 crate::bridge::register(&lua, &t, test_config()).unwrap();
768 lua.globals().set("alc", t).unwrap();
769
770 let result: String = lua
771 .load(
772 r#"
773 local val = alc.json_decode('{"a":1,"b":"two"}')
774 val.c = true
775 return alc.json_encode(val)
776 "#,
777 )
778 .eval()
779 .unwrap();
780 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
781 assert_eq!(parsed["a"], 1);
782 assert_eq!(parsed["b"], "two");
783 assert_eq!(parsed["c"], true);
784 }
785
786 #[test]
791 fn json_decode_null_yields_lua_nil() {
792 let lua = Lua::new();
793 let t = lua.create_table().unwrap();
794 crate::bridge::register(&lua, &t, test_config()).unwrap();
795 lua.globals().set("alc", t).unwrap();
796
797 let top_level_truthy: bool = lua
799 .load(r#"local v = alc.json_decode("null"); return v ~= nil"#)
800 .eval()
801 .unwrap();
802 assert!(
803 !top_level_truthy,
804 "alc.json_decode(\"null\") should return Lua nil"
805 );
806
807 let field_truthy: bool = lua
809 .load(
810 r#"
811 local obj = alc.json_decode('{"x": null, "y": 1}')
812 return obj.x ~= nil
813 "#,
814 )
815 .eval()
816 .unwrap();
817 assert!(
818 !field_truthy,
819 "Object field decoded from JSON null should be Lua nil"
820 );
821
822 let field_type: String = lua
824 .load(r#"return type(alc.json_decode('{"x": null}').x)"#)
825 .eval()
826 .unwrap();
827 assert_eq!(
828 field_type, "nil",
829 "type() of null-decoded field must be 'nil', not 'userdata'"
830 );
831
832 let arr_len: i64 = lua
835 .load(r#"return #alc.json_decode('[1, null, 3]')"#)
836 .eval()
837 .unwrap();
838 assert_eq!(
845 arr_len, 3,
846 "JSON array length is preserved across null elements (mlua/Lua 5.4 array part)"
847 );
848
849 let (a, b, c): (Option<i64>, Option<i64>, Option<i64>) = lua
852 .load(
853 r#"
854 local arr = alc.json_decode('[1, null, 3]')
855 return arr[1], arr[2], arr[3]
856 "#,
857 )
858 .eval()
859 .unwrap();
860 assert_eq!(a, Some(1));
861 assert_eq!(b, None, "Array element decoded from JSON null must be nil");
862 assert_eq!(c, Some(3));
863 }
864
865 #[test]
866 fn state_get_set() {
867 let ns = "_test_bridge_state";
870
871 let lua = Lua::new();
872 let t = lua.create_table().unwrap();
873 crate::bridge::register(&lua, &t, test_config_with_ns(ns)).unwrap();
874 lua.globals().set("alc", t).unwrap();
875
876 lua.load(r#"alc.state.set("x", 99)"#).exec().unwrap();
878 let result: i64 = lua.load(r#"return alc.state.get("x")"#).eval().unwrap();
879 assert_eq!(result, 99);
880
881 let result: i64 = lua
883 .load(r#"return alc.state.get("missing", 0)"#)
884 .eval()
885 .unwrap();
886 assert_eq!(result, 0);
887
888 let result: LuaValue = lua
890 .load(r#"return alc.state.get("missing")"#)
891 .eval()
892 .unwrap();
893 assert!(result.is_nil());
894 }
895
896 #[test]
897 fn state_has_set_nx_incr() {
898 let ns = "_test_bridge_state_t1";
899
900 let lua = Lua::new();
901 let t = lua.create_table().unwrap();
902 crate::bridge::register(&lua, &t, test_config_with_ns(ns)).unwrap();
903 lua.globals().set("alc", t).unwrap();
904
905 let h: bool = lua.load(r#"return alc.state.has("k")"#).eval().unwrap();
907 assert!(!h);
908
909 let ok: bool = lua
911 .load(r#"return alc.state.set_nx("k", "first")"#)
912 .eval()
913 .unwrap();
914 assert!(ok);
915
916 let h: bool = lua.load(r#"return alc.state.has("k")"#).eval().unwrap();
918 assert!(h);
919
920 let ok: bool = lua
922 .load(r#"return alc.state.set_nx("k", "second")"#)
923 .eval()
924 .unwrap();
925 assert!(!ok);
926
927 let v: f64 = lua
929 .load(r#"return alc.state.incr("counter")"#)
930 .eval()
931 .unwrap();
932 assert!((v - 1.0).abs() < f64::EPSILON);
933
934 let v: f64 = lua
936 .load(r#"return alc.state.incr("counter", 5)"#)
937 .eval()
938 .unwrap();
939 assert!((v - 6.0).abs() < f64::EPSILON);
940
941 let v: f64 = lua
943 .load(r#"return alc.state.incr("counter", 10, 100)"#)
944 .eval()
945 .unwrap();
946 assert!((v - 16.0).abs() < f64::EPSILON);
947 }
948
949 #[test]
950 fn card_create_get_list_from_lua() {
951 let ns = std::time::SystemTime::now()
953 .duration_since(std::time::UNIX_EPOCH)
954 .unwrap()
955 .as_nanos();
956 let pkg = format!("_test_bridge_card_{ns}");
957
958 let lua = Lua::new();
959 let t = lua.create_table().unwrap();
960 crate::bridge::register(&lua, &t, test_config()).unwrap();
961 lua.globals().set("alc", t).unwrap();
962
963 let create_script = format!(
965 r#"
966 local r = alc.card.create({{
967 pkg = {{ name = "{pkg}" }},
968 model = {{ id = "claude-opus-4-6" }},
969 stats = {{ pass_rate = 0.9 }},
970 }})
971 return r.card_id
972 "#
973 );
974 let card_id: String = lua.load(&create_script).eval().unwrap();
975 assert!(card_id.starts_with(&pkg));
976
977 let get_script = format!(r#"return alc.card.get("{card_id}").stats.pass_rate"#);
979 let rate: f64 = lua.load(&get_script).eval().unwrap();
980 assert!((rate - 0.9).abs() < 1e-9);
981
982 let list_script = format!(
984 r#"
985 local rows = alc.card.list({{ pkg = "{pkg}" }})
986 return #rows
987 "#
988 );
989 let count: i64 = lua.load(&list_script).eval().unwrap();
990 assert_eq!(count, 1);
991
992 }
994
995 #[test]
996 fn stats_record_get() {
997 let metrics = ExecutionMetrics::new();
998 let custom_handle = metrics.custom_metrics_handle();
999 let lua = Lua::new();
1000 let t = lua.create_table().unwrap();
1001 let tmp = tempfile::tempdir().expect("test tempdir");
1002 let root = tmp.path().to_path_buf();
1003 std::mem::forget(tmp);
1004 crate::bridge::register(
1005 &lua,
1006 &t,
1007 crate::bridge::BridgeConfig {
1008 llm_tx: None,
1009 ns: "default".into(),
1010 custom_metrics: custom_handle.clone(),
1011 stats: metrics.stats_handle(),
1012 budget: metrics.budget_handle(),
1013 progress: metrics.progress_handle(),
1014 lib_paths: vec![],
1015 variant_pkgs: vec![],
1016 state_store: Arc::new(JsonFileStore::new(root.join("state"))),
1017 card_store: Arc::new(FileCardStore::new(root.join("cards"))),
1018 scenarios_dir: root.join("scenarios"),
1019 log_sink: None,
1020 },
1021 )
1022 .unwrap();
1023 lua.globals().set("alc", t).unwrap();
1024
1025 lua.load(r#"alc.stats.record("score", 42)"#).exec().unwrap();
1027 let result: i64 = lua.load(r#"return alc.stats.get("score")"#).eval().unwrap();
1028 assert_eq!(result, 42);
1029
1030 assert_eq!(custom_handle.get("score"), Some(serde_json::json!(42)));
1032
1033 let result: LuaValue = lua
1035 .load(r#"return alc.stats.get("missing")"#)
1036 .eval()
1037 .unwrap();
1038 assert!(result.is_nil());
1039 }
1040
1041 #[test]
1046 fn stats_llm_calls_reads_session_status() {
1047 use crate::card::FileCardStore;
1048 use crate::state::JsonFileStore;
1049 use algocline_core::{ExecutionObserver, LlmQuery, QueryId};
1050 use std::sync::Arc;
1051
1052 let metrics = ExecutionMetrics::new();
1053 let observer = metrics.create_observer();
1054
1055 let lua = Lua::new();
1056 let t = lua.create_table().unwrap();
1057 let tmp = tempfile::tempdir().expect("test tempdir");
1058 let root = tmp.path().to_path_buf();
1059 std::mem::forget(tmp);
1060 crate::bridge::register(
1061 &lua,
1062 &t,
1063 crate::bridge::BridgeConfig {
1064 llm_tx: None,
1065 ns: "default".into(),
1066 custom_metrics: metrics.custom_metrics_handle(),
1067 stats: metrics.stats_handle(),
1068 budget: metrics.budget_handle(),
1069 progress: metrics.progress_handle(),
1070 lib_paths: vec![],
1071 variant_pkgs: vec![],
1072 state_store: Arc::new(JsonFileStore::new(root.join("state"))),
1073 card_store: Arc::new(FileCardStore::new(root.join("cards"))),
1074 scenarios_dir: root.join("scenarios"),
1075 log_sink: None,
1076 },
1077 )
1078 .unwrap();
1079 lua.globals().set("alc", t).unwrap();
1080
1081 let initial: u64 = lua.load(r#"return alc.stats.llm_calls()"#).eval().unwrap();
1083 assert_eq!(initial, 0, "fresh session must report llm_calls() == 0");
1084
1085 observer.on_paused(&[LlmQuery {
1087 id: QueryId::parse("q-0"),
1088 prompt: "hi".to_string(),
1089 system: None,
1090 max_tokens: 0,
1091 grounded: false,
1092 underspecified: false,
1093 }]);
1094
1095 let after_one: u64 = lua.load(r#"return alc.stats.llm_calls()"#).eval().unwrap();
1097 assert_eq!(
1098 after_one, 1,
1099 "one paused query must increment llm_calls() to 1"
1100 );
1101
1102 observer.on_paused(&[
1104 LlmQuery {
1105 id: QueryId::parse("q-1"),
1106 prompt: "a".to_string(),
1107 system: None,
1108 max_tokens: 0,
1109 grounded: false,
1110 underspecified: false,
1111 },
1112 LlmQuery {
1113 id: QueryId::parse("q-2"),
1114 prompt: "b".to_string(),
1115 system: None,
1116 max_tokens: 0,
1117 grounded: false,
1118 underspecified: false,
1119 },
1120 ]);
1121
1122 let after_three: u64 = lua.load(r#"return alc.stats.llm_calls()"#).eval().unwrap();
1123 assert_eq!(
1124 after_three, 3,
1125 "two further paused queries (multi-query batch) must bring llm_calls() to 3"
1126 );
1127 }
1128
1129 #[test]
1133 fn register_log_pushes_to_log_sink() {
1134 use algocline_core::LogSink;
1135
1136 let sink = LogSink::new();
1137 let lua = Lua::new();
1138 let t = lua.create_table().unwrap();
1139 register_log(&lua, &t, sink.clone()).unwrap();
1141 lua.globals().set("alc", t).unwrap();
1142
1143 lua.load(r#"alc.log("info", "hello-from-log")"#)
1144 .exec()
1145 .unwrap();
1147
1148 let entries = sink.entries();
1149 assert_eq!(entries.len(), 1);
1150 assert_eq!(entries[0].source, "alc.log");
1151 assert_eq!(entries[0].level, "info");
1152 assert_eq!(entries[0].message, "hello-from-log");
1153 }
1154
1155 #[test]
1157 fn register_log_unknown_level_still_pushes() {
1158 use algocline_core::LogSink;
1159
1160 let sink = LogSink::new();
1161 let lua = Lua::new();
1162 let t = lua.create_table().unwrap();
1163 register_log(&lua, &t, sink.clone()).unwrap();
1165 lua.globals().set("alc", t).unwrap();
1166
1167 lua.load(r#"alc.log("custom", "edge-case")"#)
1168 .exec()
1169 .unwrap();
1171
1172 let entries = sink.entries();
1173 assert_eq!(entries.len(), 1);
1174 assert_eq!(entries[0].source, "alc.log");
1175 assert_eq!(entries[0].level, "custom");
1177 assert_eq!(entries[0].message, "edge-case");
1178 }
1179
1180 #[test]
1182 fn register_log_empty_message() {
1183 use algocline_core::LogSink;
1184
1185 let sink = LogSink::new();
1186 let lua = Lua::new();
1187 let t = lua.create_table().unwrap();
1188 register_log(&lua, &t, sink.clone()).unwrap();
1190 lua.globals().set("alc", t).unwrap();
1191
1192 lua.load(r#"alc.log("warn", "")"#)
1193 .exec()
1194 .unwrap();
1196
1197 let entries = sink.entries();
1198 assert_eq!(entries.len(), 1);
1199 assert_eq!(entries[0].message, "");
1200 }
1201
1202 #[test]
1206 fn register_print_pushes_to_log_sink() {
1207 use algocline_core::LogSink;
1208
1209 let sink = LogSink::new();
1210 let lua = Lua::new();
1211 register_print(&lua, sink.clone()).unwrap();
1213
1214 lua.load(r#"print("hello-print")"#)
1215 .exec()
1216 .unwrap();
1218
1219 let entries = sink.entries();
1220 assert_eq!(entries.len(), 1);
1221 assert_eq!(entries[0].source, "alc.lua.print");
1222 assert_eq!(entries[0].level, "info");
1223 assert_eq!(entries[0].message, "hello-print");
1224 }
1225
1226 #[test]
1228 fn register_print_multiple_args_tab_joined() {
1229 use algocline_core::LogSink;
1230
1231 let sink = LogSink::new();
1232 let lua = Lua::new();
1233 register_print(&lua, sink.clone()).unwrap();
1235
1236 lua.load(r#"print("a", "b", "c")"#)
1237 .exec()
1238 .unwrap();
1240
1241 let entries = sink.entries();
1242 assert_eq!(entries.len(), 1);
1243 assert_eq!(entries[0].message, "a\tb\tc");
1244 }
1245
1246 #[test]
1248 fn register_print_mixed_value_types() {
1249 use algocline_core::LogSink;
1250
1251 let sink = LogSink::new();
1252 let lua = Lua::new();
1253 register_print(&lua, sink.clone()).unwrap();
1255
1256 lua.load(r#"print(nil, true, 42, 3.14)"#)
1257 .exec()
1258 .unwrap();
1260
1261 let entries = sink.entries();
1262 assert_eq!(entries.len(), 1);
1263 let msg = &entries[0].message;
1265 assert!(msg.starts_with("nil\ttrue\t42\t"), "got: {msg}");
1266 }
1267
1268 fn make_env_lua(pairs: &[(&str, &str)]) -> (Lua, Arc<HashMap<String, String>>) {
1271 let mut map = HashMap::new();
1272 for (k, v) in pairs {
1273 map.insert(k.to_string(), v.to_string());
1274 }
1275 let env_map = Arc::new(map);
1276 let lua = Lua::new();
1277 let alc_table = lua.create_table().unwrap();
1278 register_env(&lua, &alc_table, Arc::clone(&env_map)).unwrap();
1279 lua.globals().set("alc", alc_table).unwrap();
1280 (lua, env_map)
1281 }
1282
1283 #[test]
1284 fn env_index_reads_existing_key() {
1285 let (lua, _) = make_env_lua(&[("FOO", "bar")]);
1286 let val: Option<String> = lua.load(r#"return alc.env.FOO"#).eval().unwrap();
1287 assert_eq!(val, Some("bar".to_string()));
1288 }
1289
1290 #[test]
1291 fn env_index_missing_key_returns_nil() {
1292 let (lua, _) = make_env_lua(&[("FOO", "bar")]);
1293 let val: LuaValue = lua.load(r#"return alc.env.MISSING"#).eval().unwrap();
1294 assert!(val.is_nil());
1295 }
1296
1297 #[test]
1298 fn env_newindex_returns_error() {
1299 let (lua, _) = make_env_lua(&[("FOO", "bar")]);
1300 let result: Result<(), _> = lua.load(r#"alc.env.FOO = "x""#).exec();
1301 let err = result.unwrap_err().to_string();
1302 assert!(
1303 err.contains("alc.env is readonly"),
1304 "expected readonly error, got: {err}"
1305 );
1306 }
1307
1308 #[test]
1309 fn env_get_with_default_returns_default_on_miss() {
1310 let (lua, _) = make_env_lua(&[]);
1311 let val: Option<String> = lua
1312 .load(r#"return alc.env:get("MISSING", "fallback")"#)
1313 .eval()
1314 .unwrap();
1315 assert_eq!(val, Some("fallback".to_string()));
1316 }
1317
1318 #[test]
1319 fn env_get_returns_value_when_present() {
1320 let (lua, _) = make_env_lua(&[("KEY", "val")]);
1321 let val: Option<String> = lua
1322 .load(r#"return alc.env:get("KEY", "default")"#)
1323 .eval()
1324 .unwrap();
1325 assert_eq!(val, Some("val".to_string()));
1326 }
1327
1328 #[test]
1329 fn env_use_returns_declared_keys_only() {
1330 let (lua, _) = make_env_lua(&[("FOO", "foo_val"), ("BAR", "bar_val"), ("SECRET", "s")]);
1331 let result: LuaValue = lua
1332 .load(
1333 r#"
1334 local e = alc.env:use{"FOO", "BAR"}
1335 return e
1336 "#,
1337 )
1338 .eval()
1339 .unwrap();
1340 let tbl = result.as_table().unwrap();
1341 assert_eq!(tbl.get::<String>("FOO").unwrap(), "foo_val");
1342 assert_eq!(tbl.get::<String>("BAR").unwrap(), "bar_val");
1343 let secret: LuaValue = tbl.get("SECRET").unwrap();
1345 assert!(secret.is_nil(), "SECRET should be nil in proxy");
1346 }
1347
1348 #[test]
1349 fn env_use_undeclared_key_is_nil() {
1350 let (lua, _) = make_env_lua(&[("FOO", "foo_val")]);
1351 let val: LuaValue = lua
1352 .load(
1353 r#"
1354 local e = alc.env:use{"FOO"}
1355 return e.UNDECLARED
1356 "#,
1357 )
1358 .eval()
1359 .unwrap();
1360 assert!(val.is_nil());
1361 }
1362
1363 #[test]
1364 fn register_env_sets_app_data() {
1365 let mut map = HashMap::new();
1366 map.insert("X".to_string(), "1".to_string());
1367 let env_map = Arc::new(map);
1368 let lua = Lua::new();
1369 let alc_table = lua.create_table().unwrap();
1370 register_env(&lua, &alc_table, Arc::clone(&env_map)).unwrap();
1371 let retrieved = lua.app_data_ref::<Arc<HashMap<String, String>>>().unwrap();
1373 assert_eq!(retrieved.get("X").unwrap(), "1");
1374 }
1375
1376 mod state_dispatched_lua {
1377 use super::*;
1378 use mlua::Lua;
1379 use std::sync::Arc;
1380 use tempfile::TempDir;
1381
1382 fn setup() -> (Lua, Arc<JsonFileStore>, TempDir) {
1383 let tmp = tempfile::tempdir().unwrap();
1384 let store = Arc::new(JsonFileStore::new(tmp.path().to_path_buf()));
1385 let lua = Lua::new();
1386 let alc = lua.create_table().unwrap();
1387 register_state(&lua, &alc, "default".to_string(), Arc::clone(&store)).unwrap();
1388 lua.globals().set("alc", alc).unwrap();
1389 (lua, store, tmp)
1390 }
1391
1392 #[test]
1393 fn list_returns_sorted_keys() {
1394 let (lua, _store, tmp) = setup();
1395 std::fs::create_dir_all(tmp.path().join("testns")).unwrap();
1397 std::fs::write(
1398 tmp.path().join("testns/beta.json"),
1399 r#"{"data": {"completed_steps": [], "x": 1}}"#,
1400 )
1401 .unwrap();
1402 std::fs::write(
1403 tmp.path().join("testns/alpha.json"),
1404 r#"{"data": {"completed_steps": [], "y": 2}}"#,
1405 )
1406 .unwrap();
1407 lua.load(
1408 r#"
1409 local result = alc.state.list("testns")
1410 assert(#result == 2, "expected 2 keys, got " .. #result)
1411 assert(result[1] == "alpha", "first key should be alpha, got " .. tostring(result[1]))
1412 assert(result[2] == "beta", "second key should be beta, got " .. tostring(result[2]))
1413 "#,
1414 )
1415 .exec()
1416 .unwrap();
1417 }
1418
1419 #[test]
1420 fn show_returns_full_table() {
1421 let (lua, _store, tmp) = setup();
1422 std::fs::create_dir_all(tmp.path().join("testns")).unwrap();
1423 std::fs::write(
1424 tmp.path().join("testns/alpha.json"),
1425 r#"{"data": {"completed_steps": ["a", "b", "c"], "x": 1, "y": 2}}"#,
1426 )
1427 .unwrap();
1428 lua.load(
1429 r#"
1430 local result = alc.state.show("testns", "alpha")
1431 assert(type(result) == "table", "expected table")
1432 assert(type(result.data) == "table", "expected result.data to be a table")
1433 assert(result.data.x == 1, "expected x=1")
1434 assert(result.data.y == 2, "expected y=2")
1435 assert(#result.data.completed_steps == 3, "expected 3 steps")
1436 "#,
1437 )
1438 .exec()
1439 .unwrap();
1440 }
1441
1442 #[test]
1443 fn show_missing_returns_not_found_error() {
1444 let (lua, _store, _tmp) = setup();
1445 lua.load(
1446 r#"
1447 local ok, err = pcall(alc.state.show, "testns", "missing")
1448 assert(not ok, "expected error but got success")
1449 local msg = tostring(err)
1450 assert(string.find(msg, "not found"), "error message should contain 'not found', got: " .. msg)
1451 "#,
1452 )
1453 .exec()
1454 .unwrap();
1455 }
1456
1457 #[test]
1458 fn reset_removes_steps_and_fields_with_backup() {
1459 let (lua, _store, tmp) = setup();
1460 std::fs::create_dir_all(tmp.path().join("testns")).unwrap();
1461 let file_path = tmp.path().join("testns/alpha.json");
1462 std::fs::write(
1463 &file_path,
1464 r#"{"data": {"completed_steps": ["a", "b", "c"], "x": 1, "y": 2}}"#,
1465 )
1466 .unwrap();
1467 let tmp_path_str = tmp.path().to_string_lossy().to_string();
1469 lua.globals().set("TMP_PATH", tmp_path_str.clone()).unwrap();
1470 lua.load(
1471 r#"
1472 local r = alc.state.reset("testns", "alpha", {steps={"b"}, fields={"x"}})
1473 assert(r.ok == true, "expected ok=true")
1474 assert(type(r.backup_path) == "string", "backup_path should be a string")
1475 assert(r.steps_removed == 1, "expected steps_removed=1, got " .. tostring(r.steps_removed))
1476 assert(r.fields_removed == 1, "expected fields_removed=1, got " .. tostring(r.fields_removed))
1477 "#,
1478 )
1479 .exec()
1480 .unwrap();
1481 let bak_path = tmp.path().join("testns/alpha.json.bak");
1483 assert!(
1484 bak_path.exists(),
1485 "backup file should exist at {:?}",
1486 bak_path
1487 );
1488 let bak_content = std::fs::read_to_string(&bak_path).unwrap();
1489 assert!(
1490 bak_content.contains("\"b\""),
1491 "backup should contain original 'b' step"
1492 );
1493 let live_content = std::fs::read_to_string(&file_path).unwrap();
1495 let live: serde_json::Value = serde_json::from_str(&live_content).unwrap();
1496 let steps = live["data"]["completed_steps"].as_array().unwrap();
1497 assert!(
1498 !steps.iter().any(|s| s.as_str() == Some("b")),
1499 "step 'b' should be removed from completed_steps"
1500 );
1501 assert!(
1502 live["data"]["x"].is_null() || live["data"].get("x").is_none(),
1503 "field 'x' should be removed from data"
1504 );
1505 }
1506
1507 #[test]
1508 fn unsafe_namespace_rejected() {
1509 let (lua, _store, _tmp) = setup();
1510 lua.load(
1511 r#"
1512 local ok, err = pcall(alc.state.list, "../evil")
1513 assert(not ok, "expected error for unsafe namespace")
1514 local msg = tostring(err)
1515 assert(string.find(msg, "unsafe"), "error should contain 'unsafe', got: " .. msg)
1516 "#,
1517 )
1518 .exec()
1519 .unwrap();
1520 lua.load(
1521 r#"
1522 local ok, err = pcall(alc.state.show, "../evil", "key")
1523 assert(not ok, "expected error for unsafe namespace in show")
1524 local msg = tostring(err)
1525 assert(string.find(msg, "unsafe"), "error should contain 'unsafe', got: " .. msg)
1526 "#,
1527 )
1528 .exec()
1529 .unwrap();
1530 lua.load(
1531 r#"
1532 local ok, err = pcall(alc.state.reset, "../evil", "key", {})
1533 assert(not ok, "expected error for unsafe namespace in reset")
1534 local msg = tostring(err)
1535 assert(string.find(msg, "unsafe"), "error should contain 'unsafe', got: " .. msg)
1536 "#,
1537 )
1538 .exec()
1539 .unwrap();
1540 }
1541 }
1542}