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 let store_set_dispatched = Arc::clone(&state_store);
255 let set_dispatched = lua.create_function(
256 move |lua, (namespace, key, value): (String, String, LuaValue)| {
257 let json: serde_json::Value = lua.from_value(value)?;
258 store_set_dispatched
259 .set_dispatched(&namespace, &key, &json)
260 .map_err(LuaError::external)
261 },
262 )?;
263
264 let store_delete_dispatched = Arc::clone(&state_store);
266 let delete_dispatched = lua.create_function(move |_, (namespace, key): (String, String)| {
267 store_delete_dispatched
268 .delete_dispatched(&namespace, &key)
269 .map_err(LuaError::external)
270 })?;
271
272 state_table.set("get", get)?;
273 state_table.set("set", set)?;
274 state_table.set("keys", keys)?;
275 state_table.set("delete", delete)?;
276 state_table.set("has", has)?;
277 state_table.set("set_nx", set_nx)?;
278 state_table.set("incr", incr)?;
279 state_table.set("list", list)?;
280 state_table.set("show", show)?;
281 state_table.set("reset", reset)?;
282 state_table.set("set_dispatched", set_dispatched)?;
283 state_table.set("delete_dispatched", delete_dispatched)?;
284
285 alc_table.set("state", state_table)?;
286 Ok(())
287}
288
289pub(super) fn register_dirs(
295 lua: &Lua,
296 alc_table: &LuaTable,
297 state_dir: &Path,
298 cards_dir: &Path,
299 scenarios_dir: &Path,
300) -> LuaResult<()> {
301 let dirs = lua.create_table()?;
302 dirs.set("state", state_dir.to_string_lossy().into_owned())?;
303 dirs.set("cards", cards_dir.to_string_lossy().into_owned())?;
304 dirs.set("scenarios", scenarios_dir.to_string_lossy().into_owned())?;
305 alc_table.set("_dirs", dirs)?;
306 Ok(())
307}
308
309pub(super) fn register_card(
334 lua: &Lua,
335 alc_table: &LuaTable,
336 card_store: Arc<FileCardStore>,
337) -> LuaResult<()> {
338 let card_table = lua.create_table()?;
339
340 let store_create = Arc::clone(&card_store);
342 let create = lua.create_function(move |lua, input: LuaValue| {
343 let json: serde_json::Value = lua.from_value(input)?;
344 let (card_id, path) = store_create.create(json).map_err(LuaError::external)?;
345 let ret = lua.create_table()?;
346 ret.set("card_id", card_id)?;
347 ret.set("path", path.to_string_lossy().to_string())?;
348 Ok(ret)
349 })?;
350
351 let store_get = Arc::clone(&card_store);
353 let get = lua.create_function(move |lua, card_id: String| match store_get.get(&card_id) {
354 Ok(Some(v)) => lua.to_value(&v),
355 Ok(None) => Ok(LuaValue::Nil),
356 Err(e) => Err(LuaError::external(e)),
357 })?;
358
359 let store_list = Arc::clone(&card_store);
361 let list = lua.create_function(move |lua, filter: Option<LuaTable>| {
362 let pkg = match filter {
363 Some(t) => t.get::<Option<String>>("pkg")?,
364 None => None,
365 };
366 let rows = store_list
367 .list(pkg.as_deref())
368 .map_err(LuaError::external)?;
369 lua.to_value(&card::summaries_to_json(&rows))
370 })?;
371
372 let store_append = Arc::clone(&card_store);
374 let append = lua.create_function(move |lua, (card_id, fields): (String, LuaValue)| {
375 let json: serde_json::Value = lua.from_value(fields)?;
376 let merged = store_append
377 .append(&card_id, json)
378 .map_err(LuaError::external)?;
379 lua.to_value(&merged)
380 })?;
381
382 let store_gba = Arc::clone(&card_store);
384 let get_by_alias = lua.create_function(move |lua, name: String| {
385 match store_gba.get_by_alias(&name).map_err(LuaError::external)? {
386 Some(v) => lua.to_value(&v),
387 None => Ok(LuaValue::Nil),
388 }
389 })?;
390
391 let store_aset = Arc::clone(&card_store);
393 let alias_set = lua.create_function(
394 move |lua, (name, card_id, opts): (String, String, Option<LuaTable>)| {
395 let (pkg, note) = match opts {
396 Some(t) => (
397 t.get::<Option<String>>("pkg")?,
398 t.get::<Option<String>>("note")?,
399 ),
400 None => (None, None),
401 };
402 let a = store_aset
403 .alias_set(&name, &card_id, pkg.as_deref(), note.as_deref())
404 .map_err(LuaError::external)?;
405 let arr = card::aliases_to_json(&[a]);
406 let first = match arr {
407 serde_json::Value::Array(mut v) if !v.is_empty() => v.remove(0),
408 other => other,
409 };
410 lua.to_value(&first)
411 },
412 )?;
413
414 let store_alist = Arc::clone(&card_store);
416 let alias_list = lua.create_function(move |lua, filter: Option<LuaTable>| {
417 let pkg = match filter {
418 Some(t) => t.get::<Option<String>>("pkg")?,
419 None => None,
420 };
421 let rows = store_alist
422 .alias_list(pkg.as_deref())
423 .map_err(LuaError::external)?;
424 lua.to_value(&card::aliases_to_json(&rows))
425 })?;
426
427 let store_find = Arc::clone(&card_store);
432 let find = lua.create_function(move |lua, query: Option<LuaTable>| {
433 let q = match query {
434 Some(t) => {
435 let pkg = t.get::<Option<String>>("pkg")?;
436 let limit = t.get::<Option<usize>>("limit")?;
437 let offset = t.get::<Option<usize>>("offset")?;
438
439 let where_parsed = match t.get::<LuaValue>("where")? {
440 LuaValue::Nil => None,
441 v => {
442 let json: serde_json::Value = lua.from_value(v)?;
443 Some(card::parse_where(&json).map_err(LuaError::external)?)
444 }
445 };
446 let order_parsed = match t.get::<LuaValue>("order_by")? {
447 LuaValue::Nil => Vec::new(),
448 v => {
449 let json: serde_json::Value = lua.from_value(v)?;
450 card::parse_order_by(&json).map_err(LuaError::external)?
451 }
452 };
453
454 card::FindQuery {
455 pkg,
456 where_: where_parsed,
457 order_by: order_parsed,
458 limit,
459 offset,
460 }
461 }
462 None => card::FindQuery::default(),
463 };
464 let rows = store_find.find(q).map_err(LuaError::external)?;
465 lua.to_value(&card::summaries_to_json(&rows))
466 })?;
467
468 let store_ws = Arc::clone(&card_store);
470 let write_samples =
471 lua.create_function(move |lua, (card_id, samples): (String, LuaValue)| {
472 let json: serde_json::Value = lua.from_value(samples)?;
473 let arr = match json {
474 serde_json::Value::Array(a) => a,
475 _ => {
476 return Err(LuaError::external(
477 "alc.card.write_samples: samples must be an array",
478 ))
479 }
480 };
481 let count = arr.len();
482 let path = store_ws
483 .write_samples(&card_id, arr)
484 .map_err(LuaError::external)?;
485 let ret = lua.create_table()?;
486 ret.set("path", path.to_string_lossy().to_string())?;
487 ret.set("count", count)?;
488 Ok(ret)
489 })?;
490
491 let store_rs = Arc::clone(&card_store);
496 let read_samples =
497 lua.create_function(move |lua, (card_id, opts): (String, Option<LuaTable>)| {
498 let (offset, limit, where_parsed) = match opts {
499 Some(t) => {
500 let offset = t.get::<Option<usize>>("offset")?.unwrap_or(0);
501 let limit = t.get::<Option<usize>>("limit")?;
502 let where_parsed = match t.get::<LuaValue>("where")? {
503 LuaValue::Nil => None,
504 v => {
505 let json: serde_json::Value = lua.from_value(v)?;
506 Some(card::parse_where(&json).map_err(LuaError::external)?)
507 }
508 };
509 (offset, limit, where_parsed)
510 }
511 None => (0, None, None),
512 };
513 let q = card::SamplesQuery {
514 offset,
515 limit,
516 where_: where_parsed,
517 };
518 let rows = store_rs
519 .read_samples(&card_id, q)
520 .map_err(LuaError::external)?;
521 lua.to_value(&serde_json::Value::Array(rows))
522 })?;
523
524 let store_sb = Arc::clone(&card_store);
529 let sink_backfill = lua.create_function(move |lua, params: LuaTable| {
530 let sink: String = params.get("sink")?;
531 let dry_run: Option<bool> = params.get("dry_run")?;
532 let report = store_sb
533 .card_sink_backfill(&sink, dry_run.unwrap_or(false))
534 .map_err(LuaError::external)?;
535 lua.to_value(&report)
536 })?;
537
538 let store_lin = Arc::clone(&card_store);
543 let lineage = lua.create_function(move |lua, query: LuaTable| {
544 let card_id: String = query.get("card_id")?;
545 let direction_str: Option<String> = query.get("direction")?;
546 let direction = match direction_str.as_deref() {
547 Some(s) => card::LineageDirection::parse(s).map_err(LuaError::external)?,
548 None => card::LineageDirection::Up,
549 };
550 let depth: Option<usize> = query.get("depth")?;
551 let include_stats: Option<bool> = query.get("include_stats")?;
552 let relation_filter: Option<Vec<String>> = match query.get::<LuaValue>("relation_filter")? {
553 LuaValue::Nil => None,
554 v => Some(lua.from_value(v)?),
555 };
556
557 let q = card::LineageQuery {
558 card_id,
559 direction,
560 depth,
561 include_stats: include_stats.unwrap_or(true),
562 relation_filter,
563 };
564 match store_lin.lineage(q).map_err(LuaError::external)? {
565 Some(res) => lua.to_value(&card::lineage_to_json(&res)),
566 None => Ok(LuaValue::Nil),
567 }
568 })?;
569
570 card_table.set("create", create)?;
571 card_table.set("get", get)?;
572 card_table.set("list", list)?;
573 card_table.set("append", append)?;
574 card_table.set("get_by_alias", get_by_alias)?;
575 card_table.set("alias_set", alias_set)?;
576 card_table.set("alias_list", alias_list)?;
577 card_table.set("find", find)?;
578 card_table.set("write_samples", write_samples)?;
579 card_table.set("read_samples", read_samples)?;
580 card_table.set("lineage", lineage)?;
581 card_table.set("sink_backfill", sink_backfill)?;
582
583 alc_table.set("card", card_table)?;
584 Ok(())
585}
586
587pub(super) fn register_stats(
600 lua: &Lua,
601 alc_table: &LuaTable,
602 custom_metrics: CustomMetricsHandle,
603 stats: StatsHandle,
604) -> LuaResult<()> {
605 let stats_table = lua.create_table()?;
606
607 let cm_record = custom_metrics.clone();
609 let record = lua.create_function(move |lua, (key, value): (String, LuaValue)| {
610 let json: serde_json::Value = lua.from_value(value)?;
611 cm_record.record(key, json);
612 Ok(())
613 })?;
614
615 let cm_get = custom_metrics;
617 let get = lua.create_function(move |lua, key: String| match cm_get.get(&key) {
618 Some(v) => lua.to_value(&v),
619 None => Ok(LuaValue::Nil),
620 })?;
621
622 let stats_handle = stats;
624 let llm_calls = lua.create_function(move |_, ()| Ok(stats_handle.llm_calls()))?;
625
626 stats_table.set("record", record)?;
627 stats_table.set("get", get)?;
628 stats_table.set("llm_calls", llm_calls)?;
629
630 alc_table.set("stats", stats_table)?;
631 Ok(())
632}
633
634pub struct AlcEnv(pub Arc<HashMap<String, String>>);
645
646impl mlua::UserData for AlcEnv {
647 fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M) {
648 methods.add_meta_method(mlua::MetaMethod::Index, |_, this, key: String| {
650 Ok(this.0.get(&key).cloned())
651 });
652
653 methods.add_meta_method(
656 mlua::MetaMethod::NewIndex,
657 |_, _, (_k, _v): (mlua::Value, mlua::Value)| {
658 Err::<(), _>(mlua::Error::external("alc.env is readonly"))
659 },
660 );
661
662 methods.add_method(
664 "get",
665 |_, this, (key, default): (String, Option<String>)| {
666 Ok(this.0.get(&key).cloned().or(default))
667 },
668 );
669
670 methods.add_method("use", |lua, this, declared: Vec<String>| {
674 let proxy = lua.create_table()?;
675 for k in &declared {
676 if let Some(v) = this.0.get(k) {
677 proxy.set(k.clone(), v.clone())?;
678 }
679 }
680 Ok(proxy)
681 });
682 }
683}
684
685pub fn register_env(
693 lua: &mlua::Lua,
694 alc_table: &mlua::Table,
695 env_map: Arc<HashMap<String, String>>,
696) -> mlua::Result<()> {
697 alc_table.set("env", AlcEnv(Arc::clone(&env_map)))?;
698 lua.set_app_data(env_map);
699 Ok(())
700}
701
702#[cfg(test)]
703mod tests {
704 use super::*;
705 use algocline_core::ExecutionMetrics;
706
707 fn test_config_with(ns: &str) -> crate::bridge::BridgeConfig {
711 let metrics = ExecutionMetrics::new();
712 let tmp = tempfile::tempdir().expect("test tempdir");
713 let root = tmp.path().to_path_buf();
714 std::mem::forget(tmp);
715 crate::bridge::BridgeConfig {
716 llm_tx: None,
717 ns: ns.into(),
718 custom_metrics: metrics.custom_metrics_handle(),
719 stats: metrics.stats_handle(),
720 budget: metrics.budget_handle(),
721 progress: metrics.progress_handle(),
722 lib_paths: vec![],
723 variant_pkgs: vec![],
724 state_store: Arc::new(JsonFileStore::new(root.join("state"))),
725 card_store: Arc::new(FileCardStore::new(root.join("cards"))),
726 scenarios_dir: root.join("scenarios"),
727 log_sink: None,
728 }
729 }
730
731 fn test_config() -> crate::bridge::BridgeConfig {
732 test_config_with("default")
733 }
734
735 fn test_config_with_ns(ns: &str) -> crate::bridge::BridgeConfig {
736 test_config_with(ns)
737 }
738
739 #[test]
740 fn json_roundtrip() {
741 let lua = Lua::new();
742 let t = lua.create_table().unwrap();
743 crate::bridge::register(&lua, &t, test_config()).unwrap();
744 lua.globals().set("alc", t).unwrap();
745
746 let result: String = lua
747 .load(r#"return alc.json_encode({hello = "world", n = 42})"#)
748 .eval()
749 .unwrap();
750 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
751 assert_eq!(parsed["hello"], "world");
752 assert_eq!(parsed["n"], 42);
753 }
754
755 #[test]
756 fn json_decode_encode() {
757 let lua = Lua::new();
758 let t = lua.create_table().unwrap();
759 crate::bridge::register(&lua, &t, test_config()).unwrap();
760 lua.globals().set("alc", t).unwrap();
761
762 let result: String = lua
763 .load(
764 r#"
765 local val = alc.json_decode('{"a":1,"b":"two"}')
766 val.c = true
767 return alc.json_encode(val)
768 "#,
769 )
770 .eval()
771 .unwrap();
772 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
773 assert_eq!(parsed["a"], 1);
774 assert_eq!(parsed["b"], "two");
775 assert_eq!(parsed["c"], true);
776 }
777
778 #[test]
779 fn state_get_set() {
780 let ns = "_test_bridge_state";
783
784 let lua = Lua::new();
785 let t = lua.create_table().unwrap();
786 crate::bridge::register(&lua, &t, test_config_with_ns(ns)).unwrap();
787 lua.globals().set("alc", t).unwrap();
788
789 lua.load(r#"alc.state.set("x", 99)"#).exec().unwrap();
791 let result: i64 = lua.load(r#"return alc.state.get("x")"#).eval().unwrap();
792 assert_eq!(result, 99);
793
794 let result: i64 = lua
796 .load(r#"return alc.state.get("missing", 0)"#)
797 .eval()
798 .unwrap();
799 assert_eq!(result, 0);
800
801 let result: LuaValue = lua
803 .load(r#"return alc.state.get("missing")"#)
804 .eval()
805 .unwrap();
806 assert!(result.is_nil());
807 }
808
809 #[test]
810 fn state_has_set_nx_incr() {
811 let ns = "_test_bridge_state_t1";
812
813 let lua = Lua::new();
814 let t = lua.create_table().unwrap();
815 crate::bridge::register(&lua, &t, test_config_with_ns(ns)).unwrap();
816 lua.globals().set("alc", t).unwrap();
817
818 let h: bool = lua.load(r#"return alc.state.has("k")"#).eval().unwrap();
820 assert!(!h);
821
822 let ok: bool = lua
824 .load(r#"return alc.state.set_nx("k", "first")"#)
825 .eval()
826 .unwrap();
827 assert!(ok);
828
829 let h: bool = lua.load(r#"return alc.state.has("k")"#).eval().unwrap();
831 assert!(h);
832
833 let ok: bool = lua
835 .load(r#"return alc.state.set_nx("k", "second")"#)
836 .eval()
837 .unwrap();
838 assert!(!ok);
839
840 let v: f64 = lua
842 .load(r#"return alc.state.incr("counter")"#)
843 .eval()
844 .unwrap();
845 assert!((v - 1.0).abs() < f64::EPSILON);
846
847 let v: f64 = lua
849 .load(r#"return alc.state.incr("counter", 5)"#)
850 .eval()
851 .unwrap();
852 assert!((v - 6.0).abs() < f64::EPSILON);
853
854 let v: f64 = lua
856 .load(r#"return alc.state.incr("counter", 10, 100)"#)
857 .eval()
858 .unwrap();
859 assert!((v - 16.0).abs() < f64::EPSILON);
860 }
861
862 #[test]
863 fn card_create_get_list_from_lua() {
864 let ns = std::time::SystemTime::now()
866 .duration_since(std::time::UNIX_EPOCH)
867 .unwrap()
868 .as_nanos();
869 let pkg = format!("_test_bridge_card_{ns}");
870
871 let lua = Lua::new();
872 let t = lua.create_table().unwrap();
873 crate::bridge::register(&lua, &t, test_config()).unwrap();
874 lua.globals().set("alc", t).unwrap();
875
876 let create_script = format!(
878 r#"
879 local r = alc.card.create({{
880 pkg = {{ name = "{pkg}" }},
881 model = {{ id = "claude-opus-4-6" }},
882 stats = {{ pass_rate = 0.9 }},
883 }})
884 return r.card_id
885 "#
886 );
887 let card_id: String = lua.load(&create_script).eval().unwrap();
888 assert!(card_id.starts_with(&pkg));
889
890 let get_script = format!(r#"return alc.card.get("{card_id}").stats.pass_rate"#);
892 let rate: f64 = lua.load(&get_script).eval().unwrap();
893 assert!((rate - 0.9).abs() < 1e-9);
894
895 let list_script = format!(
897 r#"
898 local rows = alc.card.list({{ pkg = "{pkg}" }})
899 return #rows
900 "#
901 );
902 let count: i64 = lua.load(&list_script).eval().unwrap();
903 assert_eq!(count, 1);
904
905 }
907
908 #[test]
909 fn stats_record_get() {
910 let metrics = ExecutionMetrics::new();
911 let custom_handle = metrics.custom_metrics_handle();
912 let lua = Lua::new();
913 let t = lua.create_table().unwrap();
914 let tmp = tempfile::tempdir().expect("test tempdir");
915 let root = tmp.path().to_path_buf();
916 std::mem::forget(tmp);
917 crate::bridge::register(
918 &lua,
919 &t,
920 crate::bridge::BridgeConfig {
921 llm_tx: None,
922 ns: "default".into(),
923 custom_metrics: custom_handle.clone(),
924 stats: metrics.stats_handle(),
925 budget: metrics.budget_handle(),
926 progress: metrics.progress_handle(),
927 lib_paths: vec![],
928 variant_pkgs: vec![],
929 state_store: Arc::new(JsonFileStore::new(root.join("state"))),
930 card_store: Arc::new(FileCardStore::new(root.join("cards"))),
931 scenarios_dir: root.join("scenarios"),
932 log_sink: None,
933 },
934 )
935 .unwrap();
936 lua.globals().set("alc", t).unwrap();
937
938 lua.load(r#"alc.stats.record("score", 42)"#).exec().unwrap();
940 let result: i64 = lua.load(r#"return alc.stats.get("score")"#).eval().unwrap();
941 assert_eq!(result, 42);
942
943 assert_eq!(custom_handle.get("score"), Some(serde_json::json!(42)));
945
946 let result: LuaValue = lua
948 .load(r#"return alc.stats.get("missing")"#)
949 .eval()
950 .unwrap();
951 assert!(result.is_nil());
952 }
953
954 #[test]
959 fn stats_llm_calls_reads_session_status() {
960 use crate::card::FileCardStore;
961 use crate::state::JsonFileStore;
962 use algocline_core::{ExecutionObserver, LlmQuery, QueryId};
963 use std::sync::Arc;
964
965 let metrics = ExecutionMetrics::new();
966 let observer = metrics.create_observer();
967
968 let lua = Lua::new();
969 let t = lua.create_table().unwrap();
970 let tmp = tempfile::tempdir().expect("test tempdir");
971 let root = tmp.path().to_path_buf();
972 std::mem::forget(tmp);
973 crate::bridge::register(
974 &lua,
975 &t,
976 crate::bridge::BridgeConfig {
977 llm_tx: None,
978 ns: "default".into(),
979 custom_metrics: metrics.custom_metrics_handle(),
980 stats: metrics.stats_handle(),
981 budget: metrics.budget_handle(),
982 progress: metrics.progress_handle(),
983 lib_paths: vec![],
984 variant_pkgs: vec![],
985 state_store: Arc::new(JsonFileStore::new(root.join("state"))),
986 card_store: Arc::new(FileCardStore::new(root.join("cards"))),
987 scenarios_dir: root.join("scenarios"),
988 log_sink: None,
989 },
990 )
991 .unwrap();
992 lua.globals().set("alc", t).unwrap();
993
994 let initial: u64 = lua.load(r#"return alc.stats.llm_calls()"#).eval().unwrap();
996 assert_eq!(initial, 0, "fresh session must report llm_calls() == 0");
997
998 observer.on_paused(&[LlmQuery {
1000 id: QueryId::parse("q-0"),
1001 prompt: "hi".to_string(),
1002 system: None,
1003 max_tokens: 0,
1004 grounded: false,
1005 underspecified: false,
1006 }]);
1007
1008 let after_one: u64 = lua.load(r#"return alc.stats.llm_calls()"#).eval().unwrap();
1010 assert_eq!(
1011 after_one, 1,
1012 "one paused query must increment llm_calls() to 1"
1013 );
1014
1015 observer.on_paused(&[
1017 LlmQuery {
1018 id: QueryId::parse("q-1"),
1019 prompt: "a".to_string(),
1020 system: None,
1021 max_tokens: 0,
1022 grounded: false,
1023 underspecified: false,
1024 },
1025 LlmQuery {
1026 id: QueryId::parse("q-2"),
1027 prompt: "b".to_string(),
1028 system: None,
1029 max_tokens: 0,
1030 grounded: false,
1031 underspecified: false,
1032 },
1033 ]);
1034
1035 let after_three: u64 = lua.load(r#"return alc.stats.llm_calls()"#).eval().unwrap();
1036 assert_eq!(
1037 after_three, 3,
1038 "two further paused queries (multi-query batch) must bring llm_calls() to 3"
1039 );
1040 }
1041
1042 #[test]
1046 fn register_log_pushes_to_log_sink() {
1047 use algocline_core::LogSink;
1048
1049 let sink = LogSink::new();
1050 let lua = Lua::new();
1051 let t = lua.create_table().unwrap();
1052 register_log(&lua, &t, sink.clone()).unwrap();
1054 lua.globals().set("alc", t).unwrap();
1055
1056 lua.load(r#"alc.log("info", "hello-from-log")"#)
1057 .exec()
1058 .unwrap();
1060
1061 let entries = sink.entries();
1062 assert_eq!(entries.len(), 1);
1063 assert_eq!(entries[0].source, "alc.log");
1064 assert_eq!(entries[0].level, "info");
1065 assert_eq!(entries[0].message, "hello-from-log");
1066 }
1067
1068 #[test]
1070 fn register_log_unknown_level_still_pushes() {
1071 use algocline_core::LogSink;
1072
1073 let sink = LogSink::new();
1074 let lua = Lua::new();
1075 let t = lua.create_table().unwrap();
1076 register_log(&lua, &t, sink.clone()).unwrap();
1078 lua.globals().set("alc", t).unwrap();
1079
1080 lua.load(r#"alc.log("custom", "edge-case")"#)
1081 .exec()
1082 .unwrap();
1084
1085 let entries = sink.entries();
1086 assert_eq!(entries.len(), 1);
1087 assert_eq!(entries[0].source, "alc.log");
1088 assert_eq!(entries[0].level, "custom");
1090 assert_eq!(entries[0].message, "edge-case");
1091 }
1092
1093 #[test]
1095 fn register_log_empty_message() {
1096 use algocline_core::LogSink;
1097
1098 let sink = LogSink::new();
1099 let lua = Lua::new();
1100 let t = lua.create_table().unwrap();
1101 register_log(&lua, &t, sink.clone()).unwrap();
1103 lua.globals().set("alc", t).unwrap();
1104
1105 lua.load(r#"alc.log("warn", "")"#)
1106 .exec()
1107 .unwrap();
1109
1110 let entries = sink.entries();
1111 assert_eq!(entries.len(), 1);
1112 assert_eq!(entries[0].message, "");
1113 }
1114
1115 #[test]
1119 fn register_print_pushes_to_log_sink() {
1120 use algocline_core::LogSink;
1121
1122 let sink = LogSink::new();
1123 let lua = Lua::new();
1124 register_print(&lua, sink.clone()).unwrap();
1126
1127 lua.load(r#"print("hello-print")"#)
1128 .exec()
1129 .unwrap();
1131
1132 let entries = sink.entries();
1133 assert_eq!(entries.len(), 1);
1134 assert_eq!(entries[0].source, "alc.lua.print");
1135 assert_eq!(entries[0].level, "info");
1136 assert_eq!(entries[0].message, "hello-print");
1137 }
1138
1139 #[test]
1141 fn register_print_multiple_args_tab_joined() {
1142 use algocline_core::LogSink;
1143
1144 let sink = LogSink::new();
1145 let lua = Lua::new();
1146 register_print(&lua, sink.clone()).unwrap();
1148
1149 lua.load(r#"print("a", "b", "c")"#)
1150 .exec()
1151 .unwrap();
1153
1154 let entries = sink.entries();
1155 assert_eq!(entries.len(), 1);
1156 assert_eq!(entries[0].message, "a\tb\tc");
1157 }
1158
1159 #[test]
1161 fn register_print_mixed_value_types() {
1162 use algocline_core::LogSink;
1163
1164 let sink = LogSink::new();
1165 let lua = Lua::new();
1166 register_print(&lua, sink.clone()).unwrap();
1168
1169 lua.load(r#"print(nil, true, 42, 3.14)"#)
1170 .exec()
1171 .unwrap();
1173
1174 let entries = sink.entries();
1175 assert_eq!(entries.len(), 1);
1176 let msg = &entries[0].message;
1178 assert!(msg.starts_with("nil\ttrue\t42\t"), "got: {msg}");
1179 }
1180
1181 fn make_env_lua(pairs: &[(&str, &str)]) -> (Lua, Arc<HashMap<String, String>>) {
1184 let mut map = HashMap::new();
1185 for (k, v) in pairs {
1186 map.insert(k.to_string(), v.to_string());
1187 }
1188 let env_map = Arc::new(map);
1189 let lua = Lua::new();
1190 let alc_table = lua.create_table().unwrap();
1191 register_env(&lua, &alc_table, Arc::clone(&env_map)).unwrap();
1192 lua.globals().set("alc", alc_table).unwrap();
1193 (lua, env_map)
1194 }
1195
1196 #[test]
1197 fn env_index_reads_existing_key() {
1198 let (lua, _) = make_env_lua(&[("FOO", "bar")]);
1199 let val: Option<String> = lua.load(r#"return alc.env.FOO"#).eval().unwrap();
1200 assert_eq!(val, Some("bar".to_string()));
1201 }
1202
1203 #[test]
1204 fn env_index_missing_key_returns_nil() {
1205 let (lua, _) = make_env_lua(&[("FOO", "bar")]);
1206 let val: LuaValue = lua.load(r#"return alc.env.MISSING"#).eval().unwrap();
1207 assert!(val.is_nil());
1208 }
1209
1210 #[test]
1211 fn env_newindex_returns_error() {
1212 let (lua, _) = make_env_lua(&[("FOO", "bar")]);
1213 let result: Result<(), _> = lua.load(r#"alc.env.FOO = "x""#).exec();
1214 let err = result.unwrap_err().to_string();
1215 assert!(
1216 err.contains("alc.env is readonly"),
1217 "expected readonly error, got: {err}"
1218 );
1219 }
1220
1221 #[test]
1222 fn env_get_with_default_returns_default_on_miss() {
1223 let (lua, _) = make_env_lua(&[]);
1224 let val: Option<String> = lua
1225 .load(r#"return alc.env:get("MISSING", "fallback")"#)
1226 .eval()
1227 .unwrap();
1228 assert_eq!(val, Some("fallback".to_string()));
1229 }
1230
1231 #[test]
1232 fn env_get_returns_value_when_present() {
1233 let (lua, _) = make_env_lua(&[("KEY", "val")]);
1234 let val: Option<String> = lua
1235 .load(r#"return alc.env:get("KEY", "default")"#)
1236 .eval()
1237 .unwrap();
1238 assert_eq!(val, Some("val".to_string()));
1239 }
1240
1241 #[test]
1242 fn env_use_returns_declared_keys_only() {
1243 let (lua, _) = make_env_lua(&[("FOO", "foo_val"), ("BAR", "bar_val"), ("SECRET", "s")]);
1244 let result: LuaValue = lua
1245 .load(
1246 r#"
1247 local e = alc.env:use{"FOO", "BAR"}
1248 return e
1249 "#,
1250 )
1251 .eval()
1252 .unwrap();
1253 let tbl = result.as_table().unwrap();
1254 assert_eq!(tbl.get::<String>("FOO").unwrap(), "foo_val");
1255 assert_eq!(tbl.get::<String>("BAR").unwrap(), "bar_val");
1256 let secret: LuaValue = tbl.get("SECRET").unwrap();
1258 assert!(secret.is_nil(), "SECRET should be nil in proxy");
1259 }
1260
1261 #[test]
1262 fn env_use_undeclared_key_is_nil() {
1263 let (lua, _) = make_env_lua(&[("FOO", "foo_val")]);
1264 let val: LuaValue = lua
1265 .load(
1266 r#"
1267 local e = alc.env:use{"FOO"}
1268 return e.UNDECLARED
1269 "#,
1270 )
1271 .eval()
1272 .unwrap();
1273 assert!(val.is_nil());
1274 }
1275
1276 #[test]
1277 fn register_env_sets_app_data() {
1278 let mut map = HashMap::new();
1279 map.insert("X".to_string(), "1".to_string());
1280 let env_map = Arc::new(map);
1281 let lua = Lua::new();
1282 let alc_table = lua.create_table().unwrap();
1283 register_env(&lua, &alc_table, Arc::clone(&env_map)).unwrap();
1284 let retrieved = lua.app_data_ref::<Arc<HashMap<String, String>>>().unwrap();
1286 assert_eq!(retrieved.get("X").unwrap(), "1");
1287 }
1288
1289 mod state_dispatched_lua {
1290 use super::*;
1291 use mlua::Lua;
1292 use std::sync::Arc;
1293 use tempfile::TempDir;
1294
1295 fn setup() -> (Lua, Arc<JsonFileStore>, TempDir) {
1296 let tmp = tempfile::tempdir().unwrap();
1297 let store = Arc::new(JsonFileStore::new(tmp.path().to_path_buf()));
1298 let lua = Lua::new();
1299 let alc = lua.create_table().unwrap();
1300 register_state(&lua, &alc, "default".to_string(), Arc::clone(&store)).unwrap();
1301 lua.globals().set("alc", alc).unwrap();
1302 (lua, store, tmp)
1303 }
1304
1305 #[test]
1306 fn list_returns_sorted_keys() {
1307 let (lua, _store, tmp) = setup();
1308 std::fs::create_dir_all(tmp.path().join("testns")).unwrap();
1310 std::fs::write(
1311 tmp.path().join("testns/beta.json"),
1312 r#"{"data": {"completed_steps": [], "x": 1}}"#,
1313 )
1314 .unwrap();
1315 std::fs::write(
1316 tmp.path().join("testns/alpha.json"),
1317 r#"{"data": {"completed_steps": [], "y": 2}}"#,
1318 )
1319 .unwrap();
1320 lua.load(
1321 r#"
1322 local result = alc.state.list("testns")
1323 assert(#result == 2, "expected 2 keys, got " .. #result)
1324 assert(result[1] == "alpha", "first key should be alpha, got " .. tostring(result[1]))
1325 assert(result[2] == "beta", "second key should be beta, got " .. tostring(result[2]))
1326 "#,
1327 )
1328 .exec()
1329 .unwrap();
1330 }
1331
1332 #[test]
1333 fn show_returns_full_table() {
1334 let (lua, _store, tmp) = setup();
1335 std::fs::create_dir_all(tmp.path().join("testns")).unwrap();
1336 std::fs::write(
1337 tmp.path().join("testns/alpha.json"),
1338 r#"{"data": {"completed_steps": ["a", "b", "c"], "x": 1, "y": 2}}"#,
1339 )
1340 .unwrap();
1341 lua.load(
1342 r#"
1343 local result = alc.state.show("testns", "alpha")
1344 assert(type(result) == "table", "expected table")
1345 assert(type(result.data) == "table", "expected result.data to be a table")
1346 assert(result.data.x == 1, "expected x=1")
1347 assert(result.data.y == 2, "expected y=2")
1348 assert(#result.data.completed_steps == 3, "expected 3 steps")
1349 "#,
1350 )
1351 .exec()
1352 .unwrap();
1353 }
1354
1355 #[test]
1356 fn show_missing_returns_not_found_error() {
1357 let (lua, _store, _tmp) = setup();
1358 lua.load(
1359 r#"
1360 local ok, err = pcall(alc.state.show, "testns", "missing")
1361 assert(not ok, "expected error but got success")
1362 local msg = tostring(err)
1363 assert(string.find(msg, "not found"), "error message should contain 'not found', got: " .. msg)
1364 "#,
1365 )
1366 .exec()
1367 .unwrap();
1368 }
1369
1370 #[test]
1371 fn reset_removes_steps_and_fields_with_backup() {
1372 let (lua, _store, tmp) = setup();
1373 std::fs::create_dir_all(tmp.path().join("testns")).unwrap();
1374 let file_path = tmp.path().join("testns/alpha.json");
1375 std::fs::write(
1376 &file_path,
1377 r#"{"data": {"completed_steps": ["a", "b", "c"], "x": 1, "y": 2}}"#,
1378 )
1379 .unwrap();
1380 let tmp_path_str = tmp.path().to_string_lossy().to_string();
1382 lua.globals().set("TMP_PATH", tmp_path_str.clone()).unwrap();
1383 lua.load(
1384 r#"
1385 local r = alc.state.reset("testns", "alpha", {steps={"b"}, fields={"x"}})
1386 assert(r.ok == true, "expected ok=true")
1387 assert(type(r.backup_path) == "string", "backup_path should be a string")
1388 assert(r.steps_removed == 1, "expected steps_removed=1, got " .. tostring(r.steps_removed))
1389 assert(r.fields_removed == 1, "expected fields_removed=1, got " .. tostring(r.fields_removed))
1390 "#,
1391 )
1392 .exec()
1393 .unwrap();
1394 let bak_path = tmp.path().join("testns/alpha.json.bak");
1396 assert!(
1397 bak_path.exists(),
1398 "backup file should exist at {:?}",
1399 bak_path
1400 );
1401 let bak_content = std::fs::read_to_string(&bak_path).unwrap();
1402 assert!(
1403 bak_content.contains("\"b\""),
1404 "backup should contain original 'b' step"
1405 );
1406 let live_content = std::fs::read_to_string(&file_path).unwrap();
1408 let live: serde_json::Value = serde_json::from_str(&live_content).unwrap();
1409 let steps = live["data"]["completed_steps"].as_array().unwrap();
1410 assert!(
1411 !steps.iter().any(|s| s.as_str() == Some("b")),
1412 "step 'b' should be removed from completed_steps"
1413 );
1414 assert!(
1415 live["data"]["x"].is_null() || live["data"].get("x").is_none(),
1416 "field 'x' should be removed from data"
1417 );
1418 }
1419
1420 #[test]
1421 fn unsafe_namespace_rejected() {
1422 let (lua, _store, _tmp) = setup();
1423 lua.load(
1424 r#"
1425 local ok, err = pcall(alc.state.list, "../evil")
1426 assert(not ok, "expected error for unsafe namespace")
1427 local msg = tostring(err)
1428 assert(string.find(msg, "unsafe"), "error should contain 'unsafe', got: " .. msg)
1429 "#,
1430 )
1431 .exec()
1432 .unwrap();
1433 lua.load(
1434 r#"
1435 local ok, err = pcall(alc.state.show, "../evil", "key")
1436 assert(not ok, "expected error for unsafe namespace in show")
1437 local msg = tostring(err)
1438 assert(string.find(msg, "unsafe"), "error should contain 'unsafe', got: " .. msg)
1439 "#,
1440 )
1441 .exec()
1442 .unwrap();
1443 lua.load(
1444 r#"
1445 local ok, err = pcall(alc.state.reset, "../evil", "key", {})
1446 assert(not ok, "expected error for unsafe namespace in reset")
1447 local msg = tostring(err)
1448 assert(string.find(msg, "unsafe"), "error should contain 'unsafe', got: " .. msg)
1449 "#,
1450 )
1451 .exec()
1452 .unwrap();
1453 }
1454 }
1455}