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::collections::HashMap;
use std::path::PathBuf;
use crate::error::{GoblinError, Result};

/// Configuration for the entire engine
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EngineConfig {
    /// Path to the scripts directory for auto-discovery
    #[serde(default)]
    pub scripts_dir: Option<PathBuf>,
    
    /// Path to the plans directory
    #[serde(default)]
    pub plans_dir: Option<PathBuf>,
    
    /// Default timeout for script execution in seconds
    #[serde(default = "default_timeout")]
    pub default_timeout: u64,
    
    /// Global environment variables to pass to all scripts
    #[serde(default)]
    pub environment: HashMap<String, String>,
    
    /// Whether to require tests for all scripts by default
    #[serde(default)]
    pub require_tests: bool,
    
    /// Logging configuration
    #[serde(default)]
    pub logging: LoggingConfig,
    
    /// Execution configuration
    #[serde(default)]
    pub execution: ExecutionConfig,
}

/// Logging configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoggingConfig {
    /// Log level (trace, debug, info, warn, error)
    #[serde(default = "default_log_level")]
    pub level: String,
    
    /// Whether to log to stdout
    #[serde(default = "default_true")]
    pub stdout: bool,
    
    /// Optional log file path
    #[serde(default)]
    pub file: Option<PathBuf>,
    
    /// Whether to include timestamps in logs
    #[serde(default = "default_true")]
    pub timestamps: bool,
}

/// Execution configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionConfig {
    /// Maximum number of concurrent script executions
    #[serde(default = "default_max_concurrent")]
    pub max_concurrent: usize,
    
    /// Whether to stop plan execution on first error
    #[serde(default = "default_true")]
    pub fail_fast: bool,
    
    /// Directory for temporary files during execution
    #[serde(default)]
    pub temp_dir: Option<PathBuf>,
    
    /// Whether to clean up temporary files after execution
    #[serde(default = "default_true")]
    pub cleanup_temp_files: bool,
}

impl Default for EngineConfig {
    fn default() -> Self {
        Self {
            scripts_dir: None,
            plans_dir: None,
            default_timeout: default_timeout(),
            environment: HashMap::new(),
            require_tests: false,
            logging: LoggingConfig::default(),
            execution: ExecutionConfig::default(),
        }
    }
}

impl Default for LoggingConfig {
    fn default() -> Self {
        Self {
            level: default_log_level(),
            stdout: true,
            file: None,
            timestamps: true,
        }
    }
}

impl Default for ExecutionConfig {
    fn default() -> Self {
        Self {
            max_concurrent: default_max_concurrent(),
            fail_fast: true,
            temp_dir: None,
            cleanup_temp_files: true,
        }
    }
}

impl EngineConfig {
    /// Load configuration from a TOML file
    pub fn from_file(path: impl AsRef<std::path::Path>) -> Result<Self> {
        let content = std::fs::read_to_string(path)?;
        Self::from_toml_str(&content)
    }

    /// Load configuration from a TOML string
    pub fn from_toml_str(toml_str: &str) -> Result<Self> {
        let config: Self = toml::from_str(toml_str)?;
        config.validate()?;
        Ok(config)
    }

    /// Save configuration to a TOML file
    pub fn save_to_file(&self, path: impl AsRef<std::path::Path>) -> Result<()> {
        let content = toml::to_string_pretty(self)?;
        std::fs::write(path, content)?;
        Ok(())
    }

    /// Validate the configuration
    pub fn validate(&self) -> Result<()> {
        // Validate scripts directory exists if specified
        if let Some(ref scripts_dir) = self.scripts_dir {
            if !scripts_dir.exists() {
                return Err(GoblinError::config_error(format!(
                    "Scripts directory does not exist: {}", 
                    scripts_dir.display()
                )));
            }
        }

        // Validate plans directory exists if specified
        if let Some(ref plans_dir) = self.plans_dir {
            if !plans_dir.exists() {
                return Err(GoblinError::config_error(format!(
                    "Plans directory does not exist: {}", 
                    plans_dir.display()
                )));
            }
        }

        // Validate log level
        match self.logging.level.to_lowercase().as_str() {
            "trace" | "debug" | "info" | "warn" | "error" => {},
            _ => return Err(GoblinError::config_error(format!(
                "Invalid log level: {}. Must be one of: trace, debug, info, warn, error",
                self.logging.level
            ))),
        }

        // Validate timeout is reasonable
        if self.default_timeout == 0 {
            return Err(GoblinError::config_error(
                "Default timeout must be greater than 0"
            ));
        }

        // Validate max concurrent is reasonable
        if self.execution.max_concurrent == 0 {
            return Err(GoblinError::config_error(
                "Max concurrent executions must be greater than 0"
            ));
        }

        Ok(())
    }

    /// Merge this configuration with another, with the other taking precedence
    pub fn merge_with(mut self, other: EngineConfig) -> Self {
        if other.scripts_dir.is_some() {
            self.scripts_dir = other.scripts_dir;
        }
        if other.plans_dir.is_some() {
            self.plans_dir = other.plans_dir;
        }
        if other.default_timeout != default_timeout() {
            self.default_timeout = other.default_timeout;
        }
        
        // Merge environment variables
        for (key, value) in other.environment {
            self.environment.insert(key, value);
        }
        
        if other.require_tests {
            self.require_tests = other.require_tests;
        }
        
        // Merge logging config
        if other.logging.level != default_log_level() {
            self.logging.level = other.logging.level;
        }
        if !other.logging.stdout {
            self.logging.stdout = other.logging.stdout;
        }
        if other.logging.file.is_some() {
            self.logging.file = other.logging.file;
        }
        if !other.logging.timestamps {
            self.logging.timestamps = other.logging.timestamps;
        }
        
        // Merge execution config
        if other.execution.max_concurrent != default_max_concurrent() {
            self.execution.max_concurrent = other.execution.max_concurrent;
        }
        if !other.execution.fail_fast {
            self.execution.fail_fast = other.execution.fail_fast;
        }
        if other.execution.temp_dir.is_some() {
            self.execution.temp_dir = other.execution.temp_dir;
        }
        if !other.execution.cleanup_temp_files {
            self.execution.cleanup_temp_files = other.execution.cleanup_temp_files;
        }
        
        self
    }

    /// Create a sample configuration file content
    pub fn sample_config() -> String {
        r#"# Goblin Engine Configuration

# Directory containing script subdirectories with goblin.toml files
scripts_dir = "./scripts"

# Directory containing plan TOML files  
plans_dir = "./plans"

# Default timeout for script execution (seconds)
default_timeout = 500

# Whether to require tests for all scripts by default
require_tests = false

# Global environment variables passed to all scripts
[environment]
# EXAMPLE_VAR = "example_value"

[logging]
# Log level: trace, debug, info, warn, error
level = "info"
# Log to stdout
stdout = true
# Optional log file path
# file = "./goblin.log"
# Include timestamps in logs
timestamps = true

[execution]
# Maximum number of concurrent script executions
max_concurrent = 4
# Stop plan execution on first error
fail_fast = true
# Directory for temporary files (uses system temp if not specified)
# temp_dir = "./temp"
# Clean up temporary files after execution
cleanup_temp_files = true
"#.to_string()
    }
}

fn default_timeout() -> u64 {
    500
}

fn default_log_level() -> String {
    "info".to_string()
}

fn default_max_concurrent() -> usize {
    4
}

fn default_true() -> bool {
    true
}

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

    #[test]
    fn test_default_config() {
        let config = EngineConfig::default();
        assert_eq!(config.default_timeout, 500);
        assert_eq!(config.logging.level, "info");
        assert_eq!(config.execution.max_concurrent, 4);
        assert!(!config.require_tests);
        assert!(config.logging.stdout);
        assert!(config.execution.fail_fast);
    }

    #[test]
    fn test_config_from_toml() {
        let toml_content = r#"
            scripts_dir = "./scripts"
            plans_dir = "./plans"
            default_timeout = 300
            require_tests = true
            
            [logging]
            level = "debug"
            stdout = false
            
            [execution]
            max_concurrent = 8
            fail_fast = false
            
            [environment]
            TEST_VAR = "test_value"
        "#;

        let config = EngineConfig::from_toml_str(toml_content).unwrap();
        assert_eq!(config.default_timeout, 300);
        assert!(config.require_tests);
        assert_eq!(config.logging.level, "debug");
        assert!(!config.logging.stdout);
        assert_eq!(config.execution.max_concurrent, 8);
        assert!(!config.execution.fail_fast);
        assert_eq!(config.environment.get("TEST_VAR").unwrap(), "test_value");
    }

    #[test]
    fn test_config_validation() {
        // Test invalid log level
        let invalid_config = EngineConfig {
            logging: LoggingConfig {
                level: "invalid".to_string(),
                ..Default::default()
            },
            ..Default::default()
        };
        assert!(invalid_config.validate().is_err());

        // Test zero timeout
        let zero_timeout_config = EngineConfig {
            default_timeout: 0,
            ..Default::default()
        };
        assert!(zero_timeout_config.validate().is_err());

        // Test zero max concurrent
        let zero_concurrent_config = EngineConfig {
            execution: ExecutionConfig {
                max_concurrent: 0,
                ..Default::default()
            },
            ..Default::default()
        };
        assert!(zero_concurrent_config.validate().is_err());
    }

    #[test]
    fn test_config_merge() {
        let base_config = EngineConfig {
            default_timeout: 300,
            require_tests: false,
            ..Default::default()
        };

        let override_config = EngineConfig {
            default_timeout: 600,
            require_tests: true,
            ..Default::default()
        };

        let merged = base_config.merge_with(override_config);
        assert_eq!(merged.default_timeout, 600);
        assert!(merged.require_tests);
    }

    #[test]
    fn test_sample_config() {
        let sample = EngineConfig::sample_config();
        assert!(sample.contains("scripts_dir"));
        assert!(sample.contains("[logging]"));
        assert!(sample.contains("[execution]"));
    }
}