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 evalframe;
22mod fork;
23mod fuzzy;
24mod llm;
25#[cfg(feature = "nn")]
26mod nn_card;
27mod text;
28
29use crate::card::FileCardStore;
30use crate::llm_bridge::LlmRequest;
31use crate::state::JsonFileStore;
32use crate::variant_pkg::VariantPkg;
33
34pub const PRELUDE: &str = include_str!("../prelude.lua");
36
37pub struct BridgeConfig {
42 pub llm_tx: Option<tokio::sync::mpsc::Sender<LlmRequest>>,
44 pub ns: String,
46 pub custom_metrics: CustomMetricsHandle,
48 pub stats: StatsHandle,
50 pub budget: BudgetHandle,
52 pub progress: ProgressHandle,
54 pub lib_paths: Vec<PathBuf>,
56 pub variant_pkgs: Vec<VariantPkg>,
58 pub state_store: Arc<JsonFileStore>,
60 pub card_store: Arc<FileCardStore>,
62 pub card_run_enabled: bool,
71 pub scenarios_dir: PathBuf,
73 pub nn_dir: PathBuf,
86 pub log_sink: Option<LogSink>,
95}
96
97pub use data::register_env;
98pub use evalframe::register_evalframe;
99
100pub fn register(lua: &Lua, alc_table: &LuaTable, config: BridgeConfig) -> LuaResult<()> {
102 data::register_json(lua, alc_table)?;
103 fuzzy::register_fuzzy(lua, alc_table)?;
104 if let Some(sink) = config.log_sink.clone() {
106 data::register_log(lua, alc_table, sink.clone())?;
107 data::register_print(lua, sink)?;
109 } else {
110 data::register_log(lua, alc_table, algocline_core::LogSink::new())?;
112 }
113 data::register_state(lua, alc_table, config.ns, Arc::clone(&config.state_store))?;
114 data::register_card(
115 lua,
116 alc_table,
117 Arc::clone(&config.card_store),
118 config.card_run_enabled,
119 )?;
120 data::register_dirs(
121 lua,
122 alc_table,
123 config.state_store.root(),
124 config.card_store.root(),
125 &config.scenarios_dir,
126 )?;
127 text::register_chunk(lua, alc_table)?;
128 data::register_stats(lua, alc_table, config.custom_metrics, config.stats)?;
129 register_time(lua, alc_table)?;
130 register_math(lua, alc_table)?;
131 #[cfg(feature = "nn")]
132 register_nn(lua, alc_table, config.nn_dir.clone())?;
133 #[cfg(feature = "nn")]
134 nn_card::register_nn_card(
135 lua,
136 alc_table,
137 Arc::clone(&config.card_store),
138 config.nn_dir.clone(),
139 )?;
140 llm::register_budget_remaining(lua, alc_table, config.budget.clone())?;
141 llm::register_progress(lua, alc_table, config.progress)?;
142 if let Some(tx) = config.llm_tx {
143 llm::register_llm(
144 lua,
145 alc_table,
146 tx.clone(),
147 config.budget.clone(),
148 Arc::clone(&config.card_store),
149 )?;
150 llm::register_llm_batch(lua, alc_table, tx.clone(), config.budget.clone())?;
151 fork::register_fork(
152 lua,
153 alc_table,
154 tx,
155 config.budget,
156 config.lib_paths,
157 config.variant_pkgs,
158 config.state_store,
159 config.card_store,
160 config.card_run_enabled,
161 config.scenarios_dir,
162 config.nn_dir,
163 )?;
164 }
165 Ok(())
166}
167
168fn register_math(lua: &Lua, alc_table: &LuaTable) -> LuaResult<()> {
170 let math_table = mlua_mathlib::module(lua)?;
171 alc_table.set("math", math_table)?;
172 Ok(())
173}
174
175#[cfg(feature = "nn")]
189fn register_nn(lua: &Lua, alc_table: &LuaTable, nn_dir: PathBuf) -> LuaResult<()> {
190 let nn_table = algocline_nn::module(lua)?;
191 alc_table.set("nn", nn_table)?;
192 if !nn_dir.as_os_str().is_empty() {
193 algocline_nn::install_store(lua, Arc::new(algocline_nn::FsStore::new(nn_dir)))?;
194 }
195 Ok(())
196}
197
198pub(crate) const MOCK_LAYER: &str = include_str!("mock.lua");
201
202pub fn install_for_pkg_test(lua: &Lua) -> LuaResult<()> {
223 let metrics = ExecutionMetrics::new();
224 let tmp = TempDir::new()
225 .map_err(|e| LuaError::external(format!("install_for_pkg_test: tempdir: {e}")))?;
226 let root = tmp.path().to_path_buf();
227 lua.set_app_data::<TempDir>(tmp);
229
230 let config = BridgeConfig {
231 llm_tx: None,
232 ns: "default".into(),
233 custom_metrics: metrics.custom_metrics_handle(),
234 stats: metrics.stats_handle(),
235 budget: metrics.budget_handle(),
236 progress: metrics.progress_handle(),
237 lib_paths: vec![],
238 variant_pkgs: vec![],
239 state_store: std::sync::Arc::new(crate::state::JsonFileStore::new(root.join("state"))),
240 card_store: std::sync::Arc::new(crate::card::FileCardStore::new(root.join("cards"))),
241 card_run_enabled: true,
246 scenarios_dir: root.join("scenarios"),
247 nn_dir: root.join("nn"),
248 log_sink: None,
249 };
250
251 let alc_table = lua.create_table()?;
252 register(lua, &alc_table, config)?;
253
254 install_external_io_stub(lua, &alc_table, "llm")?;
259 install_external_io_stub(lua, &alc_table, "llm_batch")?;
260 install_external_io_stub(lua, &alc_table, "fork")?;
261
262 lua.globals().set("alc", alc_table)?;
263 evalframe::register_evalframe(lua).map_err(|e| {
264 LuaError::external(format!("install_for_pkg_test: register_evalframe: {e}"))
265 })?;
266 lua.load(PRELUDE)
267 .set_name("@alc_prelude")
268 .exec()
269 .map_err(|e| LuaError::external(format!("install_for_pkg_test: prelude: {e}")))?;
270 lua.load(MOCK_LAYER)
271 .set_name("@bridge_mock")
272 .exec()
273 .map_err(|e| LuaError::external(format!("install_for_pkg_test: mock layer: {e}")))?;
274 Ok(())
275}
276
277fn install_external_io_stub(lua: &Lua, alc_table: &LuaTable, name: &'static str) -> LuaResult<()> {
278 let stub = lua.create_function(
279 move |_, _: mlua::Variadic<LuaValue>| -> LuaResult<LuaValue> {
280 Err(LuaError::external(format!(
281 "mock required: alc.{name} — wrap the call in `with_alc({{ {name} = fn }}, fn)` \
282 inside your spec (alc_pkg_test sandbox stubs external I/O by design)"
283 )))
284 },
285 )?;
286 alc_table.set(name, stub)?;
287 Ok(())
288}
289
290fn register_time(lua: &Lua, alc_table: &LuaTable) -> LuaResult<()> {
299 let time_fn = lua.create_function(|_, ()| {
300 let now = std::time::SystemTime::now()
301 .duration_since(std::time::UNIX_EPOCH)
302 .map_err(mlua::Error::external)?;
303 Ok(now.as_secs_f64())
304 })?;
305 alc_table.set("time", time_fn)?;
306 Ok(())
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312
313 use algocline_core::ExecutionMetrics;
314
315 fn test_config() -> BridgeConfig {
316 let metrics = ExecutionMetrics::new();
317 let tmp = tempfile::tempdir().expect("test tempdir");
318 let root = tmp.path().to_path_buf();
319 std::mem::forget(tmp);
320 BridgeConfig {
321 llm_tx: None,
322 ns: "default".into(),
323 custom_metrics: metrics.custom_metrics_handle(),
324 stats: metrics.stats_handle(),
325 budget: metrics.budget_handle(),
326 progress: metrics.progress_handle(),
327 lib_paths: vec![],
328 variant_pkgs: vec![],
329 state_store: Arc::new(JsonFileStore::new(root.join("state"))),
330 card_store: Arc::new(FileCardStore::new(root.join("cards"))),
331 card_run_enabled: false,
332 scenarios_dir: root.join("scenarios"),
333 nn_dir: root.join("nn"),
334 log_sink: None,
335 }
336 }
337
338 fn setup_with_prelude() -> Lua {
342 let lua = Lua::new();
343 let t = lua.create_table().unwrap();
344 register(&lua, &t, test_config()).unwrap();
345 lua.globals().set("alc", t).unwrap();
346 lua.load(PRELUDE).exec().unwrap();
347 lua
348 }
349
350 #[test]
353 fn cache_info_initial_state() {
354 let lua = setup_with_prelude();
355 let result: LuaValue = lua.load("return alc.cache_info()").eval().unwrap();
356 let tbl = result.as_table().unwrap();
357 assert_eq!(tbl.get::<i64>("entries").unwrap(), 0);
358 assert_eq!(tbl.get::<i64>("hits").unwrap(), 0);
359 assert_eq!(tbl.get::<i64>("misses").unwrap(), 0);
360 }
361
362 #[test]
363 fn cache_clear_resets_state() {
364 let lua = setup_with_prelude();
365 lua.load(
366 r#"
367 -- Simulate cache state by calling cache_info before/after clear
368 local info1 = alc.cache_info()
369 alc.cache_clear()
370 local info2 = alc.cache_info()
371 assert(info2.entries == 0)
372 assert(info2.hits == 0)
373 assert(info2.misses == 0)
374 "#,
375 )
376 .exec()
377 .unwrap();
378 }
379
380 #[test]
383 fn parallel_rejects_empty_items() {
384 let lua = setup_with_prelude();
385 let result: Result<LuaValue, _> = lua
386 .load(r#"return alc.parallel({}, function(x) return x end)"#)
387 .eval();
388 let err = result.unwrap_err().to_string();
389 assert!(
390 err.contains("non-empty array"),
391 "expected non-empty array error, got: {err}"
392 );
393 }
394
395 #[test]
396 fn parallel_rejects_non_function_prompt_fn() {
397 let lua = setup_with_prelude();
398 let result: Result<LuaValue, _> = lua
399 .load(r#"return alc.parallel({"a", "b"}, "not a function")"#)
400 .eval();
401 let err = result.unwrap_err().to_string();
402 assert!(
403 err.contains("prompt_fn must be a function"),
404 "expected function error, got: {err}"
405 );
406 }
407
408 #[test]
409 fn parallel_rejects_invalid_prompt_fn_return() {
410 let lua = setup_with_prelude();
411 let result: Result<LuaValue, _> = lua
412 .load(r#"return alc.parallel({"a"}, function(x) return 42 end)"#)
413 .eval();
414 let err = result.unwrap_err().to_string();
415 assert!(
416 err.contains("must return string or table"),
417 "expected type error, got: {err}"
418 );
419 }
420
421 #[test]
422 fn parallel_rejects_table_without_prompt() {
423 let lua = setup_with_prelude();
424 let result: Result<LuaValue, _> = lua
425 .load(r#"return alc.parallel({"a"}, function(x) return { system = "hi" } end)"#)
426 .eval();
427 let err = result.unwrap_err().to_string();
428 assert!(
429 err.contains("without .prompt"),
430 "expected prompt field error, got: {err}"
431 );
432 }
433
434 #[test]
437 fn fingerprint_deterministic() {
438 let lua = setup_with_prelude();
439 let result: bool = lua
440 .load(r#"return alc.fingerprint("hello") == alc.fingerprint("hello")"#)
441 .eval()
442 .unwrap();
443 assert!(result);
444 }
445
446 #[test]
447 fn fingerprint_normalized() {
448 let lua = setup_with_prelude();
449 let result: bool = lua
450 .load(r#"return alc.fingerprint(" Hello World ") == alc.fingerprint("hello world")"#)
451 .eval()
452 .unwrap();
453 assert!(result);
454 }
455
456 #[test]
459 fn parse_number_basic() {
460 let lua = setup_with_prelude();
461 let result: f64 = lua
462 .load(r#"return alc.parse_number("Found 3 subtasks to implement")"#)
463 .eval()
464 .unwrap();
465 assert!((result - 3.0).abs() < f64::EPSILON);
466 }
467
468 #[test]
469 fn parse_number_decimal() {
470 let lua = setup_with_prelude();
471 let result: f64 = lua
472 .load(r#"return alc.parse_number("Score: 7.5/10")"#)
473 .eval()
474 .unwrap();
475 assert!((result - 7.5).abs() < f64::EPSILON);
476 }
477
478 #[test]
479 fn parse_number_with_pattern() {
480 let lua = setup_with_prelude();
481 let result: f64 = lua
482 .load(r#"return alc.parse_number("Created 3 subtasks for implementation", "(%d+)%s+subtask")"#)
483 .eval()
484 .unwrap();
485 assert!((result - 3.0).abs() < f64::EPSILON);
486 }
487
488 #[test]
489 fn parse_number_nil_on_no_match() {
490 let lua = setup_with_prelude();
491 let result: LuaValue = lua
492 .load(r#"return alc.parse_number("no numbers here")"#)
493 .eval()
494 .unwrap();
495 assert!(result.is_nil());
496 }
497
498 #[test]
499 fn parse_number_negative() {
500 let lua = setup_with_prelude();
501 let result: f64 = lua
502 .load(r#"return alc.parse_number("Temperature: -5 degrees")"#)
503 .eval()
504 .unwrap();
505 assert!((result - (-5.0)).abs() < f64::EPSILON);
506 }
507}