goblin-engine 0.1.0

A high-performance async workflow engine for executing scripts in planned sequences with dependency resolution
Documentation
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::Duration;
use crate::error::{GoblinError, Result};

/// Configuration for a script that can be loaded from TOML
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScriptConfig {
    pub name: String,
    pub command: String,
    #[serde(default = "default_timeout")]
    pub timeout: u64, // timeout in seconds
    #[serde(default)]
    pub test_command: Option<String>,
    #[serde(default)]
    pub require_test: bool,
}

/// Runtime representation of a script with resolved paths
#[derive(Debug, Clone)]
pub struct Script {
    pub name: String,
    pub command: String,
    pub timeout: Duration,
    pub test_command: Option<String>,
    pub require_test: bool,
    pub path: PathBuf,
}

impl Script {
    /// Create a new script from configuration and path
    pub fn new(config: ScriptConfig, path: PathBuf) -> Self {
        Self {
            name: config.name,
            command: config.command,
            timeout: Duration::from_secs(config.timeout),
            test_command: config.test_command,
            require_test: config.require_test,
            path,
        }
    }

    /// Load a script from a goblin.toml file
    pub fn from_toml_file(path: &PathBuf) -> Result<Self> {
        let toml_path = path.join("goblin.toml");
        let content = std::fs::read_to_string(&toml_path)?;
        let config: ScriptConfig = toml::from_str(&content)?;
        
        // Validate required fields
        if config.name.trim().is_empty() {
            return Err(GoblinError::config_error("Script name cannot be empty"));
        }
        if config.command.trim().is_empty() {
            return Err(GoblinError::config_error("Script command cannot be empty"));
        }

        Ok(Self::new(config, path.clone()))
    }

    /// Load a script from a TOML string with a specified path
    pub fn from_toml_str(toml_str: &str, path: PathBuf) -> Result<Self> {
        let config: ScriptConfig = toml::from_str(toml_str)?;
        
        // Validate required fields
        if config.name.trim().is_empty() {
            return Err(GoblinError::config_error("Script name cannot be empty"));
        }
        if config.command.trim().is_empty() {
            return Err(GoblinError::config_error("Script command cannot be empty"));
        }

        Ok(Self::new(config, path))
    }

    /// Get the working directory for this script
    pub fn working_directory(&self) -> &PathBuf {
        &self.path
    }

    /// Check if this script has a test command
    pub fn has_test(&self) -> bool {
        self.test_command.is_some() && !self.test_command.as_ref().unwrap().trim().is_empty()
    }

    /// Get the full command to execute for this script
    pub fn get_command(&self, args: &[String]) -> String {
        if args.is_empty() {
            self.command.clone()
        } else {
            format!("{} {}", self.command, args.join(" "))
        }
    }

    /// Get the test command if available
    pub fn get_test_command(&self) -> Option<&str> {
        self.test_command.as_deref().filter(|cmd| !cmd.trim().is_empty())
    }

    /// Validate the script configuration
    pub fn validate(&self) -> Result<()> {
        if self.name.trim().is_empty() {
            return Err(GoblinError::config_error("Script name cannot be empty"));
        }
        
        if self.command.trim().is_empty() {
            return Err(GoblinError::config_error("Script command cannot be empty"));
        }
        
        if !self.path.exists() {
            return Err(GoblinError::config_error(format!(
                "Script path does not exist: {}", 
                self.path.display()
            )));
        }
        
        if !self.path.is_dir() {
            return Err(GoblinError::config_error(format!(
                "Script path is not a directory: {}", 
                self.path.display()
            )));
        }

        Ok(())
    }
}

fn default_timeout() -> u64 {
    500 // 500 seconds default timeout
}

impl From<ScriptConfig> for Script {
    fn from(config: ScriptConfig) -> Self {
        Self::new(config, PathBuf::new())
    }
}

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

    #[test]
    fn test_script_from_config() {
        let config = ScriptConfig {
            name: "test_script".to_string(),
            command: "echo hello".to_string(),
            timeout: 30,
            test_command: Some("echo true".to_string()),
            require_test: false,
        };

        let path = PathBuf::from("/tmp");
        let script = Script::new(config, path.clone());

        assert_eq!(script.name, "test_script");
        assert_eq!(script.command, "echo hello");
        assert_eq!(script.timeout, Duration::from_secs(30));
        assert_eq!(script.test_command, Some("echo true".to_string()));
        assert!(!script.require_test);
        assert_eq!(script.path, path);
    }

    #[test]
    fn test_script_from_toml_str() {
        let toml_content = r#"
            name = "test_script"
            command = "deno run main.ts"
            timeout = 100
            test_command = "deno test"
            require_test = true
        "#;

        let path = PathBuf::from("/tmp");
        let script = Script::from_toml_str(toml_content, path).unwrap();

        assert_eq!(script.name, "test_script");
        assert_eq!(script.command, "deno run main.ts");
        assert_eq!(script.timeout, Duration::from_secs(100));
        assert_eq!(script.test_command, Some("deno test".to_string()));
        assert!(script.require_test);
    }

    #[test]
    fn test_script_default_values() {
        let toml_content = r#"
            name = "simple_script"
            command = "echo hello"
        "#;

        let path = PathBuf::from("/tmp");
        let script = Script::from_toml_str(toml_content, path).unwrap();

        assert_eq!(script.timeout, Duration::from_secs(500)); // default timeout
        assert_eq!(script.test_command, None);
        assert!(!script.require_test); // default false
    }

    #[test]
    fn test_script_get_command_with_args() {
        let script = Script {
            name: "test".to_string(),
            command: "echo".to_string(),
            timeout: Duration::from_secs(30),
            test_command: None,
            require_test: false,
            path: PathBuf::new(),
        };

        assert_eq!(script.get_command(&[]), "echo");
        assert_eq!(
            script.get_command(&["hello".to_string(), "world".to_string()]),
            "echo hello world"
        );
    }

    #[test]
    fn test_script_has_test() {
        let script_with_test = Script {
            name: "test".to_string(),
            command: "echo".to_string(),
            timeout: Duration::from_secs(30),
            test_command: Some("test command".to_string()),
            require_test: false,
            path: PathBuf::new(),
        };

        let script_without_test = Script {
            name: "test".to_string(),
            command: "echo".to_string(),
            timeout: Duration::from_secs(30),
            test_command: None,
            require_test: false,
            path: PathBuf::new(),
        };

        let script_with_empty_test = Script {
            name: "test".to_string(),
            command: "echo".to_string(),
            timeout: Duration::from_secs(30),
            test_command: Some("".to_string()),
            require_test: false,
            path: PathBuf::new(),
        };

        assert!(script_with_test.has_test());
        assert!(!script_without_test.has_test());
        assert!(!script_with_empty_test.has_test());
    }
}