herolib-do 0.3.4

Interactive Rhai shell aggregating herolib packages
Documentation
//! herolib-do library
//!
//! This module exposes the core functionality of herodo for use as a library,
//! enabling script execution from other Rust code and integration tests.

use rhai::{Engine, EvalAltResult, Scope, module_resolvers::FileModuleResolver};
use std::fs;
use std::path::{Path, PathBuf};

/// Error type for herodo operations
#[derive(Debug)]
pub enum HerodoError {
    /// File not found error
    FileNotFound(String),
    /// Script execution error
    ScriptError(String),
    /// Invalid file extension
    InvalidExtension(String),
    /// Engine creation error
    EngineError(String),
    /// IO error
    IoError(std::io::Error),
}

impl std::fmt::Display for HerodoError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            HerodoError::FileNotFound(path) => write!(f, "File not found: {}", path),
            HerodoError::ScriptError(msg) => write!(f, "Script error: {}", msg),
            HerodoError::InvalidExtension(path) => {
                write!(f, "Invalid extension (must be .rhai): {}", path)
            }
            HerodoError::EngineError(msg) => write!(f, "Engine error: {}", msg),
            HerodoError::IoError(e) => write!(f, "IO error: {}", e),
        }
    }
}

impl std::error::Error for HerodoError {}

impl From<std::io::Error> for HerodoError {
    fn from(e: std::io::Error) -> Self {
        HerodoError::IoError(e)
    }
}

impl From<Box<EvalAltResult>> for HerodoError {
    fn from(e: Box<EvalAltResult>) -> Self {
        HerodoError::ScriptError(e.to_string())
    }
}

/// Result type for herodo operations
pub type Result<T> = std::result::Result<T, HerodoError>;

/// Create and configure the Rhai engine with all SAL modules
fn create_engine() -> Result<Engine> {
    create_engine_with_base_path(None)
}

/// Create and configure the Rhai engine with a base path for module resolution
fn create_engine_with_base_path(base_path: Option<&Path>) -> Result<Engine> {
    let mut engine = Engine::new();

    // Register all modules from aggregated packages
    herolib_core::rhai::register_core_module(&mut engine)
        .map_err(|e| HerodoError::EngineError(e.to_string()))?;
    herolib_crypt::rhai::register_crypt_module(&mut engine)
        .map_err(|e| HerodoError::EngineError(e.to_string()))?;
    herolib_os::rhai::register_system_module(&mut engine)
        .map_err(|e| HerodoError::EngineError(e.to_string()))?;
    herolib_clients::rhai::register_clients_module(&mut engine)
        .map_err(|e| HerodoError::EngineError(e.to_string()))?;
    herolib_virt::rhai::register_kubernetes_module(&mut engine)
        .map_err(|e| HerodoError::EngineError(e.to_string()))?;

    // Set up module resolver for imports
    let resolver = if let Some(path) = base_path {
        FileModuleResolver::new_with_path(path)
    } else {
        FileModuleResolver::new_with_path(
            std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
        )
    };
    engine.set_module_resolver(resolver);

    // Configure print/debug callbacks
    engine.on_print(|s| {
        println!("{}", s);
    });

    engine.on_debug(|s, source, pos| {
        let location = match source {
            Some(src) => format!("[{}:{}] ", src, pos),
            None if !pos.is_none() => format!("[{}] ", pos),
            None => String::new(),
        };
        println!("[DEBUG] {}{}", location, s);
    });

    Ok(engine)
}

/// Run a Rhai script from a file path
///
/// This function executes a single .rhai script file or all .rhai scripts
/// in a directory (if a directory path is provided).
///
/// # Arguments
///
/// * `path` - Path to a .rhai script file or directory containing .rhai scripts
///
/// # Returns
///
/// * `Ok(())` if execution succeeds
/// * `Err(HerodoError)` if execution fails
///
/// # Example
///
/// ```no_run
/// use herolib_do::run;
///
/// // Run a single script
/// run("script.rhai").expect("Script execution failed");
///
/// // Run all scripts in a directory
/// run("scripts/").expect("Directory execution failed");
/// ```
pub fn run(path: &str) -> Result<()> {
    let path = Path::new(path);

    if !path.exists() {
        return Err(HerodoError::FileNotFound(path.display().to_string()));
    }

    if path.is_file() {
        run_script_file(path)
    } else if path.is_dir() {
        run_scripts_in_directory(path)
    } else {
        Err(HerodoError::FileNotFound(path.display().to_string()))
    }
}

/// Run a single script file
fn run_script_file(path: &Path) -> Result<()> {
    // Check extension
    if path.extension().is_some_and(|ext| ext != "rhai") {
        return Err(HerodoError::InvalidExtension(path.display().to_string()));
    }

    // Use the script's directory as the base path for module resolution
    let base_path = path.parent().unwrap_or_else(|| Path::new("."));
    let engine = create_engine_with_base_path(Some(base_path))?;
    let mut scope = Scope::new();

    let script = fs::read_to_string(path)?;

    engine
        .run_with_scope(&mut scope, &script)
        .map_err(|e| HerodoError::ScriptError(e.to_string()))
}

/// Run all .rhai scripts in a directory
fn run_scripts_in_directory(dir: &Path) -> Result<()> {
    let mut scripts: Vec<PathBuf> = Vec::new();

    // Collect all .rhai files recursively
    collect_rhai_files(dir, &mut scripts)?;

    if scripts.is_empty() {
        // Empty directory is not an error, just nothing to run
        return Ok(());
    }

    // Sort scripts by name for predictable execution order
    scripts.sort();

    // Execute each script
    for script_path in scripts {
        run_script_file(&script_path)?;
    }

    Ok(())
}

/// Recursively collect all .rhai files in a directory
fn collect_rhai_files(dir: &Path, scripts: &mut Vec<PathBuf>) -> Result<()> {
    for entry in fs::read_dir(dir)? {
        let entry = entry?;
        let path = entry.path();

        if path.is_dir() {
            collect_rhai_files(&path, scripts)?;
        } else if path.extension().is_some_and(|ext| ext == "rhai") {
            scripts.push(path);
        }
    }
    Ok(())
}

/// Run a script from a string
///
/// # Arguments
///
/// * `script` - The Rhai script content as a string
///
/// # Returns
///
/// * `Ok(())` if execution succeeds
/// * `Err(HerodoError)` if execution fails
pub fn run_script(script: &str) -> Result<()> {
    let engine = create_engine()?;
    let mut scope = Scope::new();

    engine
        .run_with_scope(&mut scope, script)
        .map_err(|e| HerodoError::ScriptError(e.to_string()))
}

/// Evaluate a script and return the result
///
/// # Arguments
///
/// * `script` - The Rhai script content as a string
///
/// # Returns
///
/// * `Ok(Dynamic)` containing the script result
/// * `Err(HerodoError)` if evaluation fails
pub fn eval(script: &str) -> Result<rhai::Dynamic> {
    let engine = create_engine()?;
    let mut scope = Scope::new();

    engine
        .eval_with_scope::<rhai::Dynamic>(&mut scope, script)
        .map_err(|e| HerodoError::ScriptError(e.to_string()))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_run_script_simple() {
        let result = run_script(r#"let x = 42; x"#);
        assert!(result.is_ok());
    }

    #[test]
    fn test_eval_simple() {
        let result = eval(r#"let x = 42; x"#);
        assert!(result.is_ok());
        assert_eq!(result.unwrap().as_int().unwrap(), 42);
    }

    #[test]
    fn test_file_not_found() {
        let result = run("/nonexistent/path/script.rhai");
        assert!(matches!(result, Err(HerodoError::FileNotFound(_))));
    }
}