1use std::path::PathBuf;
12use std::sync::Arc;
13
14use algocline_core::{
15 BudgetHandle, CustomMetricsHandle, ExecutionMetrics, LogSink, ProgressHandle, StatsHandle,
16};
17use mlua::prelude::*;
18use tempfile::TempDir;
19
20mod data;
21mod fork;
22mod fuzzy;
23mod llm;
24mod text;
25
26use crate::card::FileCardStore;
27use crate::llm_bridge::LlmRequest;
28use crate::state::JsonFileStore;
29use crate::variant_pkg::VariantPkg;
30
31pub const PRELUDE: &str = include_str!("../prelude.lua");
33
34pub struct BridgeConfig {
39 pub llm_tx: Option<tokio::sync::mpsc::Sender<LlmRequest>>,
41 pub ns: String,
43 pub custom_metrics: CustomMetricsHandle,
45 pub stats: StatsHandle,
47 pub budget: BudgetHandle,
49 pub progress: ProgressHandle,
51 pub lib_paths: Vec<PathBuf>,
53 pub variant_pkgs: Vec<VariantPkg>,
55 pub state_store: Arc<JsonFileStore>,
57 pub card_store: Arc<FileCardStore>,
59 pub scenarios_dir: PathBuf,
61 pub log_sink: Option<LogSink>,
70}
71
72pub use data::register_env;
73
74pub fn register(lua: &Lua, alc_table: &LuaTable, config: BridgeConfig) -> LuaResult<()> {
76 data::register_json(lua, alc_table)?;
77 fuzzy::register_fuzzy(lua, alc_table)?;
78 if let Some(sink) = config.log_sink.clone() {
80 data::register_log(lua, alc_table, sink.clone())?;
81 data::register_print(lua, sink)?;
83 } else {
84 data::register_log(lua, alc_table, algocline_core::LogSink::new())?;
86 }
87 data::register_state(lua, alc_table, config.ns, Arc::clone(&config.state_store))?;
88 data::register_card(lua, alc_table, Arc::clone(&config.card_store))?;
89 data::register_dirs(
90 lua,
91 alc_table,
92 config.state_store.root(),
93 config.card_store.root(),
94 &config.scenarios_dir,
95 )?;
96 text::register_chunk(lua, alc_table)?;
97 data::register_stats(lua, alc_table, config.custom_metrics, config.stats)?;
98 register_time(lua, alc_table)?;
99 register_math(lua, alc_table)?;
100 llm::register_budget_remaining(lua, alc_table, config.budget.clone())?;
101 llm::register_progress(lua, alc_table, config.progress)?;
102 if let Some(tx) = config.llm_tx {
103 llm::register_llm(lua, alc_table, tx.clone(), config.budget.clone())?;
104 llm::register_llm_batch(lua, alc_table, tx.clone(), config.budget.clone())?;
105 fork::register_fork(
106 lua,
107 alc_table,
108 tx,
109 config.budget,
110 config.lib_paths,
111 config.variant_pkgs,
112 config.state_store,
113 config.card_store,
114 config.scenarios_dir,
115 )?;
116 }
117 Ok(())
118}
119
120fn register_math(lua: &Lua, alc_table: &LuaTable) -> LuaResult<()> {
122 let math_table = mlua_mathlib::module(lua)?;
123 alc_table.set("math", math_table)?;
124 Ok(())
125}
126
127pub(crate) const MOCK_LAYER: &str = include_str!("mock.lua");
130
131pub fn install_for_pkg_test(lua: &Lua) -> LuaResult<()> {
152 let metrics = ExecutionMetrics::new();
153 let tmp = TempDir::new()
154 .map_err(|e| LuaError::external(format!("install_for_pkg_test: tempdir: {e}")))?;
155 let root = tmp.path().to_path_buf();
156 lua.set_app_data::<TempDir>(tmp);
158
159 let config = BridgeConfig {
160 llm_tx: None,
161 ns: "default".into(),
162 custom_metrics: metrics.custom_metrics_handle(),
163 stats: metrics.stats_handle(),
164 budget: metrics.budget_handle(),
165 progress: metrics.progress_handle(),
166 lib_paths: vec![],
167 variant_pkgs: vec![],
168 state_store: std::sync::Arc::new(crate::state::JsonFileStore::new(root.join("state"))),
169 card_store: std::sync::Arc::new(crate::card::FileCardStore::new(root.join("cards"))),
170 scenarios_dir: root.join("scenarios"),
171 log_sink: None,
172 };
173
174 let alc_table = lua.create_table()?;
175 register(lua, &alc_table, config)?;
176
177 install_external_io_stub(lua, &alc_table, "llm")?;
182 install_external_io_stub(lua, &alc_table, "llm_batch")?;
183 install_external_io_stub(lua, &alc_table, "fork")?;
184
185 lua.globals().set("alc", alc_table)?;
186 lua.load(PRELUDE)
187 .set_name("@alc_prelude")
188 .exec()
189 .map_err(|e| LuaError::external(format!("install_for_pkg_test: prelude: {e}")))?;
190 lua.load(MOCK_LAYER)
191 .set_name("@bridge_mock")
192 .exec()
193 .map_err(|e| LuaError::external(format!("install_for_pkg_test: mock layer: {e}")))?;
194 Ok(())
195}
196
197fn install_external_io_stub(lua: &Lua, alc_table: &LuaTable, name: &'static str) -> LuaResult<()> {
198 let stub = lua.create_function(
199 move |_, _: mlua::Variadic<LuaValue>| -> LuaResult<LuaValue> {
200 Err(LuaError::external(format!(
201 "mock required: alc.{name} — wrap the call in `with_alc({{ {name} = fn }}, fn)` \
202 inside your spec (alc_pkg_test sandbox stubs external I/O by design)"
203 )))
204 },
205 )?;
206 alc_table.set(name, stub)?;
207 Ok(())
208}
209
210fn register_time(lua: &Lua, alc_table: &LuaTable) -> LuaResult<()> {
219 let time_fn = lua.create_function(|_, ()| {
220 let now = std::time::SystemTime::now()
221 .duration_since(std::time::UNIX_EPOCH)
222 .map_err(mlua::Error::external)?;
223 Ok(now.as_secs_f64())
224 })?;
225 alc_table.set("time", time_fn)?;
226 Ok(())
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 use algocline_core::ExecutionMetrics;
234
235 fn test_config() -> BridgeConfig {
236 let metrics = ExecutionMetrics::new();
237 let tmp = tempfile::tempdir().expect("test tempdir");
238 let root = tmp.path().to_path_buf();
239 std::mem::forget(tmp);
240 BridgeConfig {
241 llm_tx: None,
242 ns: "default".into(),
243 custom_metrics: metrics.custom_metrics_handle(),
244 stats: metrics.stats_handle(),
245 budget: metrics.budget_handle(),
246 progress: metrics.progress_handle(),
247 lib_paths: vec![],
248 variant_pkgs: vec![],
249 state_store: Arc::new(JsonFileStore::new(root.join("state"))),
250 card_store: Arc::new(FileCardStore::new(root.join("cards"))),
251 scenarios_dir: root.join("scenarios"),
252 log_sink: None,
253 }
254 }
255
256 fn setup_with_prelude() -> Lua {
260 let lua = Lua::new();
261 let t = lua.create_table().unwrap();
262 register(&lua, &t, test_config()).unwrap();
263 lua.globals().set("alc", t).unwrap();
264 lua.load(PRELUDE).exec().unwrap();
265 lua
266 }
267
268 #[test]
271 fn cache_info_initial_state() {
272 let lua = setup_with_prelude();
273 let result: LuaValue = lua.load("return alc.cache_info()").eval().unwrap();
274 let tbl = result.as_table().unwrap();
275 assert_eq!(tbl.get::<i64>("entries").unwrap(), 0);
276 assert_eq!(tbl.get::<i64>("hits").unwrap(), 0);
277 assert_eq!(tbl.get::<i64>("misses").unwrap(), 0);
278 }
279
280 #[test]
281 fn cache_clear_resets_state() {
282 let lua = setup_with_prelude();
283 lua.load(
284 r#"
285 -- Simulate cache state by calling cache_info before/after clear
286 local info1 = alc.cache_info()
287 alc.cache_clear()
288 local info2 = alc.cache_info()
289 assert(info2.entries == 0)
290 assert(info2.hits == 0)
291 assert(info2.misses == 0)
292 "#,
293 )
294 .exec()
295 .unwrap();
296 }
297
298 #[test]
301 fn parallel_rejects_empty_items() {
302 let lua = setup_with_prelude();
303 let result: Result<LuaValue, _> = lua
304 .load(r#"return alc.parallel({}, function(x) return x end)"#)
305 .eval();
306 let err = result.unwrap_err().to_string();
307 assert!(
308 err.contains("non-empty array"),
309 "expected non-empty array error, got: {err}"
310 );
311 }
312
313 #[test]
314 fn parallel_rejects_non_function_prompt_fn() {
315 let lua = setup_with_prelude();
316 let result: Result<LuaValue, _> = lua
317 .load(r#"return alc.parallel({"a", "b"}, "not a function")"#)
318 .eval();
319 let err = result.unwrap_err().to_string();
320 assert!(
321 err.contains("prompt_fn must be a function"),
322 "expected function error, got: {err}"
323 );
324 }
325
326 #[test]
327 fn parallel_rejects_invalid_prompt_fn_return() {
328 let lua = setup_with_prelude();
329 let result: Result<LuaValue, _> = lua
330 .load(r#"return alc.parallel({"a"}, function(x) return 42 end)"#)
331 .eval();
332 let err = result.unwrap_err().to_string();
333 assert!(
334 err.contains("must return string or table"),
335 "expected type error, got: {err}"
336 );
337 }
338
339 #[test]
340 fn parallel_rejects_table_without_prompt() {
341 let lua = setup_with_prelude();
342 let result: Result<LuaValue, _> = lua
343 .load(r#"return alc.parallel({"a"}, function(x) return { system = "hi" } end)"#)
344 .eval();
345 let err = result.unwrap_err().to_string();
346 assert!(
347 err.contains("without .prompt"),
348 "expected prompt field error, got: {err}"
349 );
350 }
351
352 #[test]
355 fn fingerprint_deterministic() {
356 let lua = setup_with_prelude();
357 let result: bool = lua
358 .load(r#"return alc.fingerprint("hello") == alc.fingerprint("hello")"#)
359 .eval()
360 .unwrap();
361 assert!(result);
362 }
363
364 #[test]
365 fn fingerprint_normalized() {
366 let lua = setup_with_prelude();
367 let result: bool = lua
368 .load(r#"return alc.fingerprint(" Hello World ") == alc.fingerprint("hello world")"#)
369 .eval()
370 .unwrap();
371 assert!(result);
372 }
373
374 #[test]
377 fn parse_number_basic() {
378 let lua = setup_with_prelude();
379 let result: f64 = lua
380 .load(r#"return alc.parse_number("Found 3 subtasks to implement")"#)
381 .eval()
382 .unwrap();
383 assert!((result - 3.0).abs() < f64::EPSILON);
384 }
385
386 #[test]
387 fn parse_number_decimal() {
388 let lua = setup_with_prelude();
389 let result: f64 = lua
390 .load(r#"return alc.parse_number("Score: 7.5/10")"#)
391 .eval()
392 .unwrap();
393 assert!((result - 7.5).abs() < f64::EPSILON);
394 }
395
396 #[test]
397 fn parse_number_with_pattern() {
398 let lua = setup_with_prelude();
399 let result: f64 = lua
400 .load(r#"return alc.parse_number("Created 3 subtasks for implementation", "(%d+)%s+subtask")"#)
401 .eval()
402 .unwrap();
403 assert!((result - 3.0).abs() < f64::EPSILON);
404 }
405
406 #[test]
407 fn parse_number_nil_on_no_match() {
408 let lua = setup_with_prelude();
409 let result: LuaValue = lua
410 .load(r#"return alc.parse_number("no numbers here")"#)
411 .eval()
412 .unwrap();
413 assert!(result.is_nil());
414 }
415
416 #[test]
417 fn parse_number_negative() {
418 let lua = setup_with_prelude();
419 let result: f64 = lua
420 .load(r#"return alc.parse_number("Temperature: -5 degrees")"#)
421 .eval()
422 .unwrap();
423 assert!((result - (-5.0)).abs() < f64::EPSILON);
424 }
425}