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