herolib_do/
lib.rs

1//! herolib-do library
2//!
3//! This module exposes the core functionality of herodo for use as a library,
4//! enabling script execution from other Rust code and integration tests.
5//!
6//! ## Shebang Support
7//!
8//! Scripts can be made directly executable using a shebang line:
9//!
10//! ```rhai
11//! #!/usr/bin/env herodo
12//!
13//! print("Hello from herodo!");
14//! ```
15//!
16//! Then make the script executable and run it:
17//!
18//! ```bash
19//! chmod +x script.rhai
20//! ./script.rhai
21//! ```
22//!
23//! The shebang line is automatically stripped before execution.
24
25use rhai::{Engine, EvalAltResult, Scope, module_resolvers::FileModuleResolver};
26use std::fs;
27use std::path::{Path, PathBuf};
28
29/// Error type for herodo operations
30#[derive(Debug)]
31pub enum HerodoError {
32    /// File not found error
33    FileNotFound(String),
34    /// Script execution error
35    ScriptError(String),
36    /// Invalid file extension
37    InvalidExtension(String),
38    /// Engine creation error
39    EngineError(String),
40    /// IO error
41    IoError(std::io::Error),
42}
43
44impl std::fmt::Display for HerodoError {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        match self {
47            HerodoError::FileNotFound(path) => write!(f, "File not found: {}", path),
48            HerodoError::ScriptError(msg) => write!(f, "Script error: {}", msg),
49            HerodoError::InvalidExtension(path) => {
50                write!(f, "Invalid extension (must be .rhai): {}", path)
51            }
52            HerodoError::EngineError(msg) => write!(f, "Engine error: {}", msg),
53            HerodoError::IoError(e) => write!(f, "IO error: {}", e),
54        }
55    }
56}
57
58impl std::error::Error for HerodoError {}
59
60impl From<std::io::Error> for HerodoError {
61    fn from(e: std::io::Error) -> Self {
62        HerodoError::IoError(e)
63    }
64}
65
66impl From<Box<EvalAltResult>> for HerodoError {
67    fn from(e: Box<EvalAltResult>) -> Self {
68        HerodoError::ScriptError(e.to_string())
69    }
70}
71
72/// Result type for herodo operations
73pub type Result<T> = std::result::Result<T, HerodoError>;
74
75/// Create and configure the Rhai engine with all SAL modules
76fn create_engine() -> Result<Engine> {
77    create_engine_with_base_path(None)
78}
79
80/// Create and configure the Rhai engine with a base path for module resolution
81fn create_engine_with_base_path(base_path: Option<&Path>) -> Result<Engine> {
82    let mut engine = Engine::new();
83
84    // Register all modules from aggregated packages
85    herolib_core::rhai::register_core_module(&mut engine)
86        .map_err(|e| HerodoError::EngineError(e.to_string()))?;
87    herolib_crypt::rhai::register(&mut engine)
88        .map_err(|e| HerodoError::EngineError(e.to_string()))?;
89    herolib_os::rhai::register_system_module(&mut engine)
90        .map_err(|e| HerodoError::EngineError(e.to_string()))?;
91    herolib_clients::rhai::register_clients_module(&mut engine)
92        .map_err(|e| HerodoError::EngineError(e.to_string()))?;
93    herolib_virt::rhai::register_virt_module(&mut engine)
94        .map_err(|e| HerodoError::EngineError(e.to_string()))?;
95    herolib_ai::rhai::register(&mut engine).map_err(|e| HerodoError::EngineError(e.to_string()))?;
96    eprintln!("[DEBUG herodo] About to register rust_builder");
97    herolib_code::rust_builder::rhai::register_rust_builder_module(&mut engine)
98        .map_err(|e| HerodoError::EngineError(e.to_string()))?;
99    eprintln!("[DEBUG herodo] Registered rust_builder");
100
101    // Set up module resolver for imports
102    let resolver = if let Some(path) = base_path {
103        FileModuleResolver::new_with_path(path)
104    } else {
105        FileModuleResolver::new_with_path(
106            std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
107        )
108    };
109    engine.set_module_resolver(resolver);
110
111    // Configure print/debug callbacks
112    engine.on_print(|s| {
113        println!("{}", s);
114    });
115
116    engine.on_debug(|s, source, pos| {
117        let location = match source {
118            Some(src) => format!("[{}:{}] ", src, pos),
119            None if !pos.is_none() => format!("[{}] ", pos),
120            None => String::new(),
121        };
122        println!("[DEBUG] {}{}", location, s);
123    });
124
125    Ok(engine)
126}
127
128/// Run a Rhai script from a file path
129///
130/// This function executes a single .rhai script file or all .rhai scripts
131/// in a directory (if a directory path is provided).
132///
133/// # Arguments
134///
135/// * `path` - Path to a .rhai script file or directory containing .rhai scripts
136///
137/// # Returns
138///
139/// * `Ok(())` if execution succeeds
140/// * `Err(HerodoError)` if execution fails
141///
142/// # Example
143///
144/// ```no_run
145/// use herolib_do::run;
146///
147/// // Run a single script
148/// run("script.rhai").expect("Script execution failed");
149///
150/// // Run all scripts in a directory
151/// run("scripts/").expect("Directory execution failed");
152/// ```
153pub fn run(path: &str) -> Result<()> {
154    let path = Path::new(path);
155
156    if !path.exists() {
157        return Err(HerodoError::FileNotFound(path.display().to_string()));
158    }
159
160    if path.is_file() {
161        run_script_file(path)
162    } else if path.is_dir() {
163        run_scripts_in_directory(path)
164    } else {
165        Err(HerodoError::FileNotFound(path.display().to_string()))
166    }
167}
168
169/// Run a single script file
170fn run_script_file(path: &Path) -> Result<()> {
171    // Check extension
172    if path.extension().is_some_and(|ext| ext != "rhai") {
173        return Err(HerodoError::InvalidExtension(path.display().to_string()));
174    }
175
176    // Use the script's directory as the base path for module resolution
177    let base_path = path.parent().unwrap_or_else(|| Path::new("."));
178
179    // Set environment variable for script directory so script_dir() can access it
180    let script_dir = base_path
181        .canonicalize()
182        .unwrap_or_else(|_| base_path.to_path_buf())
183        .display()
184        .to_string();
185    // SAFETY: set_var is unsafe but we're in single-threaded script execution context
186    unsafe {
187        std::env::set_var("HERODO_SCRIPT_DIR", &script_dir);
188    }
189
190    let engine = create_engine_with_base_path(Some(base_path))?;
191    let mut scope = Scope::new();
192
193    let script = fs::read_to_string(path)?;
194
195    // Strip shebang line if present (for #!/usr/bin/env herodo support)
196    let script = if script.starts_with("#!") {
197        script.lines().skip(1).collect::<Vec<_>>().join("\n")
198    } else {
199        script
200    };
201
202    engine
203        .run_with_scope(&mut scope, &script)
204        .map_err(|e| HerodoError::ScriptError(e.to_string()))
205}
206
207/// Run all .rhai scripts in a directory
208fn run_scripts_in_directory(dir: &Path) -> Result<()> {
209    let mut scripts: Vec<PathBuf> = Vec::new();
210
211    // Collect all .rhai files recursively
212    collect_rhai_files(dir, &mut scripts)?;
213
214    if scripts.is_empty() {
215        // Empty directory is not an error, just nothing to run
216        return Ok(());
217    }
218
219    // Sort scripts by name for predictable execution order
220    scripts.sort();
221
222    // Execute each script
223    for script_path in scripts {
224        run_script_file(&script_path)?;
225    }
226
227    Ok(())
228}
229
230/// Recursively collect all .rhai files in a directory
231fn collect_rhai_files(dir: &Path, scripts: &mut Vec<PathBuf>) -> Result<()> {
232    for entry in fs::read_dir(dir)? {
233        let entry = entry?;
234        let path = entry.path();
235
236        if path.is_dir() {
237            collect_rhai_files(&path, scripts)?;
238        } else if path.extension().is_some_and(|ext| ext == "rhai") {
239            scripts.push(path);
240        }
241    }
242    Ok(())
243}
244
245/// Run a script from a string
246///
247/// # Arguments
248///
249/// * `script` - The Rhai script content as a string
250///
251/// # Returns
252///
253/// * `Ok(())` if execution succeeds
254/// * `Err(HerodoError)` if execution fails
255pub fn run_script(script: &str) -> Result<()> {
256    let engine = create_engine()?;
257    let mut scope = Scope::new();
258
259    engine
260        .run_with_scope(&mut scope, script)
261        .map_err(|e| HerodoError::ScriptError(e.to_string()))
262}
263
264/// Evaluate a script and return the result
265///
266/// # Arguments
267///
268/// * `script` - The Rhai script content as a string
269///
270/// # Returns
271///
272/// * `Ok(Dynamic)` containing the script result
273/// * `Err(HerodoError)` if evaluation fails
274pub fn eval(script: &str) -> Result<rhai::Dynamic> {
275    let engine = create_engine()?;
276    let mut scope = Scope::new();
277
278    engine
279        .eval_with_scope::<rhai::Dynamic>(&mut scope, script)
280        .map_err(|e| HerodoError::ScriptError(e.to_string()))
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn test_run_script_simple() {
289        let result = run_script(r#"let x = 42; x"#);
290        assert!(result.is_ok());
291    }
292
293    #[test]
294    fn test_eval_simple() {
295        let result = eval(r#"let x = 42; x"#);
296        assert!(result.is_ok());
297        assert_eq!(result.unwrap().as_int().unwrap(), 42);
298    }
299
300    #[test]
301    fn test_file_not_found() {
302        let result = run("/nonexistent/path/script.rhai");
303        assert!(matches!(result, Err(HerodoError::FileNotFound(_))));
304    }
305}