1use std::collections::HashMap;
2use std::path::Path;
3use std::sync::Arc;
4
5use algocline_core::{CustomMetricsHandle, LogEntry, LogSink, StatsHandle};
6use mlua::prelude::*;
7use mlua::LuaSerdeExt;
8
9use crate::card::{self, FileCardStore};
10use crate::state::{JsonFileStore, StateStore};
11
12pub(super) fn register_json(lua: &Lua, alc_table: &LuaTable) -> LuaResult<()> {
13 let encode = lua.create_function(|lua, value: LuaValue| {
14 let json: serde_json::Value = lua.from_value(value)?;
15 serde_json::to_string(&json).map_err(LuaError::external)
16 })?;
17
18 let decode = lua.create_function(|lua, s: String| {
19 let value: serde_json::Value = serde_json::from_str(&s).map_err(LuaError::external)?;
20 lua.to_value(&value)
21 })?;
22
23 alc_table.set("json_encode", encode)?;
24 alc_table.set("json_decode", decode)?;
25 Ok(())
26}
27
28pub(super) fn register_log(lua: &Lua, alc_table: &LuaTable, log_sink: LogSink) -> LuaResult<()> {
42 let log = lua.create_function(move |_, (level, msg): (String, String)| {
43 match level.as_str() {
45 "error" => tracing::error!(target: "alc.log", "{}", msg),
46 "warn" => tracing::warn!(target: "alc.log", "{}", msg),
47 "info" => tracing::info!(target: "alc.log", "{}", msg),
48 "debug" => tracing::debug!(target: "alc.log", "{}", msg),
49 _ => tracing::info!(target: "alc.log", "{}", msg),
50 }
51 log_sink.push(LogEntry::new(level.clone(), "alc.log", msg));
53 Ok(())
54 })?;
55
56 alc_table.set("log", log)?;
57 Ok(())
58}
59
60pub(super) fn register_print(lua: &Lua, log_sink: LogSink) -> LuaResult<()> {
80 let print_fn = lua.create_function(move |lua_inner, args: mlua::MultiValue| {
81 let parts: Vec<String> = args
82 .iter()
83 .map(|v| match v {
84 LuaValue::Nil => "nil".to_string(),
85 LuaValue::Boolean(b) => b.to_string(),
86 LuaValue::Integer(n) => n.to_string(),
87 LuaValue::Number(n) => {
88 if n.fract() == 0.0 && n.abs() < 1e15_f64 {
91 format!("{n:.1}")
92 } else {
93 format!("{n}")
94 }
95 }
96 other => lua_inner
97 .coerce_string(other.clone())
98 .ok()
99 .flatten()
100 .and_then(|s| s.to_str().ok().map(|r| r.to_string()))
101 .unwrap_or_else(|| format!("{other:?}")),
102 })
103 .collect();
104 let line = parts.join("\t");
105 tracing::info!(target: "alc.lua.print", "{}", line);
107 let message = line.trim_end_matches('\n').to_string();
109 log_sink.push(LogEntry::new("info", "alc.lua.print", message));
110 Ok(())
111 })?;
112 lua.globals().set("print", print_fn)?;
113 Ok(())
114}
115
116pub(super) fn register_state(
134 lua: &Lua,
135 alc_table: &LuaTable,
136 ns: String,
137 state_store: Arc<JsonFileStore>,
138) -> LuaResult<()> {
139 let state_table = lua.create_table()?;
140
141 let ns_get = ns.clone();
143 let store_get = Arc::clone(&state_store);
144 let get =
145 lua.create_function(
146 move |lua, (key, default): (String, Option<LuaValue>)| match store_get
147 .get(&ns_get, &key)
148 {
149 Ok(Some(v)) => lua.to_value(&v),
150 Ok(None) => Ok(default.unwrap_or(LuaValue::Nil)),
151 Err(e) => Err(LuaError::external(e)),
152 },
153 )?;
154
155 let ns_set = ns.clone();
157 let store_set = Arc::clone(&state_store);
158 let set = lua.create_function(move |lua, (key, value): (String, LuaValue)| {
159 let json: serde_json::Value = lua.from_value(value)?;
160 store_set
161 .set(&ns_set, &key, json)
162 .map_err(LuaError::external)
163 })?;
164
165 let ns_keys = ns.clone();
167 let store_keys = Arc::clone(&state_store);
168 let keys = lua.create_function(move |lua, ()| {
169 let k = store_keys.keys(&ns_keys).map_err(LuaError::external)?;
170 lua.to_value(&k)
171 })?;
172
173 let ns_del = ns.clone();
175 let store_del = Arc::clone(&state_store);
176 let delete = lua.create_function(move |_, key: String| {
177 store_del.delete(&ns_del, &key).map_err(LuaError::external)
178 })?;
179
180 let ns_has = ns.clone();
182 let store_has = Arc::clone(&state_store);
183 let has = lua.create_function(move |_, key: String| {
184 store_has.has(&ns_has, &key).map_err(LuaError::external)
185 })?;
186
187 let ns_snx = ns.clone();
189 let store_snx = Arc::clone(&state_store);
190 let set_nx = lua.create_function(move |lua, (key, value): (String, LuaValue)| {
191 let json: serde_json::Value = lua.from_value(value)?;
192 store_snx
193 .set_nx(&ns_snx, &key, json)
194 .map_err(LuaError::external)
195 })?;
196
197 let ns_incr = ns;
199 let store_incr = Arc::clone(&state_store);
200 let incr = lua.create_function(
201 move |_, (key, delta, default): (String, Option<f64>, Option<f64>)| {
202 store_incr
203 .incr(&ns_incr, &key, delta.unwrap_or(1.0), default.unwrap_or(0.0))
204 .map_err(LuaError::external)
205 },
206 )?;
207
208 let store_list = Arc::clone(&state_store);
210 let list = lua.create_function(move |lua, namespace: String| {
211 let keys = store_list
212 .list_dispatched(&namespace)
213 .map_err(LuaError::external)?;
214 lua.to_value(&keys)
215 })?;
216
217 let store_show = Arc::clone(&state_store);
219 let show = lua.create_function(move |lua, (namespace, key): (String, String)| {
220 let v = store_show
221 .show_dispatched(&namespace, &key)
222 .map_err(LuaError::external)?;
223 lua.to_value(&v)
224 })?;
225
226 let store_reset = Arc::clone(&state_store);
228 let reset = lua.create_function(
229 move |lua, (namespace, key, opts): (String, String, Option<LuaTable>)| {
230 let (steps, fields) = match opts {
231 Some(t) => {
232 let s = t.get::<Option<Vec<String>>>("steps")?.unwrap_or_default();
233 let f = t.get::<Option<Vec<String>>>("fields")?.unwrap_or_default();
234 (s, f)
235 }
236 None => (Vec::new(), Vec::new()),
237 };
238 let report = store_reset
239 .reset_dispatched_with_backup(&namespace, &key, &steps, &fields)
240 .map_err(LuaError::external)?;
241 let ret = lua.create_table()?;
242 ret.set("ok", true)?;
243 ret.set(
244 "backup_path",
245 report.backup_path.to_string_lossy().to_string(),
246 )?;
247 ret.set("steps_removed", report.steps_removed)?;
248 ret.set("fields_removed", report.fields_removed)?;
249 Ok(ret)
250 },
251 )?;
252
253 state_table.set("get", get)?;
254 state_table.set("set", set)?;
255 state_table.set("keys", keys)?;
256 state_table.set("delete", delete)?;
257 state_table.set("has", has)?;
258 state_table.set("set_nx", set_nx)?;
259 state_table.set("incr", incr)?;
260 state_table.set("list", list)?;
261 state_table.set("show", show)?;
262 state_table.set("reset", reset)?;
263
264 alc_table.set("state", state_table)?;
265 Ok(())
266}
267
268pub(super) fn register_dirs(
274 lua: &Lua,
275 alc_table: &LuaTable,
276 state_dir: &Path,
277 cards_dir: &Path,
278 scenarios_dir: &Path,
279) -> LuaResult<()> {
280 let dirs = lua.create_table()?;
281 dirs.set("state", state_dir.to_string_lossy().into_owned())?;
282 dirs.set("cards", cards_dir.to_string_lossy().into_owned())?;
283 dirs.set("scenarios", scenarios_dir.to_string_lossy().into_owned())?;
284 alc_table.set("_dirs", dirs)?;
285 Ok(())
286}
287
288pub(super) fn register_card(
313 lua: &Lua,
314 alc_table: &LuaTable,
315 card_store: Arc<FileCardStore>,
316) -> LuaResult<()> {
317 let card_table = lua.create_table()?;
318
319 let store_create = Arc::clone(&card_store);
321 let create = lua.create_function(move |lua, input: LuaValue| {
322 let json: serde_json::Value = lua.from_value(input)?;
323 let (card_id, path) = store_create.create(json).map_err(LuaError::external)?;
324 let ret = lua.create_table()?;
325 ret.set("card_id", card_id)?;
326 ret.set("path", path.to_string_lossy().to_string())?;
327 Ok(ret)
328 })?;
329
330 let store_get = Arc::clone(&card_store);
332 let get = lua.create_function(move |lua, card_id: String| match store_get.get(&card_id) {
333 Ok(Some(v)) => lua.to_value(&v),
334 Ok(None) => Ok(LuaValue::Nil),
335 Err(e) => Err(LuaError::external(e)),
336 })?;
337
338 let store_list = Arc::clone(&card_store);
340 let list = lua.create_function(move |lua, filter: Option<LuaTable>| {
341 let pkg = match filter {
342 Some(t) => t.get::<Option<String>>("pkg")?,
343 None => None,
344 };
345 let rows = store_list
346 .list(pkg.as_deref())
347 .map_err(LuaError::external)?;
348 lua.to_value(&card::summaries_to_json(&rows))
349 })?;
350
351 let store_append = Arc::clone(&card_store);
353 let append = lua.create_function(move |lua, (card_id, fields): (String, LuaValue)| {
354 let json: serde_json::Value = lua.from_value(fields)?;
355 let merged = store_append
356 .append(&card_id, json)
357 .map_err(LuaError::external)?;
358 lua.to_value(&merged)
359 })?;
360
361 let store_gba = Arc::clone(&card_store);
363 let get_by_alias = lua.create_function(move |lua, name: String| {
364 match store_gba.get_by_alias(&name).map_err(LuaError::external)? {
365 Some(v) => lua.to_value(&v),
366 None => Ok(LuaValue::Nil),
367 }
368 })?;
369
370 let store_aset = Arc::clone(&card_store);
372 let alias_set = lua.create_function(
373 move |lua, (name, card_id, opts): (String, String, Option<LuaTable>)| {
374 let (pkg, note) = match opts {
375 Some(t) => (
376 t.get::<Option<String>>("pkg")?,
377 t.get::<Option<String>>("note")?,
378 ),
379 None => (None, None),
380 };
381 let a = store_aset
382 .alias_set(&name, &card_id, pkg.as_deref(), note.as_deref())
383 .map_err(LuaError::external)?;
384 let arr = card::aliases_to_json(&[a]);
385 let first = match arr {
386 serde_json::Value::Array(mut v) if !v.is_empty() => v.remove(0),
387 other => other,
388 };
389 lua.to_value(&first)
390 },
391 )?;
392
393 let store_alist = Arc::clone(&card_store);
395 let alias_list = lua.create_function(move |lua, filter: Option<LuaTable>| {
396 let pkg = match filter {
397 Some(t) => t.get::<Option<String>>("pkg")?,
398 None => None,
399 };
400 let rows = store_alist
401 .alias_list(pkg.as_deref())
402 .map_err(LuaError::external)?;
403 lua.to_value(&card::aliases_to_json(&rows))
404 })?;
405
406 let store_find = Arc::clone(&card_store);
411 let find = lua.create_function(move |lua, query: Option<LuaTable>| {
412 let q = match query {
413 Some(t) => {
414 let pkg = t.get::<Option<String>>("pkg")?;
415 let limit = t.get::<Option<usize>>("limit")?;
416 let offset = t.get::<Option<usize>>("offset")?;
417
418 let where_parsed = match t.get::<LuaValue>("where")? {
419 LuaValue::Nil => None,
420 v => {
421 let json: serde_json::Value = lua.from_value(v)?;
422 Some(card::parse_where(&json).map_err(LuaError::external)?)
423 }
424 };
425 let order_parsed = match t.get::<LuaValue>("order_by")? {
426 LuaValue::Nil => Vec::new(),
427 v => {
428 let json: serde_json::Value = lua.from_value(v)?;
429 card::parse_order_by(&json).map_err(LuaError::external)?
430 }
431 };
432
433 card::FindQuery {
434 pkg,
435 where_: where_parsed,
436 order_by: order_parsed,
437 limit,
438 offset,
439 }
440 }
441 None => card::FindQuery::default(),
442 };
443 let rows = store_find.find(q).map_err(LuaError::external)?;
444 lua.to_value(&card::summaries_to_json(&rows))
445 })?;
446
447 let store_ws = Arc::clone(&card_store);
449 let write_samples =
450 lua.create_function(move |lua, (card_id, samples): (String, LuaValue)| {
451 let json: serde_json::Value = lua.from_value(samples)?;
452 let arr = match json {
453 serde_json::Value::Array(a) => a,
454 _ => {
455 return Err(LuaError::external(
456 "alc.card.write_samples: samples must be an array",
457 ))
458 }
459 };
460 let count = arr.len();
461 let path = store_ws
462 .write_samples(&card_id, arr)
463 .map_err(LuaError::external)?;
464 let ret = lua.create_table()?;
465 ret.set("path", path.to_string_lossy().to_string())?;
466 ret.set("count", count)?;
467 Ok(ret)
468 })?;
469
470 let store_rs = Arc::clone(&card_store);
475 let read_samples =
476 lua.create_function(move |lua, (card_id, opts): (String, Option<LuaTable>)| {
477 let (offset, limit, where_parsed) = match opts {
478 Some(t) => {
479 let offset = t.get::<Option<usize>>("offset")?.unwrap_or(0);
480 let limit = t.get::<Option<usize>>("limit")?;
481 let where_parsed = match t.get::<LuaValue>("where")? {
482 LuaValue::Nil => None,
483 v => {
484 let json: serde_json::Value = lua.from_value(v)?;
485 Some(card::parse_where(&json).map_err(LuaError::external)?)
486 }
487 };
488 (offset, limit, where_parsed)
489 }
490 None => (0, None, None),
491 };
492 let q = card::SamplesQuery {
493 offset,
494 limit,
495 where_: where_parsed,
496 };
497 let rows = store_rs
498 .read_samples(&card_id, q)
499 .map_err(LuaError::external)?;
500 lua.to_value(&serde_json::Value::Array(rows))
501 })?;
502
503 let store_sb = Arc::clone(&card_store);
508 let sink_backfill = lua.create_function(move |lua, params: LuaTable| {
509 let sink: String = params.get("sink")?;
510 let dry_run: Option<bool> = params.get("dry_run")?;
511 let report = store_sb
512 .card_sink_backfill(&sink, dry_run.unwrap_or(false))
513 .map_err(LuaError::external)?;
514 lua.to_value(&report)
515 })?;
516
517 let store_lin = Arc::clone(&card_store);
522 let lineage = lua.create_function(move |lua, query: LuaTable| {
523 let card_id: String = query.get("card_id")?;
524 let direction_str: Option<String> = query.get("direction")?;
525 let direction = match direction_str.as_deref() {
526 Some(s) => card::LineageDirection::parse(s).map_err(LuaError::external)?,
527 None => card::LineageDirection::Up,
528 };
529 let depth: Option<usize> = query.get("depth")?;
530 let include_stats: Option<bool> = query.get("include_stats")?;
531 let relation_filter: Option<Vec<String>> = match query.get::<LuaValue>("relation_filter")? {
532 LuaValue::Nil => None,
533 v => Some(lua.from_value(v)?),
534 };
535
536 let q = card::LineageQuery {
537 card_id,
538 direction,
539 depth,
540 include_stats: include_stats.unwrap_or(true),
541 relation_filter,
542 };
543 match store_lin.lineage(q).map_err(LuaError::external)? {
544 Some(res) => lua.to_value(&card::lineage_to_json(&res)),
545 None => Ok(LuaValue::Nil),
546 }
547 })?;
548
549 card_table.set("create", create)?;
550 card_table.set("get", get)?;
551 card_table.set("list", list)?;
552 card_table.set("append", append)?;
553 card_table.set("get_by_alias", get_by_alias)?;
554 card_table.set("alias_set", alias_set)?;
555 card_table.set("alias_list", alias_list)?;
556 card_table.set("find", find)?;
557 card_table.set("write_samples", write_samples)?;
558 card_table.set("read_samples", read_samples)?;
559 card_table.set("lineage", lineage)?;
560 card_table.set("sink_backfill", sink_backfill)?;
561
562 alc_table.set("card", card_table)?;
563 Ok(())
564}
565
566pub(super) fn register_stats(
579 lua: &Lua,
580 alc_table: &LuaTable,
581 custom_metrics: CustomMetricsHandle,
582 stats: StatsHandle,
583) -> LuaResult<()> {
584 let stats_table = lua.create_table()?;
585
586 let cm_record = custom_metrics.clone();
588 let record = lua.create_function(move |lua, (key, value): (String, LuaValue)| {
589 let json: serde_json::Value = lua.from_value(value)?;
590 cm_record.record(key, json);
591 Ok(())
592 })?;
593
594 let cm_get = custom_metrics;
596 let get = lua.create_function(move |lua, key: String| match cm_get.get(&key) {
597 Some(v) => lua.to_value(&v),
598 None => Ok(LuaValue::Nil),
599 })?;
600
601 let stats_handle = stats;
603 let llm_calls = lua.create_function(move |_, ()| Ok(stats_handle.llm_calls()))?;
604
605 stats_table.set("record", record)?;
606 stats_table.set("get", get)?;
607 stats_table.set("llm_calls", llm_calls)?;
608
609 alc_table.set("stats", stats_table)?;
610 Ok(())
611}
612
613pub struct AlcEnv(pub Arc<HashMap<String, String>>);
624
625impl mlua::UserData for AlcEnv {
626 fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M) {
627 methods.add_meta_method(mlua::MetaMethod::Index, |_, this, key: String| {
629 Ok(this.0.get(&key).cloned())
630 });
631
632 methods.add_meta_method(
635 mlua::MetaMethod::NewIndex,
636 |_, _, (_k, _v): (mlua::Value, mlua::Value)| {
637 Err::<(), _>(mlua::Error::external("alc.env is readonly"))
638 },
639 );
640
641 methods.add_method(
643 "get",
644 |_, this, (key, default): (String, Option<String>)| {
645 Ok(this.0.get(&key).cloned().or(default))
646 },
647 );
648
649 methods.add_method("use", |lua, this, declared: Vec<String>| {
653 let proxy = lua.create_table()?;
654 for k in &declared {
655 if let Some(v) = this.0.get(k) {
656 proxy.set(k.clone(), v.clone())?;
657 }
658 }
659 Ok(proxy)
660 });
661 }
662}
663
664pub fn register_env(
672 lua: &mlua::Lua,
673 alc_table: &mlua::Table,
674 env_map: Arc<HashMap<String, String>>,
675) -> mlua::Result<()> {
676 alc_table.set("env", AlcEnv(Arc::clone(&env_map)))?;
677 lua.set_app_data(env_map);
678 Ok(())
679}
680
681#[cfg(test)]
682mod tests {
683 use super::*;
684 use algocline_core::ExecutionMetrics;
685
686 fn test_config_with(ns: &str) -> crate::bridge::BridgeConfig {
690 let metrics = ExecutionMetrics::new();
691 let tmp = tempfile::tempdir().expect("test tempdir");
692 let root = tmp.path().to_path_buf();
693 std::mem::forget(tmp);
694 crate::bridge::BridgeConfig {
695 llm_tx: None,
696 ns: ns.into(),
697 custom_metrics: metrics.custom_metrics_handle(),
698 stats: metrics.stats_handle(),
699 budget: metrics.budget_handle(),
700 progress: metrics.progress_handle(),
701 lib_paths: vec![],
702 variant_pkgs: vec![],
703 state_store: Arc::new(JsonFileStore::new(root.join("state"))),
704 card_store: Arc::new(FileCardStore::new(root.join("cards"))),
705 scenarios_dir: root.join("scenarios"),
706 log_sink: None,
707 }
708 }
709
710 fn test_config() -> crate::bridge::BridgeConfig {
711 test_config_with("default")
712 }
713
714 fn test_config_with_ns(ns: &str) -> crate::bridge::BridgeConfig {
715 test_config_with(ns)
716 }
717
718 #[test]
719 fn json_roundtrip() {
720 let lua = Lua::new();
721 let t = lua.create_table().unwrap();
722 crate::bridge::register(&lua, &t, test_config()).unwrap();
723 lua.globals().set("alc", t).unwrap();
724
725 let result: String = lua
726 .load(r#"return alc.json_encode({hello = "world", n = 42})"#)
727 .eval()
728 .unwrap();
729 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
730 assert_eq!(parsed["hello"], "world");
731 assert_eq!(parsed["n"], 42);
732 }
733
734 #[test]
735 fn json_decode_encode() {
736 let lua = Lua::new();
737 let t = lua.create_table().unwrap();
738 crate::bridge::register(&lua, &t, test_config()).unwrap();
739 lua.globals().set("alc", t).unwrap();
740
741 let result: String = lua
742 .load(
743 r#"
744 local val = alc.json_decode('{"a":1,"b":"two"}')
745 val.c = true
746 return alc.json_encode(val)
747 "#,
748 )
749 .eval()
750 .unwrap();
751 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
752 assert_eq!(parsed["a"], 1);
753 assert_eq!(parsed["b"], "two");
754 assert_eq!(parsed["c"], true);
755 }
756
757 #[test]
758 fn state_get_set() {
759 let ns = "_test_bridge_state";
762
763 let lua = Lua::new();
764 let t = lua.create_table().unwrap();
765 crate::bridge::register(&lua, &t, test_config_with_ns(ns)).unwrap();
766 lua.globals().set("alc", t).unwrap();
767
768 lua.load(r#"alc.state.set("x", 99)"#).exec().unwrap();
770 let result: i64 = lua.load(r#"return alc.state.get("x")"#).eval().unwrap();
771 assert_eq!(result, 99);
772
773 let result: i64 = lua
775 .load(r#"return alc.state.get("missing", 0)"#)
776 .eval()
777 .unwrap();
778 assert_eq!(result, 0);
779
780 let result: LuaValue = lua
782 .load(r#"return alc.state.get("missing")"#)
783 .eval()
784 .unwrap();
785 assert!(result.is_nil());
786 }
787
788 #[test]
789 fn state_has_set_nx_incr() {
790 let ns = "_test_bridge_state_t1";
791
792 let lua = Lua::new();
793 let t = lua.create_table().unwrap();
794 crate::bridge::register(&lua, &t, test_config_with_ns(ns)).unwrap();
795 lua.globals().set("alc", t).unwrap();
796
797 let h: bool = lua.load(r#"return alc.state.has("k")"#).eval().unwrap();
799 assert!(!h);
800
801 let ok: bool = lua
803 .load(r#"return alc.state.set_nx("k", "first")"#)
804 .eval()
805 .unwrap();
806 assert!(ok);
807
808 let h: bool = lua.load(r#"return alc.state.has("k")"#).eval().unwrap();
810 assert!(h);
811
812 let ok: bool = lua
814 .load(r#"return alc.state.set_nx("k", "second")"#)
815 .eval()
816 .unwrap();
817 assert!(!ok);
818
819 let v: f64 = lua
821 .load(r#"return alc.state.incr("counter")"#)
822 .eval()
823 .unwrap();
824 assert!((v - 1.0).abs() < f64::EPSILON);
825
826 let v: f64 = lua
828 .load(r#"return alc.state.incr("counter", 5)"#)
829 .eval()
830 .unwrap();
831 assert!((v - 6.0).abs() < f64::EPSILON);
832
833 let v: f64 = lua
835 .load(r#"return alc.state.incr("counter", 10, 100)"#)
836 .eval()
837 .unwrap();
838 assert!((v - 16.0).abs() < f64::EPSILON);
839 }
840
841 #[test]
842 fn card_create_get_list_from_lua() {
843 let ns = std::time::SystemTime::now()
845 .duration_since(std::time::UNIX_EPOCH)
846 .unwrap()
847 .as_nanos();
848 let pkg = format!("_test_bridge_card_{ns}");
849
850 let lua = Lua::new();
851 let t = lua.create_table().unwrap();
852 crate::bridge::register(&lua, &t, test_config()).unwrap();
853 lua.globals().set("alc", t).unwrap();
854
855 let create_script = format!(
857 r#"
858 local r = alc.card.create({{
859 pkg = {{ name = "{pkg}" }},
860 model = {{ id = "claude-opus-4-6" }},
861 stats = {{ pass_rate = 0.9 }},
862 }})
863 return r.card_id
864 "#
865 );
866 let card_id: String = lua.load(&create_script).eval().unwrap();
867 assert!(card_id.starts_with(&pkg));
868
869 let get_script = format!(r#"return alc.card.get("{card_id}").stats.pass_rate"#);
871 let rate: f64 = lua.load(&get_script).eval().unwrap();
872 assert!((rate - 0.9).abs() < 1e-9);
873
874 let list_script = format!(
876 r#"
877 local rows = alc.card.list({{ pkg = "{pkg}" }})
878 return #rows
879 "#
880 );
881 let count: i64 = lua.load(&list_script).eval().unwrap();
882 assert_eq!(count, 1);
883
884 }
886
887 #[test]
888 fn stats_record_get() {
889 let metrics = ExecutionMetrics::new();
890 let custom_handle = metrics.custom_metrics_handle();
891 let lua = Lua::new();
892 let t = lua.create_table().unwrap();
893 let tmp = tempfile::tempdir().expect("test tempdir");
894 let root = tmp.path().to_path_buf();
895 std::mem::forget(tmp);
896 crate::bridge::register(
897 &lua,
898 &t,
899 crate::bridge::BridgeConfig {
900 llm_tx: None,
901 ns: "default".into(),
902 custom_metrics: custom_handle.clone(),
903 stats: metrics.stats_handle(),
904 budget: metrics.budget_handle(),
905 progress: metrics.progress_handle(),
906 lib_paths: vec![],
907 variant_pkgs: vec![],
908 state_store: Arc::new(JsonFileStore::new(root.join("state"))),
909 card_store: Arc::new(FileCardStore::new(root.join("cards"))),
910 scenarios_dir: root.join("scenarios"),
911 log_sink: None,
912 },
913 )
914 .unwrap();
915 lua.globals().set("alc", t).unwrap();
916
917 lua.load(r#"alc.stats.record("score", 42)"#).exec().unwrap();
919 let result: i64 = lua.load(r#"return alc.stats.get("score")"#).eval().unwrap();
920 assert_eq!(result, 42);
921
922 assert_eq!(custom_handle.get("score"), Some(serde_json::json!(42)));
924
925 let result: LuaValue = lua
927 .load(r#"return alc.stats.get("missing")"#)
928 .eval()
929 .unwrap();
930 assert!(result.is_nil());
931 }
932
933 #[test]
938 fn stats_llm_calls_reads_session_status() {
939 use crate::card::FileCardStore;
940 use crate::state::JsonFileStore;
941 use algocline_core::{ExecutionObserver, LlmQuery, QueryId};
942 use std::sync::Arc;
943
944 let metrics = ExecutionMetrics::new();
945 let observer = metrics.create_observer();
946
947 let lua = Lua::new();
948 let t = lua.create_table().unwrap();
949 let tmp = tempfile::tempdir().expect("test tempdir");
950 let root = tmp.path().to_path_buf();
951 std::mem::forget(tmp);
952 crate::bridge::register(
953 &lua,
954 &t,
955 crate::bridge::BridgeConfig {
956 llm_tx: None,
957 ns: "default".into(),
958 custom_metrics: metrics.custom_metrics_handle(),
959 stats: metrics.stats_handle(),
960 budget: metrics.budget_handle(),
961 progress: metrics.progress_handle(),
962 lib_paths: vec![],
963 variant_pkgs: vec![],
964 state_store: Arc::new(JsonFileStore::new(root.join("state"))),
965 card_store: Arc::new(FileCardStore::new(root.join("cards"))),
966 scenarios_dir: root.join("scenarios"),
967 log_sink: None,
968 },
969 )
970 .unwrap();
971 lua.globals().set("alc", t).unwrap();
972
973 let initial: u64 = lua.load(r#"return alc.stats.llm_calls()"#).eval().unwrap();
975 assert_eq!(initial, 0, "fresh session must report llm_calls() == 0");
976
977 observer.on_paused(&[LlmQuery {
979 id: QueryId::parse("q-0"),
980 prompt: "hi".to_string(),
981 system: None,
982 max_tokens: 0,
983 grounded: false,
984 underspecified: false,
985 }]);
986
987 let after_one: u64 = lua.load(r#"return alc.stats.llm_calls()"#).eval().unwrap();
989 assert_eq!(
990 after_one, 1,
991 "one paused query must increment llm_calls() to 1"
992 );
993
994 observer.on_paused(&[
996 LlmQuery {
997 id: QueryId::parse("q-1"),
998 prompt: "a".to_string(),
999 system: None,
1000 max_tokens: 0,
1001 grounded: false,
1002 underspecified: false,
1003 },
1004 LlmQuery {
1005 id: QueryId::parse("q-2"),
1006 prompt: "b".to_string(),
1007 system: None,
1008 max_tokens: 0,
1009 grounded: false,
1010 underspecified: false,
1011 },
1012 ]);
1013
1014 let after_three: u64 = lua.load(r#"return alc.stats.llm_calls()"#).eval().unwrap();
1015 assert_eq!(
1016 after_three, 3,
1017 "two further paused queries (multi-query batch) must bring llm_calls() to 3"
1018 );
1019 }
1020
1021 #[test]
1025 fn register_log_pushes_to_log_sink() {
1026 use algocline_core::LogSink;
1027
1028 let sink = LogSink::new();
1029 let lua = Lua::new();
1030 let t = lua.create_table().unwrap();
1031 register_log(&lua, &t, sink.clone()).unwrap();
1033 lua.globals().set("alc", t).unwrap();
1034
1035 lua.load(r#"alc.log("info", "hello-from-log")"#)
1036 .exec()
1037 .unwrap();
1039
1040 let entries = sink.entries();
1041 assert_eq!(entries.len(), 1);
1042 assert_eq!(entries[0].source, "alc.log");
1043 assert_eq!(entries[0].level, "info");
1044 assert_eq!(entries[0].message, "hello-from-log");
1045 }
1046
1047 #[test]
1049 fn register_log_unknown_level_still_pushes() {
1050 use algocline_core::LogSink;
1051
1052 let sink = LogSink::new();
1053 let lua = Lua::new();
1054 let t = lua.create_table().unwrap();
1055 register_log(&lua, &t, sink.clone()).unwrap();
1057 lua.globals().set("alc", t).unwrap();
1058
1059 lua.load(r#"alc.log("custom", "edge-case")"#)
1060 .exec()
1061 .unwrap();
1063
1064 let entries = sink.entries();
1065 assert_eq!(entries.len(), 1);
1066 assert_eq!(entries[0].source, "alc.log");
1067 assert_eq!(entries[0].level, "custom");
1069 assert_eq!(entries[0].message, "edge-case");
1070 }
1071
1072 #[test]
1074 fn register_log_empty_message() {
1075 use algocline_core::LogSink;
1076
1077 let sink = LogSink::new();
1078 let lua = Lua::new();
1079 let t = lua.create_table().unwrap();
1080 register_log(&lua, &t, sink.clone()).unwrap();
1082 lua.globals().set("alc", t).unwrap();
1083
1084 lua.load(r#"alc.log("warn", "")"#)
1085 .exec()
1086 .unwrap();
1088
1089 let entries = sink.entries();
1090 assert_eq!(entries.len(), 1);
1091 assert_eq!(entries[0].message, "");
1092 }
1093
1094 #[test]
1098 fn register_print_pushes_to_log_sink() {
1099 use algocline_core::LogSink;
1100
1101 let sink = LogSink::new();
1102 let lua = Lua::new();
1103 register_print(&lua, sink.clone()).unwrap();
1105
1106 lua.load(r#"print("hello-print")"#)
1107 .exec()
1108 .unwrap();
1110
1111 let entries = sink.entries();
1112 assert_eq!(entries.len(), 1);
1113 assert_eq!(entries[0].source, "alc.lua.print");
1114 assert_eq!(entries[0].level, "info");
1115 assert_eq!(entries[0].message, "hello-print");
1116 }
1117
1118 #[test]
1120 fn register_print_multiple_args_tab_joined() {
1121 use algocline_core::LogSink;
1122
1123 let sink = LogSink::new();
1124 let lua = Lua::new();
1125 register_print(&lua, sink.clone()).unwrap();
1127
1128 lua.load(r#"print("a", "b", "c")"#)
1129 .exec()
1130 .unwrap();
1132
1133 let entries = sink.entries();
1134 assert_eq!(entries.len(), 1);
1135 assert_eq!(entries[0].message, "a\tb\tc");
1136 }
1137
1138 #[test]
1140 fn register_print_mixed_value_types() {
1141 use algocline_core::LogSink;
1142
1143 let sink = LogSink::new();
1144 let lua = Lua::new();
1145 register_print(&lua, sink.clone()).unwrap();
1147
1148 lua.load(r#"print(nil, true, 42, 3.14)"#)
1149 .exec()
1150 .unwrap();
1152
1153 let entries = sink.entries();
1154 assert_eq!(entries.len(), 1);
1155 let msg = &entries[0].message;
1157 assert!(msg.starts_with("nil\ttrue\t42\t"), "got: {msg}");
1158 }
1159
1160 fn make_env_lua(pairs: &[(&str, &str)]) -> (Lua, Arc<HashMap<String, String>>) {
1163 let mut map = HashMap::new();
1164 for (k, v) in pairs {
1165 map.insert(k.to_string(), v.to_string());
1166 }
1167 let env_map = Arc::new(map);
1168 let lua = Lua::new();
1169 let alc_table = lua.create_table().unwrap();
1170 register_env(&lua, &alc_table, Arc::clone(&env_map)).unwrap();
1171 lua.globals().set("alc", alc_table).unwrap();
1172 (lua, env_map)
1173 }
1174
1175 #[test]
1176 fn env_index_reads_existing_key() {
1177 let (lua, _) = make_env_lua(&[("FOO", "bar")]);
1178 let val: Option<String> = lua.load(r#"return alc.env.FOO"#).eval().unwrap();
1179 assert_eq!(val, Some("bar".to_string()));
1180 }
1181
1182 #[test]
1183 fn env_index_missing_key_returns_nil() {
1184 let (lua, _) = make_env_lua(&[("FOO", "bar")]);
1185 let val: LuaValue = lua.load(r#"return alc.env.MISSING"#).eval().unwrap();
1186 assert!(val.is_nil());
1187 }
1188
1189 #[test]
1190 fn env_newindex_returns_error() {
1191 let (lua, _) = make_env_lua(&[("FOO", "bar")]);
1192 let result: Result<(), _> = lua.load(r#"alc.env.FOO = "x""#).exec();
1193 let err = result.unwrap_err().to_string();
1194 assert!(
1195 err.contains("alc.env is readonly"),
1196 "expected readonly error, got: {err}"
1197 );
1198 }
1199
1200 #[test]
1201 fn env_get_with_default_returns_default_on_miss() {
1202 let (lua, _) = make_env_lua(&[]);
1203 let val: Option<String> = lua
1204 .load(r#"return alc.env:get("MISSING", "fallback")"#)
1205 .eval()
1206 .unwrap();
1207 assert_eq!(val, Some("fallback".to_string()));
1208 }
1209
1210 #[test]
1211 fn env_get_returns_value_when_present() {
1212 let (lua, _) = make_env_lua(&[("KEY", "val")]);
1213 let val: Option<String> = lua
1214 .load(r#"return alc.env:get("KEY", "default")"#)
1215 .eval()
1216 .unwrap();
1217 assert_eq!(val, Some("val".to_string()));
1218 }
1219
1220 #[test]
1221 fn env_use_returns_declared_keys_only() {
1222 let (lua, _) = make_env_lua(&[("FOO", "foo_val"), ("BAR", "bar_val"), ("SECRET", "s")]);
1223 let result: LuaValue = lua
1224 .load(
1225 r#"
1226 local e = alc.env:use{"FOO", "BAR"}
1227 return e
1228 "#,
1229 )
1230 .eval()
1231 .unwrap();
1232 let tbl = result.as_table().unwrap();
1233 assert_eq!(tbl.get::<String>("FOO").unwrap(), "foo_val");
1234 assert_eq!(tbl.get::<String>("BAR").unwrap(), "bar_val");
1235 let secret: LuaValue = tbl.get("SECRET").unwrap();
1237 assert!(secret.is_nil(), "SECRET should be nil in proxy");
1238 }
1239
1240 #[test]
1241 fn env_use_undeclared_key_is_nil() {
1242 let (lua, _) = make_env_lua(&[("FOO", "foo_val")]);
1243 let val: LuaValue = lua
1244 .load(
1245 r#"
1246 local e = alc.env:use{"FOO"}
1247 return e.UNDECLARED
1248 "#,
1249 )
1250 .eval()
1251 .unwrap();
1252 assert!(val.is_nil());
1253 }
1254
1255 #[test]
1256 fn register_env_sets_app_data() {
1257 let mut map = HashMap::new();
1258 map.insert("X".to_string(), "1".to_string());
1259 let env_map = Arc::new(map);
1260 let lua = Lua::new();
1261 let alc_table = lua.create_table().unwrap();
1262 register_env(&lua, &alc_table, Arc::clone(&env_map)).unwrap();
1263 let retrieved = lua.app_data_ref::<Arc<HashMap<String, String>>>().unwrap();
1265 assert_eq!(retrieved.get("X").unwrap(), "1");
1266 }
1267
1268 mod state_dispatched_lua {
1269 use super::*;
1270 use mlua::Lua;
1271 use std::sync::Arc;
1272 use tempfile::TempDir;
1273
1274 fn setup() -> (Lua, Arc<JsonFileStore>, TempDir) {
1275 let tmp = tempfile::tempdir().unwrap();
1276 let store = Arc::new(JsonFileStore::new(tmp.path().to_path_buf()));
1277 let lua = Lua::new();
1278 let alc = lua.create_table().unwrap();
1279 register_state(&lua, &alc, "default".to_string(), Arc::clone(&store)).unwrap();
1280 lua.globals().set("alc", alc).unwrap();
1281 (lua, store, tmp)
1282 }
1283
1284 #[test]
1285 fn list_returns_sorted_keys() {
1286 let (lua, _store, tmp) = setup();
1287 std::fs::create_dir_all(tmp.path().join("testns")).unwrap();
1289 std::fs::write(
1290 tmp.path().join("testns/beta.json"),
1291 r#"{"data": {"completed_steps": [], "x": 1}}"#,
1292 )
1293 .unwrap();
1294 std::fs::write(
1295 tmp.path().join("testns/alpha.json"),
1296 r#"{"data": {"completed_steps": [], "y": 2}}"#,
1297 )
1298 .unwrap();
1299 lua.load(
1300 r#"
1301 local result = alc.state.list("testns")
1302 assert(#result == 2, "expected 2 keys, got " .. #result)
1303 assert(result[1] == "alpha", "first key should be alpha, got " .. tostring(result[1]))
1304 assert(result[2] == "beta", "second key should be beta, got " .. tostring(result[2]))
1305 "#,
1306 )
1307 .exec()
1308 .unwrap();
1309 }
1310
1311 #[test]
1312 fn show_returns_full_table() {
1313 let (lua, _store, tmp) = setup();
1314 std::fs::create_dir_all(tmp.path().join("testns")).unwrap();
1315 std::fs::write(
1316 tmp.path().join("testns/alpha.json"),
1317 r#"{"data": {"completed_steps": ["a", "b", "c"], "x": 1, "y": 2}}"#,
1318 )
1319 .unwrap();
1320 lua.load(
1321 r#"
1322 local result = alc.state.show("testns", "alpha")
1323 assert(type(result) == "table", "expected table")
1324 assert(type(result.data) == "table", "expected result.data to be a table")
1325 assert(result.data.x == 1, "expected x=1")
1326 assert(result.data.y == 2, "expected y=2")
1327 assert(#result.data.completed_steps == 3, "expected 3 steps")
1328 "#,
1329 )
1330 .exec()
1331 .unwrap();
1332 }
1333
1334 #[test]
1335 fn show_missing_returns_not_found_error() {
1336 let (lua, _store, _tmp) = setup();
1337 lua.load(
1338 r#"
1339 local ok, err = pcall(alc.state.show, "testns", "missing")
1340 assert(not ok, "expected error but got success")
1341 local msg = tostring(err)
1342 assert(string.find(msg, "not found"), "error message should contain 'not found', got: " .. msg)
1343 "#,
1344 )
1345 .exec()
1346 .unwrap();
1347 }
1348
1349 #[test]
1350 fn reset_removes_steps_and_fields_with_backup() {
1351 let (lua, _store, tmp) = setup();
1352 std::fs::create_dir_all(tmp.path().join("testns")).unwrap();
1353 let file_path = tmp.path().join("testns/alpha.json");
1354 std::fs::write(
1355 &file_path,
1356 r#"{"data": {"completed_steps": ["a", "b", "c"], "x": 1, "y": 2}}"#,
1357 )
1358 .unwrap();
1359 let tmp_path_str = tmp.path().to_string_lossy().to_string();
1361 lua.globals().set("TMP_PATH", tmp_path_str.clone()).unwrap();
1362 lua.load(
1363 r#"
1364 local r = alc.state.reset("testns", "alpha", {steps={"b"}, fields={"x"}})
1365 assert(r.ok == true, "expected ok=true")
1366 assert(type(r.backup_path) == "string", "backup_path should be a string")
1367 assert(r.steps_removed == 1, "expected steps_removed=1, got " .. tostring(r.steps_removed))
1368 assert(r.fields_removed == 1, "expected fields_removed=1, got " .. tostring(r.fields_removed))
1369 "#,
1370 )
1371 .exec()
1372 .unwrap();
1373 let bak_path = tmp.path().join("testns/alpha.json.bak");
1375 assert!(
1376 bak_path.exists(),
1377 "backup file should exist at {:?}",
1378 bak_path
1379 );
1380 let bak_content = std::fs::read_to_string(&bak_path).unwrap();
1381 assert!(
1382 bak_content.contains("\"b\""),
1383 "backup should contain original 'b' step"
1384 );
1385 let live_content = std::fs::read_to_string(&file_path).unwrap();
1387 let live: serde_json::Value = serde_json::from_str(&live_content).unwrap();
1388 let steps = live["data"]["completed_steps"].as_array().unwrap();
1389 assert!(
1390 !steps.iter().any(|s| s.as_str() == Some("b")),
1391 "step 'b' should be removed from completed_steps"
1392 );
1393 assert!(
1394 live["data"]["x"].is_null() || live["data"].get("x").is_none(),
1395 "field 'x' should be removed from data"
1396 );
1397 }
1398
1399 #[test]
1400 fn unsafe_namespace_rejected() {
1401 let (lua, _store, _tmp) = setup();
1402 lua.load(
1403 r#"
1404 local ok, err = pcall(alc.state.list, "../evil")
1405 assert(not ok, "expected error for unsafe namespace")
1406 local msg = tostring(err)
1407 assert(string.find(msg, "unsafe"), "error should contain 'unsafe', got: " .. msg)
1408 "#,
1409 )
1410 .exec()
1411 .unwrap();
1412 lua.load(
1413 r#"
1414 local ok, err = pcall(alc.state.show, "../evil", "key")
1415 assert(not ok, "expected error for unsafe namespace in show")
1416 local msg = tostring(err)
1417 assert(string.find(msg, "unsafe"), "error should contain 'unsafe', got: " .. msg)
1418 "#,
1419 )
1420 .exec()
1421 .unwrap();
1422 lua.load(
1423 r#"
1424 local ok, err = pcall(alc.state.reset, "../evil", "key", {})
1425 assert(not ok, "expected error for unsafe namespace in reset")
1426 local msg = tostring(err)
1427 assert(string.find(msg, "unsafe"), "error should contain 'unsafe', got: " .. msg)
1428 "#,
1429 )
1430 .exec()
1431 .unwrap();
1432 }
1433 }
1434}