use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use crate::error::{GoblinError, Result};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EngineConfig {
#[serde(default)]
pub scripts_dir: Option<PathBuf>,
#[serde(default)]
pub plans_dir: Option<PathBuf>,
#[serde(default = "default_timeout")]
pub default_timeout: u64,
#[serde(default)]
pub environment: HashMap<String, String>,
#[serde(default)]
pub require_tests: bool,
#[serde(default)]
pub logging: LoggingConfig,
#[serde(default)]
pub execution: ExecutionConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoggingConfig {
#[serde(default = "default_log_level")]
pub level: String,
#[serde(default = "default_true")]
pub stdout: bool,
#[serde(default)]
pub file: Option<PathBuf>,
#[serde(default = "default_true")]
pub timestamps: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionConfig {
#[serde(default = "default_max_concurrent")]
pub max_concurrent: usize,
#[serde(default = "default_true")]
pub fail_fast: bool,
#[serde(default)]
pub temp_dir: Option<PathBuf>,
#[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 {
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)
}
pub fn from_toml_str(toml_str: &str) -> Result<Self> {
let config: Self = toml::from_str(toml_str)?;
config.validate()?;
Ok(config)
}
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(())
}
pub fn validate(&self) -> Result<()> {
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()
)));
}
}
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()
)));
}
}
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
))),
}
if self.default_timeout == 0 {
return Err(GoblinError::config_error(
"Default timeout must be greater than 0"
));
}
if self.execution.max_concurrent == 0 {
return Err(GoblinError::config_error(
"Max concurrent executions must be greater than 0"
));
}
Ok(())
}
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;
}
for (key, value) in other.environment {
self.environment.insert(key, value);
}
if other.require_tests {
self.require_tests = other.require_tests;
}
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;
}
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
}
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() {
let invalid_config = EngineConfig {
logging: LoggingConfig {
level: "invalid".to_string(),
..Default::default()
},
..Default::default()
};
assert!(invalid_config.validate().is_err());
let zero_timeout_config = EngineConfig {
default_timeout: 0,
..Default::default()
};
assert!(zero_timeout_config.validate().is_err());
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]"));
}
}