1use crate::component::LuaComponent;
22use crate::error::LuaError;
23use orcs_runtime::sandbox::SandboxPolicy;
24use std::path::{Path, PathBuf};
25use std::sync::Arc;
26
27#[derive(Default)]
32pub struct LoadResult {
33 pub loaded: Vec<(String, LuaComponent)>,
35 pub warnings: Vec<LoadWarning>,
37}
38
39impl std::fmt::Debug for LoadResult {
40 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 f.debug_struct("LoadResult")
42 .field("loaded_count", &self.loaded.len())
43 .field("warnings", &self.warnings)
44 .finish()
45 }
46}
47
48impl LoadResult {
49 #[must_use]
51 pub fn has_loaded(&self) -> bool {
52 !self.loaded.is_empty()
53 }
54
55 #[must_use]
57 pub fn has_warnings(&self) -> bool {
58 !self.warnings.is_empty()
59 }
60
61 #[must_use]
63 pub fn loaded_count(&self) -> usize {
64 self.loaded.len()
65 }
66
67 #[must_use]
69 pub fn warning_count(&self) -> usize {
70 self.warnings.len()
71 }
72}
73
74#[derive(Debug)]
78pub struct LoadWarning {
79 pub path: PathBuf,
81 pub error: LuaError,
83}
84
85impl std::fmt::Display for LoadWarning {
86 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87 write!(f, "{}: {}", self.path.display(), self.error)
88 }
89}
90
91#[derive(Debug, Clone)]
95pub struct ScriptLoader {
96 search_paths: Vec<PathBuf>,
98 sandbox: Arc<dyn SandboxPolicy>,
100}
101
102impl ScriptLoader {
103 #[must_use]
107 pub fn new(sandbox: Arc<dyn SandboxPolicy>) -> Self {
108 Self {
109 search_paths: Vec::new(),
110 sandbox,
111 }
112 }
113
114 #[must_use]
118 pub fn with_path(mut self, path: impl AsRef<Path>) -> Self {
119 self.search_paths.push(path.as_ref().to_path_buf());
120 self
121 }
122
123 #[must_use]
127 pub fn with_project_root(mut self, root: impl AsRef<Path>) -> Self {
128 self.search_paths.push(root.as_ref().join("scripts"));
129 self
130 }
131
132 #[must_use]
134 pub fn with_paths(mut self, paths: impl IntoIterator<Item = impl AsRef<Path>>) -> Self {
135 for path in paths {
136 self.search_paths.push(path.as_ref().to_path_buf());
137 }
138 self
139 }
140
141 #[must_use]
143 pub fn search_paths(&self) -> &[PathBuf] {
144 &self.search_paths
145 }
146
147 pub fn load(&self, name: &str) -> Result<LuaComponent, LuaError> {
160 for path in &self.search_paths {
161 let file_path = path.join(format!("{name}.lua"));
163 if file_path.exists() {
164 return LuaComponent::from_file(&file_path, Arc::clone(&self.sandbox));
165 }
166
167 let dir_path = path.join(name);
169 if dir_path.join("init.lua").exists() {
170 return LuaComponent::from_dir(&dir_path, Arc::clone(&self.sandbox));
171 }
172 }
173
174 let searched: Vec<_> = self
176 .search_paths
177 .iter()
178 .flat_map(|p| {
179 [
180 p.join(format!("{name}.lua")).display().to_string(),
181 p.join(name).join("init.lua").display().to_string(),
182 ]
183 })
184 .collect();
185
186 Err(LuaError::ScriptNotFound(format!(
187 "{} (searched: {})",
188 name,
189 searched.join(", ")
190 )))
191 }
192
193 #[must_use]
197 pub fn list_available(&self) -> Vec<String> {
198 use std::collections::HashSet;
199 let mut names: HashSet<String> = HashSet::new();
200
201 for dir in &self.search_paths {
203 if let Ok(entries) = std::fs::read_dir(dir) {
204 for entry in entries.flatten() {
205 let path = entry.path();
206 if path.is_file() && path.extension().is_some_and(|ext| ext == "lua") {
207 if let Some(stem) = path.file_stem() {
208 names.insert(stem.to_string_lossy().into_owned());
209 }
210 } else if path.is_dir() && path.join("init.lua").exists() {
211 if let Some(name) = path.file_name() {
212 names.insert(name.to_string_lossy().into_owned());
213 }
214 }
215 }
216 }
217 }
218
219 let mut result: Vec<String> = names.into_iter().collect();
220 result.sort();
221 result
222 }
223
224 #[must_use]
251 pub fn load_all(&self) -> LoadResult {
252 let mut result = LoadResult::default();
253
254 for dir in &self.search_paths {
255 if !dir.exists() {
256 continue;
257 }
258
259 let entries = match std::fs::read_dir(dir) {
260 Ok(e) => e,
261 Err(e) => {
262 result.warnings.push(LoadWarning {
263 path: dir.clone(),
264 error: LuaError::ScriptNotFound(format!("failed to read directory: {}", e)),
265 });
266 continue;
267 }
268 };
269
270 for entry in entries.flatten() {
271 let path = entry.path();
272
273 if path.is_file() && path.extension().is_some_and(|ext| ext == "lua") {
274 match LuaComponent::from_file(&path, Arc::clone(&self.sandbox)) {
276 Ok(component) => {
277 let name = path
278 .file_stem()
279 .map(|s| s.to_string_lossy().into_owned())
280 .unwrap_or_default();
281 result.loaded.push((name, component));
282 }
283 Err(e) => {
284 result.warnings.push(LoadWarning { path, error: e });
285 }
286 }
287 } else if path.is_dir() && path.join("init.lua").exists() {
288 let name = path
290 .file_name()
291 .map(|s| s.to_string_lossy().into_owned())
292 .unwrap_or_default();
293 match LuaComponent::from_dir(&path, Arc::clone(&self.sandbox)) {
294 Ok(component) => {
295 result.loaded.push((name, component));
296 }
297 Err(e) => {
298 result.warnings.push(LoadWarning { path, error: e });
299 }
300 }
301 }
302 }
303 }
304
305 result
306 }
307
308 #[must_use]
317 pub fn load_dir(path: &Path, sandbox: Arc<dyn SandboxPolicy>) -> LoadResult {
318 Self::new(sandbox).with_path(path).load_all()
319 }
320
321 pub fn load_file<P: AsRef<Path>>(
327 path: P,
328 sandbox: Arc<dyn SandboxPolicy>,
329 ) -> Result<LuaComponent, LuaError> {
330 LuaComponent::from_file(path, sandbox)
331 }
332
333 #[must_use]
338 pub fn crate_scripts_dir() -> PathBuf {
339 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("scripts")
340 }
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346 use orcs_component::Component;
347 use orcs_runtime::sandbox::ProjectSandbox;
348
349 fn test_sandbox() -> Arc<dyn SandboxPolicy> {
350 Arc::new(ProjectSandbox::new(".").expect("test sandbox"))
351 }
352
353 #[test]
354 fn new_loader_has_empty_search_paths() {
355 let loader = ScriptLoader::new(test_sandbox());
356 assert!(loader.search_paths.is_empty());
357 }
358
359 #[test]
360 fn with_path_adds_to_search_paths() {
361 let loader = ScriptLoader::new(test_sandbox())
362 .with_path("/foo")
363 .with_path("/bar");
364 assert_eq!(loader.search_paths.len(), 2);
365 assert_eq!(loader.search_paths[0], PathBuf::from("/foo"));
366 assert_eq!(loader.search_paths[1], PathBuf::from("/bar"));
367 }
368
369 #[test]
370 fn with_project_root_adds_scripts_subdir() {
371 let loader = ScriptLoader::new(test_sandbox()).with_project_root("/project");
372 assert_eq!(loader.search_paths[0], PathBuf::from("/project/scripts"));
373 }
374
375 #[test]
376 fn load_from_crate_scripts_dir() {
377 let loader = ScriptLoader::new(test_sandbox()).with_path(ScriptLoader::crate_scripts_dir());
378 let component = loader.load("echo");
379 assert!(component.is_ok());
380 }
381
382 #[test]
383 fn load_not_found_shows_searched_paths() {
384 let loader = ScriptLoader::new(test_sandbox()).with_path("/nonexistent/path");
385 let result = loader.load("missing");
386 let Err(err) = result else {
387 panic!("expected error");
388 };
389 let err_str = err.to_string();
390 assert!(err_str.contains("/nonexistent/path"));
391 assert!(err_str.contains("missing"));
392 }
393
394 #[test]
395 fn list_available_includes_filesystem() {
396 let loader = ScriptLoader::new(test_sandbox()).with_path(ScriptLoader::crate_scripts_dir());
397 let names = loader.list_available();
398 assert!(names.contains(&"echo".to_string()));
399 }
400
401 #[test]
402 fn crate_scripts_dir_exists() {
403 let dir = ScriptLoader::crate_scripts_dir();
404 assert!(dir.exists(), "scripts dir should exist: {:?}", dir);
405 }
406
407 #[test]
410 fn load_all_from_crate_scripts_dir() {
411 let loader = ScriptLoader::new(test_sandbox()).with_path(ScriptLoader::crate_scripts_dir());
412 let result = loader.load_all();
413
414 assert!(result.has_loaded());
416 assert!(result.loaded_count() >= 1);
417
418 let names: Vec<&str> = result.loaded.iter().map(|(n, _)| n.as_str()).collect();
420 assert!(names.contains(&"echo"));
421 }
422
423 #[test]
424 fn load_all_empty_for_nonexistent_dir() {
425 let loader = ScriptLoader::new(test_sandbox()).with_path("/nonexistent/path");
426 let result = loader.load_all();
427
428 assert!(!result.has_loaded());
430 assert!(!result.has_warnings());
431 }
432
433 #[test]
434 fn load_dir_convenience() {
435 let result = ScriptLoader::load_dir(&ScriptLoader::crate_scripts_dir(), test_sandbox());
436 assert!(result.has_loaded());
437 }
438
439 #[test]
440 fn load_result_debug() {
441 let result = LoadResult::default();
442 let debug_str = format!("{:?}", result);
443 assert!(debug_str.contains("LoadResult"));
444 assert!(debug_str.contains("loaded_count"));
445 }
446
447 #[test]
448 fn load_warning_display() {
449 let warn = LoadWarning {
450 path: PathBuf::from("/test/script.lua"),
451 error: LuaError::ScriptNotFound("test".into()),
452 };
453 let display_str = format!("{}", warn);
454 assert!(display_str.contains("/test/script.lua"));
455 }
456
457 #[test]
460 fn from_dir_loads_init_lua() {
461 let dir = tempfile::tempdir().expect("create temp dir");
462 let init = dir.path().join("init.lua");
463 std::fs::write(
464 &init,
465 r#"return {
466 id = "dir-component",
467 namespace = "test",
468 subscriptions = {"Echo"},
469 on_request = function(r) return {success=true, data={}} end,
470 on_signal = function(s) return "Handled" end,
471 }"#,
472 )
473 .expect("write init.lua");
474
475 let sb = test_sandbox();
476 let component = LuaComponent::from_dir(dir.path(), sb).expect("load dir component");
477 assert_eq!(component.id().name, "dir-component");
478 }
479
480 #[test]
481 fn from_dir_require_colocated_module() {
482 let dir = tempfile::tempdir().expect("create temp dir");
483
484 std::fs::create_dir_all(dir.path().join("lib")).expect("create lib dir");
486 std::fs::write(
487 dir.path().join("lib").join("helper.lua"),
488 r#"local M = {}
489 function M.greet() return "hello from helper" end
490 return M"#,
491 )
492 .expect("write helper.lua");
493
494 std::fs::write(
496 dir.path().join("init.lua"),
497 r#"local helper = require("lib.helper")
498 return {
499 id = "require-test",
500 namespace = "test",
501 subscriptions = {"Echo"},
502 on_request = function(r) return {success=true, data={msg=helper.greet()}} end,
503 on_signal = function(s) return "Handled" end,
504 }"#,
505 )
506 .expect("write init.lua");
507
508 let sb = test_sandbox();
509 let component =
510 LuaComponent::from_dir(dir.path(), sb).expect("load require-test component");
511 assert_eq!(component.id().name, "require-test");
512 }
513
514 #[test]
515 fn loader_finds_directory_component() {
516 let dir = tempfile::tempdir().expect("create temp dir");
517 let comp_dir = dir.path().join("my_comp");
518 std::fs::create_dir_all(&comp_dir).expect("create my_comp dir");
519 std::fs::write(
520 comp_dir.join("init.lua"),
521 r#"return {
522 id = "my_comp",
523 namespace = "test",
524 subscriptions = {"Echo"},
525 on_request = function(r) return {success=true, data={}} end,
526 on_signal = function(s) return "Handled" end,
527 }"#,
528 )
529 .expect("write my_comp/init.lua");
530
531 let sb = test_sandbox();
532 let loader = ScriptLoader::new(sb).with_path(dir.path());
533 let component = loader.load("my_comp").expect("load my_comp");
534 assert_eq!(component.id().name, "my_comp");
535 }
536
537 #[test]
538 fn load_skill_manager_as_directory_component() {
539 let loader = ScriptLoader::new(test_sandbox()).with_path(ScriptLoader::crate_scripts_dir());
540
541 let component = loader
542 .load("skill_manager")
543 .expect("skill_manager should load as directory component via init.lua");
544
545 assert_eq!(component.id().name, "skill_manager");
546 }
547
548 #[test]
549 fn list_available_includes_skill_manager_directory() {
550 let loader = ScriptLoader::new(test_sandbox()).with_path(ScriptLoader::crate_scripts_dir());
551 let names = loader.list_available();
552 assert!(
553 names.contains(&"skill_manager".to_string()),
554 "directory component should appear in list: {names:?}"
555 );
556 }
557
558 #[test]
559 fn from_dir_require_flat_colocated_module() {
560 let dir = tempfile::tempdir().expect("create temp dir");
563
564 std::fs::write(
565 dir.path().join("my_helper.lua"),
566 r#"local M = {}
567 function M.value() return 42 end
568 return M"#,
569 )
570 .expect("write my_helper.lua");
571
572 std::fs::write(
573 dir.path().join("init.lua"),
574 r#"local helper = require("my_helper")
575 return {
576 id = "flat-require-test",
577 namespace = "test",
578 subscriptions = {"Echo"},
579 on_request = function(r) return {success=true, data={v=helper.value()}} end,
580 on_signal = function(s) return "Handled" end,
581 }"#,
582 )
583 .expect("write init.lua");
584
585 let component = LuaComponent::from_dir(dir.path(), test_sandbox())
586 .expect("load flat require component");
587 assert_eq!(component.id().name, "flat-require-test");
588 }
589
590 #[test]
591 fn list_available_includes_directories() {
592 let dir = tempfile::tempdir().expect("create temp dir");
593 let comp_dir = dir.path().join("dir_comp");
594 std::fs::create_dir_all(&comp_dir).expect("create dir_comp dir");
595 std::fs::write(comp_dir.join("init.lua"), "return {}").expect("write init.lua");
596
597 let sb = test_sandbox();
598 let loader = ScriptLoader::new(sb).with_path(dir.path());
599 let names = loader.list_available();
600 assert!(names.contains(&"dir_comp".to_string()));
601 }
602}