Skip to main content

orcs_lua/
loader.rs

1//! Lua script loader with filesystem-based search paths.
2//!
3//! # Loading Priority
4//!
5//! Search paths in order (first match wins):
6//! - `{path}/{name}.lua` (single file)
7//! - `{path}/{name}/init.lua` (directory component)
8//!
9//! # Example
10//!
11//! ```ignore
12//! use orcs_lua::ScriptLoader;
13//!
14//! let loader = ScriptLoader::new(sandbox)
15//!     .with_path("~/.orcs/components")
16//!     .with_path("/versioned/builtins/components");
17//!
18//! let component = loader.load("echo")?;
19//! ```
20
21use crate::component::LuaComponent;
22use crate::error::LuaError;
23use orcs_runtime::sandbox::SandboxPolicy;
24use std::path::{Path, PathBuf};
25use std::sync::Arc;
26
27/// Result of batch loading operation.
28///
29/// Contains both successfully loaded components and warnings for failures.
30/// This allows the application to continue even when some scripts fail to load.
31#[derive(Default)]
32pub struct LoadResult {
33    /// Successfully loaded components with their names.
34    pub loaded: Vec<(String, LuaComponent)>,
35    /// Warnings for scripts that failed to load.
36    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    /// Returns true if any scripts were loaded.
50    #[must_use]
51    pub fn has_loaded(&self) -> bool {
52        !self.loaded.is_empty()
53    }
54
55    /// Returns true if any warnings occurred.
56    #[must_use]
57    pub fn has_warnings(&self) -> bool {
58        !self.warnings.is_empty()
59    }
60
61    /// Returns the count of loaded scripts.
62    #[must_use]
63    pub fn loaded_count(&self) -> usize {
64        self.loaded.len()
65    }
66
67    /// Returns the count of warnings.
68    #[must_use]
69    pub fn warning_count(&self) -> usize {
70        self.warnings.len()
71    }
72}
73
74/// Warning for a failed script load.
75///
76/// Contains the path that was attempted and the error that occurred.
77#[derive(Debug)]
78pub struct LoadWarning {
79    /// Path to the script that failed to load.
80    pub path: PathBuf,
81    /// Error that occurred during loading.
82    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/// Script loader with configurable search paths.
92///
93/// Search order: configured search paths in order added.
94#[derive(Debug, Clone)]
95pub struct ScriptLoader {
96    /// Search paths for scripts (priority order).
97    search_paths: Vec<PathBuf>,
98    /// Sandbox policy for file operations in loaded components.
99    sandbox: Arc<dyn SandboxPolicy>,
100}
101
102impl ScriptLoader {
103    /// Creates a new loader.
104    ///
105    /// The sandbox is passed to all components loaded by this loader.
106    #[must_use]
107    pub fn new(sandbox: Arc<dyn SandboxPolicy>) -> Self {
108        Self {
109            search_paths: Vec::new(),
110            sandbox,
111        }
112    }
113
114    /// Adds a search path.
115    ///
116    /// Paths are searched in the order they are added.
117    #[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    /// Adds project root's `scripts/` directory to search paths.
124    ///
125    /// Convenience method for `with_path(root.join("scripts"))`.
126    #[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    /// Adds multiple search paths.
133    #[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    /// Returns configured search paths.
142    #[must_use]
143    pub fn search_paths(&self) -> &[PathBuf] {
144        &self.search_paths
145    }
146
147    /// Loads a script by name.
148    ///
149    /// Search order:
150    /// 1. Each search path: `{path}/{name}.lua` (single file)
151    /// 2. Each search path: `{path}/{name}/init.lua` (directory with co-located modules)
152    ///
153    /// Directory-based loading sets up `package.path` so that standard
154    /// `require()` resolves co-located modules within the component directory.
155    ///
156    /// # Errors
157    ///
158    /// Returns error if script not found in any location.
159    pub fn load(&self, name: &str) -> Result<LuaComponent, LuaError> {
160        for path in &self.search_paths {
161            // 1. Single file: {path}/{name}.lua
162            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            // 2. Directory: {path}/{name}/init.lua
168            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        // Build error message with searched locations
175        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    /// Lists all available script names.
194    ///
195    /// Includes scripts from all search paths and embedded (if enabled).
196    #[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        // Collect from search paths (single files + directories with init.lua)
202        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    // === Batch loading methods ===
225
226    /// Loads all scripts from configured search paths.
227    ///
228    /// Scans each search path for `.lua` files and attempts to load them.
229    /// Errors are collected as warnings, not propagated—allowing partial success.
230    ///
231    /// # Example
232    ///
233    /// ```ignore
234    /// let loader = ScriptLoader::new()
235    ///     .with_path("~/.orcs/scripts")
236    ///     .with_path(".orcs/scripts");
237    ///
238    /// let result = loader.load_all();
239    ///
240    /// // Log warnings but continue
241    /// for warn in &result.warnings {
242    ///     eprintln!("Warning: {}", warn);
243    /// }
244    ///
245    /// // Use loaded components
246    /// for (name, component) in result.loaded {
247    ///     println!("Loaded: {}", name);
248    /// }
249    /// ```
250    #[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                    // Single file: {dir}/{name}.lua
275                    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                    // Directory component: {dir}/{name}/init.lua
289                    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    /// Loads all scripts from a single directory.
309    ///
310    /// Convenience method equivalent to:
311    /// ```ignore
312    /// ScriptLoader::new(sandbox)
313    ///     .with_path(path)
314    ///     .load_all()
315    /// ```
316    #[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    /// Loads a script from a specific file path.
322    ///
323    /// # Errors
324    ///
325    /// Returns error if file not found or invalid.
326    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    /// Returns the crate's built-in scripts directory.
334    ///
335    /// This is the `scripts/` directory relative to the crate root.
336    /// Mainly useful for development and testing.
337    #[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    // === Batch loading tests ===
408
409    #[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        // Should load at least echo.lua
415        assert!(result.has_loaded());
416        assert!(result.loaded_count() >= 1);
417
418        // Verify echo is loaded
419        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        // Nonexistent dirs are skipped, not errors
429        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    // === Directory-based loading tests ===
458
459    #[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        // Create lib/helper.lua
485        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        // Create init.lua that uses require
495        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        // Verify that require("module_name") resolves to a flat co-located file
561        // (no lib/ subdirectory needed).
562        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}