use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use algocline_core::{CustomMetricsHandle, LogEntry, LogSink, StatsHandle};
use mlua::prelude::*;
use mlua::LuaSerdeExt;
use crate::card::{self, FileCardStore};
use crate::state::{JsonFileStore, StateStore};
pub(super) fn register_json(lua: &Lua, alc_table: &LuaTable) -> LuaResult<()> {
let encode = lua.create_function(|lua, value: LuaValue| {
let json: serde_json::Value = lua.from_value(value)?;
serde_json::to_string(&json).map_err(LuaError::external)
})?;
let decode = lua.create_function(|lua, s: String| {
let value: serde_json::Value = serde_json::from_str(&s).map_err(LuaError::external)?;
lua.to_value(&value)
})?;
alc_table.set("json_encode", encode)?;
alc_table.set("json_decode", decode)?;
Ok(())
}
pub(super) fn register_log(lua: &Lua, alc_table: &LuaTable, log_sink: LogSink) -> LuaResult<()> {
let log = lua.create_function(move |_, (level, msg): (String, String)| {
match level.as_str() {
"error" => tracing::error!(target: "alc.log", "{}", msg),
"warn" => tracing::warn!(target: "alc.log", "{}", msg),
"info" => tracing::info!(target: "alc.log", "{}", msg),
"debug" => tracing::debug!(target: "alc.log", "{}", msg),
_ => tracing::info!(target: "alc.log", "{}", msg),
}
log_sink.push(LogEntry::new(level.clone(), "alc.log", msg));
Ok(())
})?;
alc_table.set("log", log)?;
Ok(())
}
pub(super) fn register_print(lua: &Lua, log_sink: LogSink) -> LuaResult<()> {
let print_fn = lua.create_function(move |lua_inner, args: mlua::MultiValue| {
let parts: Vec<String> = args
.iter()
.map(|v| match v {
LuaValue::Nil => "nil".to_string(),
LuaValue::Boolean(b) => b.to_string(),
LuaValue::Integer(n) => n.to_string(),
LuaValue::Number(n) => {
if n.fract() == 0.0 && n.abs() < 1e15_f64 {
format!("{n:.1}")
} else {
format!("{n}")
}
}
other => lua_inner
.coerce_string(other.clone())
.ok()
.flatten()
.and_then(|s| s.to_str().ok().map(|r| r.to_string()))
.unwrap_or_else(|| format!("{other:?}")),
})
.collect();
let line = parts.join("\t");
tracing::info!(target: "alc.lua.print", "{}", line);
let message = line.trim_end_matches('\n').to_string();
log_sink.push(LogEntry::new("info", "alc.lua.print", message));
Ok(())
})?;
lua.globals().set("print", print_fn)?;
Ok(())
}
pub(super) fn register_state(
lua: &Lua,
alc_table: &LuaTable,
ns: String,
state_store: Arc<JsonFileStore>,
) -> LuaResult<()> {
let state_table = lua.create_table()?;
let ns_get = ns.clone();
let store_get = Arc::clone(&state_store);
let get =
lua.create_function(
move |lua, (key, default): (String, Option<LuaValue>)| match store_get
.get(&ns_get, &key)
{
Ok(Some(v)) => lua.to_value(&v),
Ok(None) => Ok(default.unwrap_or(LuaValue::Nil)),
Err(e) => Err(LuaError::external(e)),
},
)?;
let ns_set = ns.clone();
let store_set = Arc::clone(&state_store);
let set = lua.create_function(move |lua, (key, value): (String, LuaValue)| {
let json: serde_json::Value = lua.from_value(value)?;
store_set
.set(&ns_set, &key, json)
.map_err(LuaError::external)
})?;
let ns_keys = ns.clone();
let store_keys = Arc::clone(&state_store);
let keys = lua.create_function(move |lua, ()| {
let k = store_keys.keys(&ns_keys).map_err(LuaError::external)?;
lua.to_value(&k)
})?;
let ns_del = ns.clone();
let store_del = Arc::clone(&state_store);
let delete = lua.create_function(move |_, key: String| {
store_del.delete(&ns_del, &key).map_err(LuaError::external)
})?;
let ns_has = ns.clone();
let store_has = Arc::clone(&state_store);
let has = lua.create_function(move |_, key: String| {
store_has.has(&ns_has, &key).map_err(LuaError::external)
})?;
let ns_snx = ns.clone();
let store_snx = Arc::clone(&state_store);
let set_nx = lua.create_function(move |lua, (key, value): (String, LuaValue)| {
let json: serde_json::Value = lua.from_value(value)?;
store_snx
.set_nx(&ns_snx, &key, json)
.map_err(LuaError::external)
})?;
let ns_incr = ns;
let store_incr = Arc::clone(&state_store);
let incr = lua.create_function(
move |_, (key, delta, default): (String, Option<f64>, Option<f64>)| {
store_incr
.incr(&ns_incr, &key, delta.unwrap_or(1.0), default.unwrap_or(0.0))
.map_err(LuaError::external)
},
)?;
let store_list = Arc::clone(&state_store);
let list = lua.create_function(move |lua, namespace: String| {
let keys = store_list
.list_dispatched(&namespace)
.map_err(LuaError::external)?;
lua.to_value(&keys)
})?;
let store_show = Arc::clone(&state_store);
let show = lua.create_function(move |lua, (namespace, key): (String, String)| {
let v = store_show
.show_dispatched(&namespace, &key)
.map_err(LuaError::external)?;
lua.to_value(&v)
})?;
let store_reset = Arc::clone(&state_store);
let reset = lua.create_function(
move |lua, (namespace, key, opts): (String, String, Option<LuaTable>)| {
let (steps, fields) = match opts {
Some(t) => {
let s = t.get::<Option<Vec<String>>>("steps")?.unwrap_or_default();
let f = t.get::<Option<Vec<String>>>("fields")?.unwrap_or_default();
(s, f)
}
None => (Vec::new(), Vec::new()),
};
let report = store_reset
.reset_dispatched_with_backup(&namespace, &key, &steps, &fields)
.map_err(LuaError::external)?;
let ret = lua.create_table()?;
ret.set("ok", true)?;
ret.set(
"backup_path",
report.backup_path.to_string_lossy().to_string(),
)?;
ret.set("steps_removed", report.steps_removed)?;
ret.set("fields_removed", report.fields_removed)?;
Ok(ret)
},
)?;
state_table.set("get", get)?;
state_table.set("set", set)?;
state_table.set("keys", keys)?;
state_table.set("delete", delete)?;
state_table.set("has", has)?;
state_table.set("set_nx", set_nx)?;
state_table.set("incr", incr)?;
state_table.set("list", list)?;
state_table.set("show", show)?;
state_table.set("reset", reset)?;
alc_table.set("state", state_table)?;
Ok(())
}
pub(super) fn register_dirs(
lua: &Lua,
alc_table: &LuaTable,
state_dir: &Path,
cards_dir: &Path,
scenarios_dir: &Path,
) -> LuaResult<()> {
let dirs = lua.create_table()?;
dirs.set("state", state_dir.to_string_lossy().into_owned())?;
dirs.set("cards", cards_dir.to_string_lossy().into_owned())?;
dirs.set("scenarios", scenarios_dir.to_string_lossy().into_owned())?;
alc_table.set("_dirs", dirs)?;
Ok(())
}
pub(super) fn register_card(
lua: &Lua,
alc_table: &LuaTable,
card_store: Arc<FileCardStore>,
) -> LuaResult<()> {
let card_table = lua.create_table()?;
let store_create = Arc::clone(&card_store);
let create = lua.create_function(move |lua, input: LuaValue| {
let json: serde_json::Value = lua.from_value(input)?;
let (card_id, path) = store_create.create(json).map_err(LuaError::external)?;
let ret = lua.create_table()?;
ret.set("card_id", card_id)?;
ret.set("path", path.to_string_lossy().to_string())?;
Ok(ret)
})?;
let store_get = Arc::clone(&card_store);
let get = lua.create_function(move |lua, card_id: String| match store_get.get(&card_id) {
Ok(Some(v)) => lua.to_value(&v),
Ok(None) => Ok(LuaValue::Nil),
Err(e) => Err(LuaError::external(e)),
})?;
let store_list = Arc::clone(&card_store);
let list = lua.create_function(move |lua, filter: Option<LuaTable>| {
let pkg = match filter {
Some(t) => t.get::<Option<String>>("pkg")?,
None => None,
};
let rows = store_list
.list(pkg.as_deref())
.map_err(LuaError::external)?;
lua.to_value(&card::summaries_to_json(&rows))
})?;
let store_append = Arc::clone(&card_store);
let append = lua.create_function(move |lua, (card_id, fields): (String, LuaValue)| {
let json: serde_json::Value = lua.from_value(fields)?;
let merged = store_append
.append(&card_id, json)
.map_err(LuaError::external)?;
lua.to_value(&merged)
})?;
let store_gba = Arc::clone(&card_store);
let get_by_alias = lua.create_function(move |lua, name: String| {
match store_gba.get_by_alias(&name).map_err(LuaError::external)? {
Some(v) => lua.to_value(&v),
None => Ok(LuaValue::Nil),
}
})?;
let store_aset = Arc::clone(&card_store);
let alias_set = lua.create_function(
move |lua, (name, card_id, opts): (String, String, Option<LuaTable>)| {
let (pkg, note) = match opts {
Some(t) => (
t.get::<Option<String>>("pkg")?,
t.get::<Option<String>>("note")?,
),
None => (None, None),
};
let a = store_aset
.alias_set(&name, &card_id, pkg.as_deref(), note.as_deref())
.map_err(LuaError::external)?;
let arr = card::aliases_to_json(&[a]);
let first = match arr {
serde_json::Value::Array(mut v) if !v.is_empty() => v.remove(0),
other => other,
};
lua.to_value(&first)
},
)?;
let store_alist = Arc::clone(&card_store);
let alias_list = lua.create_function(move |lua, filter: Option<LuaTable>| {
let pkg = match filter {
Some(t) => t.get::<Option<String>>("pkg")?,
None => None,
};
let rows = store_alist
.alias_list(pkg.as_deref())
.map_err(LuaError::external)?;
lua.to_value(&card::aliases_to_json(&rows))
})?;
let store_find = Arc::clone(&card_store);
let find = lua.create_function(move |lua, query: Option<LuaTable>| {
let q = match query {
Some(t) => {
let pkg = t.get::<Option<String>>("pkg")?;
let limit = t.get::<Option<usize>>("limit")?;
let offset = t.get::<Option<usize>>("offset")?;
let where_parsed = match t.get::<LuaValue>("where")? {
LuaValue::Nil => None,
v => {
let json: serde_json::Value = lua.from_value(v)?;
Some(card::parse_where(&json).map_err(LuaError::external)?)
}
};
let order_parsed = match t.get::<LuaValue>("order_by")? {
LuaValue::Nil => Vec::new(),
v => {
let json: serde_json::Value = lua.from_value(v)?;
card::parse_order_by(&json).map_err(LuaError::external)?
}
};
card::FindQuery {
pkg,
where_: where_parsed,
order_by: order_parsed,
limit,
offset,
}
}
None => card::FindQuery::default(),
};
let rows = store_find.find(q).map_err(LuaError::external)?;
lua.to_value(&card::summaries_to_json(&rows))
})?;
let store_ws = Arc::clone(&card_store);
let write_samples =
lua.create_function(move |lua, (card_id, samples): (String, LuaValue)| {
let json: serde_json::Value = lua.from_value(samples)?;
let arr = match json {
serde_json::Value::Array(a) => a,
_ => {
return Err(LuaError::external(
"alc.card.write_samples: samples must be an array",
))
}
};
let count = arr.len();
let path = store_ws
.write_samples(&card_id, arr)
.map_err(LuaError::external)?;
let ret = lua.create_table()?;
ret.set("path", path.to_string_lossy().to_string())?;
ret.set("count", count)?;
Ok(ret)
})?;
let store_rs = Arc::clone(&card_store);
let read_samples =
lua.create_function(move |lua, (card_id, opts): (String, Option<LuaTable>)| {
let (offset, limit, where_parsed) = match opts {
Some(t) => {
let offset = t.get::<Option<usize>>("offset")?.unwrap_or(0);
let limit = t.get::<Option<usize>>("limit")?;
let where_parsed = match t.get::<LuaValue>("where")? {
LuaValue::Nil => None,
v => {
let json: serde_json::Value = lua.from_value(v)?;
Some(card::parse_where(&json).map_err(LuaError::external)?)
}
};
(offset, limit, where_parsed)
}
None => (0, None, None),
};
let q = card::SamplesQuery {
offset,
limit,
where_: where_parsed,
};
let rows = store_rs
.read_samples(&card_id, q)
.map_err(LuaError::external)?;
lua.to_value(&serde_json::Value::Array(rows))
})?;
let store_sb = Arc::clone(&card_store);
let sink_backfill = lua.create_function(move |lua, params: LuaTable| {
let sink: String = params.get("sink")?;
let dry_run: Option<bool> = params.get("dry_run")?;
let report = store_sb
.card_sink_backfill(&sink, dry_run.unwrap_or(false))
.map_err(LuaError::external)?;
lua.to_value(&report)
})?;
let store_lin = Arc::clone(&card_store);
let lineage = lua.create_function(move |lua, query: LuaTable| {
let card_id: String = query.get("card_id")?;
let direction_str: Option<String> = query.get("direction")?;
let direction = match direction_str.as_deref() {
Some(s) => card::LineageDirection::parse(s).map_err(LuaError::external)?,
None => card::LineageDirection::Up,
};
let depth: Option<usize> = query.get("depth")?;
let include_stats: Option<bool> = query.get("include_stats")?;
let relation_filter: Option<Vec<String>> = match query.get::<LuaValue>("relation_filter")? {
LuaValue::Nil => None,
v => Some(lua.from_value(v)?),
};
let q = card::LineageQuery {
card_id,
direction,
depth,
include_stats: include_stats.unwrap_or(true),
relation_filter,
};
match store_lin.lineage(q).map_err(LuaError::external)? {
Some(res) => lua.to_value(&card::lineage_to_json(&res)),
None => Ok(LuaValue::Nil),
}
})?;
card_table.set("create", create)?;
card_table.set("get", get)?;
card_table.set("list", list)?;
card_table.set("append", append)?;
card_table.set("get_by_alias", get_by_alias)?;
card_table.set("alias_set", alias_set)?;
card_table.set("alias_list", alias_list)?;
card_table.set("find", find)?;
card_table.set("write_samples", write_samples)?;
card_table.set("read_samples", read_samples)?;
card_table.set("lineage", lineage)?;
card_table.set("sink_backfill", sink_backfill)?;
alc_table.set("card", card_table)?;
Ok(())
}
pub(super) fn register_stats(
lua: &Lua,
alc_table: &LuaTable,
custom_metrics: CustomMetricsHandle,
stats: StatsHandle,
) -> LuaResult<()> {
let stats_table = lua.create_table()?;
let cm_record = custom_metrics.clone();
let record = lua.create_function(move |lua, (key, value): (String, LuaValue)| {
let json: serde_json::Value = lua.from_value(value)?;
cm_record.record(key, json);
Ok(())
})?;
let cm_get = custom_metrics;
let get = lua.create_function(move |lua, key: String| match cm_get.get(&key) {
Some(v) => lua.to_value(&v),
None => Ok(LuaValue::Nil),
})?;
let stats_handle = stats;
let llm_calls = lua.create_function(move |_, ()| Ok(stats_handle.llm_calls()))?;
stats_table.set("record", record)?;
stats_table.set("get", get)?;
stats_table.set("llm_calls", llm_calls)?;
alc_table.set("stats", stats_table)?;
Ok(())
}
pub struct AlcEnv(pub Arc<HashMap<String, String>>);
impl mlua::UserData for AlcEnv {
fn add_methods<M: mlua::UserDataMethods<Self>>(methods: &mut M) {
methods.add_meta_method(mlua::MetaMethod::Index, |_, this, key: String| {
Ok(this.0.get(&key).cloned())
});
methods.add_meta_method(
mlua::MetaMethod::NewIndex,
|_, _, (_k, _v): (mlua::Value, mlua::Value)| {
Err::<(), _>(mlua::Error::external("alc.env is readonly"))
},
);
methods.add_method(
"get",
|_, this, (key, default): (String, Option<String>)| {
Ok(this.0.get(&key).cloned().or(default))
},
);
methods.add_method("use", |lua, this, declared: Vec<String>| {
let proxy = lua.create_table()?;
for k in &declared {
if let Some(v) = this.0.get(k) {
proxy.set(k.clone(), v.clone())?;
}
}
Ok(proxy)
});
}
}
pub fn register_env(
lua: &mlua::Lua,
alc_table: &mlua::Table,
env_map: Arc<HashMap<String, String>>,
) -> mlua::Result<()> {
alc_table.set("env", AlcEnv(Arc::clone(&env_map)))?;
lua.set_app_data(env_map);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use algocline_core::ExecutionMetrics;
fn test_config_with(ns: &str) -> crate::bridge::BridgeConfig {
let metrics = ExecutionMetrics::new();
let tmp = tempfile::tempdir().expect("test tempdir");
let root = tmp.path().to_path_buf();
std::mem::forget(tmp);
crate::bridge::BridgeConfig {
llm_tx: None,
ns: ns.into(),
custom_metrics: metrics.custom_metrics_handle(),
stats: metrics.stats_handle(),
budget: metrics.budget_handle(),
progress: metrics.progress_handle(),
lib_paths: vec![],
variant_pkgs: vec![],
state_store: Arc::new(JsonFileStore::new(root.join("state"))),
card_store: Arc::new(FileCardStore::new(root.join("cards"))),
scenarios_dir: root.join("scenarios"),
log_sink: None,
}
}
fn test_config() -> crate::bridge::BridgeConfig {
test_config_with("default")
}
fn test_config_with_ns(ns: &str) -> crate::bridge::BridgeConfig {
test_config_with(ns)
}
#[test]
fn json_roundtrip() {
let lua = Lua::new();
let t = lua.create_table().unwrap();
crate::bridge::register(&lua, &t, test_config()).unwrap();
lua.globals().set("alc", t).unwrap();
let result: String = lua
.load(r#"return alc.json_encode({hello = "world", n = 42})"#)
.eval()
.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["hello"], "world");
assert_eq!(parsed["n"], 42);
}
#[test]
fn json_decode_encode() {
let lua = Lua::new();
let t = lua.create_table().unwrap();
crate::bridge::register(&lua, &t, test_config()).unwrap();
lua.globals().set("alc", t).unwrap();
let result: String = lua
.load(
r#"
local val = alc.json_decode('{"a":1,"b":"two"}')
val.c = true
return alc.json_encode(val)
"#,
)
.eval()
.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["a"], 1);
assert_eq!(parsed["b"], "two");
assert_eq!(parsed["c"], true);
}
#[test]
fn state_get_set() {
let ns = "_test_bridge_state";
let lua = Lua::new();
let t = lua.create_table().unwrap();
crate::bridge::register(&lua, &t, test_config_with_ns(ns)).unwrap();
lua.globals().set("alc", t).unwrap();
lua.load(r#"alc.state.set("x", 99)"#).exec().unwrap();
let result: i64 = lua.load(r#"return alc.state.get("x")"#).eval().unwrap();
assert_eq!(result, 99);
let result: i64 = lua
.load(r#"return alc.state.get("missing", 0)"#)
.eval()
.unwrap();
assert_eq!(result, 0);
let result: LuaValue = lua
.load(r#"return alc.state.get("missing")"#)
.eval()
.unwrap();
assert!(result.is_nil());
}
#[test]
fn state_has_set_nx_incr() {
let ns = "_test_bridge_state_t1";
let lua = Lua::new();
let t = lua.create_table().unwrap();
crate::bridge::register(&lua, &t, test_config_with_ns(ns)).unwrap();
lua.globals().set("alc", t).unwrap();
let h: bool = lua.load(r#"return alc.state.has("k")"#).eval().unwrap();
assert!(!h);
let ok: bool = lua
.load(r#"return alc.state.set_nx("k", "first")"#)
.eval()
.unwrap();
assert!(ok);
let h: bool = lua.load(r#"return alc.state.has("k")"#).eval().unwrap();
assert!(h);
let ok: bool = lua
.load(r#"return alc.state.set_nx("k", "second")"#)
.eval()
.unwrap();
assert!(!ok);
let v: f64 = lua
.load(r#"return alc.state.incr("counter")"#)
.eval()
.unwrap();
assert!((v - 1.0).abs() < f64::EPSILON);
let v: f64 = lua
.load(r#"return alc.state.incr("counter", 5)"#)
.eval()
.unwrap();
assert!((v - 6.0).abs() < f64::EPSILON);
let v: f64 = lua
.load(r#"return alc.state.incr("counter", 10, 100)"#)
.eval()
.unwrap();
assert!((v - 16.0).abs() < f64::EPSILON);
}
#[test]
fn card_create_get_list_from_lua() {
let ns = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let pkg = format!("_test_bridge_card_{ns}");
let lua = Lua::new();
let t = lua.create_table().unwrap();
crate::bridge::register(&lua, &t, test_config()).unwrap();
lua.globals().set("alc", t).unwrap();
let create_script = format!(
r#"
local r = alc.card.create({{
pkg = {{ name = "{pkg}" }},
model = {{ id = "claude-opus-4-6" }},
stats = {{ pass_rate = 0.9 }},
}})
return r.card_id
"#
);
let card_id: String = lua.load(&create_script).eval().unwrap();
assert!(card_id.starts_with(&pkg));
let get_script = format!(r#"return alc.card.get("{card_id}").stats.pass_rate"#);
let rate: f64 = lua.load(&get_script).eval().unwrap();
assert!((rate - 0.9).abs() < 1e-9);
let list_script = format!(
r#"
local rows = alc.card.list({{ pkg = "{pkg}" }})
return #rows
"#
);
let count: i64 = lua.load(&list_script).eval().unwrap();
assert_eq!(count, 1);
}
#[test]
fn stats_record_get() {
let metrics = ExecutionMetrics::new();
let custom_handle = metrics.custom_metrics_handle();
let lua = Lua::new();
let t = lua.create_table().unwrap();
let tmp = tempfile::tempdir().expect("test tempdir");
let root = tmp.path().to_path_buf();
std::mem::forget(tmp);
crate::bridge::register(
&lua,
&t,
crate::bridge::BridgeConfig {
llm_tx: None,
ns: "default".into(),
custom_metrics: custom_handle.clone(),
stats: metrics.stats_handle(),
budget: metrics.budget_handle(),
progress: metrics.progress_handle(),
lib_paths: vec![],
variant_pkgs: vec![],
state_store: Arc::new(JsonFileStore::new(root.join("state"))),
card_store: Arc::new(FileCardStore::new(root.join("cards"))),
scenarios_dir: root.join("scenarios"),
log_sink: None,
},
)
.unwrap();
lua.globals().set("alc", t).unwrap();
lua.load(r#"alc.stats.record("score", 42)"#).exec().unwrap();
let result: i64 = lua.load(r#"return alc.stats.get("score")"#).eval().unwrap();
assert_eq!(result, 42);
assert_eq!(custom_handle.get("score"), Some(serde_json::json!(42)));
let result: LuaValue = lua
.load(r#"return alc.stats.get("missing")"#)
.eval()
.unwrap();
assert!(result.is_nil());
}
#[test]
fn stats_llm_calls_reads_session_status() {
use crate::card::FileCardStore;
use crate::state::JsonFileStore;
use algocline_core::{ExecutionObserver, LlmQuery, QueryId};
use std::sync::Arc;
let metrics = ExecutionMetrics::new();
let observer = metrics.create_observer();
let lua = Lua::new();
let t = lua.create_table().unwrap();
let tmp = tempfile::tempdir().expect("test tempdir");
let root = tmp.path().to_path_buf();
std::mem::forget(tmp);
crate::bridge::register(
&lua,
&t,
crate::bridge::BridgeConfig {
llm_tx: None,
ns: "default".into(),
custom_metrics: metrics.custom_metrics_handle(),
stats: metrics.stats_handle(),
budget: metrics.budget_handle(),
progress: metrics.progress_handle(),
lib_paths: vec![],
variant_pkgs: vec![],
state_store: Arc::new(JsonFileStore::new(root.join("state"))),
card_store: Arc::new(FileCardStore::new(root.join("cards"))),
scenarios_dir: root.join("scenarios"),
log_sink: None,
},
)
.unwrap();
lua.globals().set("alc", t).unwrap();
let initial: u64 = lua.load(r#"return alc.stats.llm_calls()"#).eval().unwrap();
assert_eq!(initial, 0, "fresh session must report llm_calls() == 0");
observer.on_paused(&[LlmQuery {
id: QueryId::parse("q-0"),
prompt: "hi".to_string(),
system: None,
max_tokens: 0,
grounded: false,
underspecified: false,
}]);
let after_one: u64 = lua.load(r#"return alc.stats.llm_calls()"#).eval().unwrap();
assert_eq!(
after_one, 1,
"one paused query must increment llm_calls() to 1"
);
observer.on_paused(&[
LlmQuery {
id: QueryId::parse("q-1"),
prompt: "a".to_string(),
system: None,
max_tokens: 0,
grounded: false,
underspecified: false,
},
LlmQuery {
id: QueryId::parse("q-2"),
prompt: "b".to_string(),
system: None,
max_tokens: 0,
grounded: false,
underspecified: false,
},
]);
let after_three: u64 = lua.load(r#"return alc.stats.llm_calls()"#).eval().unwrap();
assert_eq!(
after_three, 3,
"two further paused queries (multi-query batch) must bring llm_calls() to 3"
);
}
#[test]
fn register_log_pushes_to_log_sink() {
use algocline_core::LogSink;
let sink = LogSink::new();
let lua = Lua::new();
let t = lua.create_table().unwrap();
register_log(&lua, &t, sink.clone()).unwrap();
lua.globals().set("alc", t).unwrap();
lua.load(r#"alc.log("info", "hello-from-log")"#)
.exec()
.unwrap();
let entries = sink.entries();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].source, "alc.log");
assert_eq!(entries[0].level, "info");
assert_eq!(entries[0].message, "hello-from-log");
}
#[test]
fn register_log_unknown_level_still_pushes() {
use algocline_core::LogSink;
let sink = LogSink::new();
let lua = Lua::new();
let t = lua.create_table().unwrap();
register_log(&lua, &t, sink.clone()).unwrap();
lua.globals().set("alc", t).unwrap();
lua.load(r#"alc.log("custom", "edge-case")"#)
.exec()
.unwrap();
let entries = sink.entries();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].source, "alc.log");
assert_eq!(entries[0].level, "custom");
assert_eq!(entries[0].message, "edge-case");
}
#[test]
fn register_log_empty_message() {
use algocline_core::LogSink;
let sink = LogSink::new();
let lua = Lua::new();
let t = lua.create_table().unwrap();
register_log(&lua, &t, sink.clone()).unwrap();
lua.globals().set("alc", t).unwrap();
lua.load(r#"alc.log("warn", "")"#)
.exec()
.unwrap();
let entries = sink.entries();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].message, "");
}
#[test]
fn register_print_pushes_to_log_sink() {
use algocline_core::LogSink;
let sink = LogSink::new();
let lua = Lua::new();
register_print(&lua, sink.clone()).unwrap();
lua.load(r#"print("hello-print")"#)
.exec()
.unwrap();
let entries = sink.entries();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].source, "alc.lua.print");
assert_eq!(entries[0].level, "info");
assert_eq!(entries[0].message, "hello-print");
}
#[test]
fn register_print_multiple_args_tab_joined() {
use algocline_core::LogSink;
let sink = LogSink::new();
let lua = Lua::new();
register_print(&lua, sink.clone()).unwrap();
lua.load(r#"print("a", "b", "c")"#)
.exec()
.unwrap();
let entries = sink.entries();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].message, "a\tb\tc");
}
#[test]
fn register_print_mixed_value_types() {
use algocline_core::LogSink;
let sink = LogSink::new();
let lua = Lua::new();
register_print(&lua, sink.clone()).unwrap();
lua.load(r#"print(nil, true, 42, 3.14)"#)
.exec()
.unwrap();
let entries = sink.entries();
assert_eq!(entries.len(), 1);
let msg = &entries[0].message;
assert!(msg.starts_with("nil\ttrue\t42\t"), "got: {msg}");
}
fn make_env_lua(pairs: &[(&str, &str)]) -> (Lua, Arc<HashMap<String, String>>) {
let mut map = HashMap::new();
for (k, v) in pairs {
map.insert(k.to_string(), v.to_string());
}
let env_map = Arc::new(map);
let lua = Lua::new();
let alc_table = lua.create_table().unwrap();
register_env(&lua, &alc_table, Arc::clone(&env_map)).unwrap();
lua.globals().set("alc", alc_table).unwrap();
(lua, env_map)
}
#[test]
fn env_index_reads_existing_key() {
let (lua, _) = make_env_lua(&[("FOO", "bar")]);
let val: Option<String> = lua.load(r#"return alc.env.FOO"#).eval().unwrap();
assert_eq!(val, Some("bar".to_string()));
}
#[test]
fn env_index_missing_key_returns_nil() {
let (lua, _) = make_env_lua(&[("FOO", "bar")]);
let val: LuaValue = lua.load(r#"return alc.env.MISSING"#).eval().unwrap();
assert!(val.is_nil());
}
#[test]
fn env_newindex_returns_error() {
let (lua, _) = make_env_lua(&[("FOO", "bar")]);
let result: Result<(), _> = lua.load(r#"alc.env.FOO = "x""#).exec();
let err = result.unwrap_err().to_string();
assert!(
err.contains("alc.env is readonly"),
"expected readonly error, got: {err}"
);
}
#[test]
fn env_get_with_default_returns_default_on_miss() {
let (lua, _) = make_env_lua(&[]);
let val: Option<String> = lua
.load(r#"return alc.env:get("MISSING", "fallback")"#)
.eval()
.unwrap();
assert_eq!(val, Some("fallback".to_string()));
}
#[test]
fn env_get_returns_value_when_present() {
let (lua, _) = make_env_lua(&[("KEY", "val")]);
let val: Option<String> = lua
.load(r#"return alc.env:get("KEY", "default")"#)
.eval()
.unwrap();
assert_eq!(val, Some("val".to_string()));
}
#[test]
fn env_use_returns_declared_keys_only() {
let (lua, _) = make_env_lua(&[("FOO", "foo_val"), ("BAR", "bar_val"), ("SECRET", "s")]);
let result: LuaValue = lua
.load(
r#"
local e = alc.env:use{"FOO", "BAR"}
return e
"#,
)
.eval()
.unwrap();
let tbl = result.as_table().unwrap();
assert_eq!(tbl.get::<String>("FOO").unwrap(), "foo_val");
assert_eq!(tbl.get::<String>("BAR").unwrap(), "bar_val");
let secret: LuaValue = tbl.get("SECRET").unwrap();
assert!(secret.is_nil(), "SECRET should be nil in proxy");
}
#[test]
fn env_use_undeclared_key_is_nil() {
let (lua, _) = make_env_lua(&[("FOO", "foo_val")]);
let val: LuaValue = lua
.load(
r#"
local e = alc.env:use{"FOO"}
return e.UNDECLARED
"#,
)
.eval()
.unwrap();
assert!(val.is_nil());
}
#[test]
fn register_env_sets_app_data() {
let mut map = HashMap::new();
map.insert("X".to_string(), "1".to_string());
let env_map = Arc::new(map);
let lua = Lua::new();
let alc_table = lua.create_table().unwrap();
register_env(&lua, &alc_table, Arc::clone(&env_map)).unwrap();
let retrieved = lua.app_data_ref::<Arc<HashMap<String, String>>>().unwrap();
assert_eq!(retrieved.get("X").unwrap(), "1");
}
mod state_dispatched_lua {
use super::*;
use mlua::Lua;
use std::sync::Arc;
use tempfile::TempDir;
fn setup() -> (Lua, Arc<JsonFileStore>, TempDir) {
let tmp = tempfile::tempdir().unwrap();
let store = Arc::new(JsonFileStore::new(tmp.path().to_path_buf()));
let lua = Lua::new();
let alc = lua.create_table().unwrap();
register_state(&lua, &alc, "default".to_string(), Arc::clone(&store)).unwrap();
lua.globals().set("alc", alc).unwrap();
(lua, store, tmp)
}
#[test]
fn list_returns_sorted_keys() {
let (lua, _store, tmp) = setup();
std::fs::create_dir_all(tmp.path().join("testns")).unwrap();
std::fs::write(
tmp.path().join("testns/beta.json"),
r#"{"data": {"completed_steps": [], "x": 1}}"#,
)
.unwrap();
std::fs::write(
tmp.path().join("testns/alpha.json"),
r#"{"data": {"completed_steps": [], "y": 2}}"#,
)
.unwrap();
lua.load(
r#"
local result = alc.state.list("testns")
assert(#result == 2, "expected 2 keys, got " .. #result)
assert(result[1] == "alpha", "first key should be alpha, got " .. tostring(result[1]))
assert(result[2] == "beta", "second key should be beta, got " .. tostring(result[2]))
"#,
)
.exec()
.unwrap();
}
#[test]
fn show_returns_full_table() {
let (lua, _store, tmp) = setup();
std::fs::create_dir_all(tmp.path().join("testns")).unwrap();
std::fs::write(
tmp.path().join("testns/alpha.json"),
r#"{"data": {"completed_steps": ["a", "b", "c"], "x": 1, "y": 2}}"#,
)
.unwrap();
lua.load(
r#"
local result = alc.state.show("testns", "alpha")
assert(type(result) == "table", "expected table")
assert(type(result.data) == "table", "expected result.data to be a table")
assert(result.data.x == 1, "expected x=1")
assert(result.data.y == 2, "expected y=2")
assert(#result.data.completed_steps == 3, "expected 3 steps")
"#,
)
.exec()
.unwrap();
}
#[test]
fn show_missing_returns_not_found_error() {
let (lua, _store, _tmp) = setup();
lua.load(
r#"
local ok, err = pcall(alc.state.show, "testns", "missing")
assert(not ok, "expected error but got success")
local msg = tostring(err)
assert(string.find(msg, "not found"), "error message should contain 'not found', got: " .. msg)
"#,
)
.exec()
.unwrap();
}
#[test]
fn reset_removes_steps_and_fields_with_backup() {
let (lua, _store, tmp) = setup();
std::fs::create_dir_all(tmp.path().join("testns")).unwrap();
let file_path = tmp.path().join("testns/alpha.json");
std::fs::write(
&file_path,
r#"{"data": {"completed_steps": ["a", "b", "c"], "x": 1, "y": 2}}"#,
)
.unwrap();
let tmp_path_str = tmp.path().to_string_lossy().to_string();
lua.globals().set("TMP_PATH", tmp_path_str.clone()).unwrap();
lua.load(
r#"
local r = alc.state.reset("testns", "alpha", {steps={"b"}, fields={"x"}})
assert(r.ok == true, "expected ok=true")
assert(type(r.backup_path) == "string", "backup_path should be a string")
assert(r.steps_removed == 1, "expected steps_removed=1, got " .. tostring(r.steps_removed))
assert(r.fields_removed == 1, "expected fields_removed=1, got " .. tostring(r.fields_removed))
"#,
)
.exec()
.unwrap();
let bak_path = tmp.path().join("testns/alpha.json.bak");
assert!(
bak_path.exists(),
"backup file should exist at {:?}",
bak_path
);
let bak_content = std::fs::read_to_string(&bak_path).unwrap();
assert!(
bak_content.contains("\"b\""),
"backup should contain original 'b' step"
);
let live_content = std::fs::read_to_string(&file_path).unwrap();
let live: serde_json::Value = serde_json::from_str(&live_content).unwrap();
let steps = live["data"]["completed_steps"].as_array().unwrap();
assert!(
!steps.iter().any(|s| s.as_str() == Some("b")),
"step 'b' should be removed from completed_steps"
);
assert!(
live["data"]["x"].is_null() || live["data"].get("x").is_none(),
"field 'x' should be removed from data"
);
}
#[test]
fn unsafe_namespace_rejected() {
let (lua, _store, _tmp) = setup();
lua.load(
r#"
local ok, err = pcall(alc.state.list, "../evil")
assert(not ok, "expected error for unsafe namespace")
local msg = tostring(err)
assert(string.find(msg, "unsafe"), "error should contain 'unsafe', got: " .. msg)
"#,
)
.exec()
.unwrap();
lua.load(
r#"
local ok, err = pcall(alc.state.show, "../evil", "key")
assert(not ok, "expected error for unsafe namespace in show")
local msg = tostring(err)
assert(string.find(msg, "unsafe"), "error should contain 'unsafe', got: " .. msg)
"#,
)
.exec()
.unwrap();
lua.load(
r#"
local ok, err = pcall(alc.state.reset, "../evil", "key", {})
assert(not ok, "expected error for unsafe namespace in reset")
local msg = tostring(err)
assert(string.find(msg, "unsafe"), "error should contain 'unsafe', got: " .. msg)
"#,
)
.exec()
.unwrap();
}
}
}