goblin_engine/
config.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::PathBuf;
4use crate::error::{GoblinError, Result};
5
6/// Configuration for the entire engine
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct EngineConfig {
9    /// Path to the scripts directory for auto-discovery
10    #[serde(default)]
11    pub scripts_dir: Option<PathBuf>,
12    
13    /// Path to the plans directory
14    #[serde(default)]
15    pub plans_dir: Option<PathBuf>,
16    
17    /// Default timeout for script execution in seconds
18    #[serde(default = "default_timeout")]
19    pub default_timeout: u64,
20    
21    /// Global environment variables to pass to all scripts
22    #[serde(default)]
23    pub environment: HashMap<String, String>,
24    
25    /// Whether to require tests for all scripts by default
26    #[serde(default)]
27    pub require_tests: bool,
28    
29    /// Logging configuration
30    #[serde(default)]
31    pub logging: LoggingConfig,
32    
33    /// Execution configuration
34    #[serde(default)]
35    pub execution: ExecutionConfig,
36}
37
38/// Logging configuration
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct LoggingConfig {
41    /// Log level (trace, debug, info, warn, error)
42    #[serde(default = "default_log_level")]
43    pub level: String,
44    
45    /// Whether to log to stdout
46    #[serde(default = "default_true")]
47    pub stdout: bool,
48    
49    /// Optional log file path
50    #[serde(default)]
51    pub file: Option<PathBuf>,
52    
53    /// Whether to include timestamps in logs
54    #[serde(default = "default_true")]
55    pub timestamps: bool,
56}
57
58/// Execution configuration
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct ExecutionConfig {
61    /// Maximum number of concurrent script executions
62    #[serde(default = "default_max_concurrent")]
63    pub max_concurrent: usize,
64    
65    /// Whether to stop plan execution on first error
66    #[serde(default = "default_true")]
67    pub fail_fast: bool,
68    
69    /// Directory for temporary files during execution
70    #[serde(default)]
71    pub temp_dir: Option<PathBuf>,
72    
73    /// Whether to clean up temporary files after execution
74    #[serde(default = "default_true")]
75    pub cleanup_temp_files: bool,
76}
77
78impl Default for EngineConfig {
79    fn default() -> Self {
80        Self {
81            scripts_dir: None,
82            plans_dir: None,
83            default_timeout: default_timeout(),
84            environment: HashMap::new(),
85            require_tests: false,
86            logging: LoggingConfig::default(),
87            execution: ExecutionConfig::default(),
88        }
89    }
90}
91
92impl Default for LoggingConfig {
93    fn default() -> Self {
94        Self {
95            level: default_log_level(),
96            stdout: true,
97            file: None,
98            timestamps: true,
99        }
100    }
101}
102
103impl Default for ExecutionConfig {
104    fn default() -> Self {
105        Self {
106            max_concurrent: default_max_concurrent(),
107            fail_fast: true,
108            temp_dir: None,
109            cleanup_temp_files: true,
110        }
111    }
112}
113
114impl EngineConfig {
115    /// Load configuration from a TOML file
116    pub fn from_file(path: impl AsRef<std::path::Path>) -> Result<Self> {
117        let content = std::fs::read_to_string(path)?;
118        Self::from_toml_str(&content)
119    }
120
121    /// Load configuration from a TOML string
122    pub fn from_toml_str(toml_str: &str) -> Result<Self> {
123        let config: Self = toml::from_str(toml_str)?;
124        config.validate()?;
125        Ok(config)
126    }
127
128    /// Save configuration to a TOML file
129    pub fn save_to_file(&self, path: impl AsRef<std::path::Path>) -> Result<()> {
130        let content = toml::to_string_pretty(self)?;
131        std::fs::write(path, content)?;
132        Ok(())
133    }
134
135    /// Validate the configuration
136    pub fn validate(&self) -> Result<()> {
137        // Validate scripts directory exists if specified
138        if let Some(ref scripts_dir) = self.scripts_dir {
139            if !scripts_dir.exists() {
140                return Err(GoblinError::config_error(format!(
141                    "Scripts directory does not exist: {}", 
142                    scripts_dir.display()
143                )));
144            }
145        }
146
147        // Validate plans directory exists if specified
148        if let Some(ref plans_dir) = self.plans_dir {
149            if !plans_dir.exists() {
150                return Err(GoblinError::config_error(format!(
151                    "Plans directory does not exist: {}", 
152                    plans_dir.display()
153                )));
154            }
155        }
156
157        // Validate log level
158        match self.logging.level.to_lowercase().as_str() {
159            "trace" | "debug" | "info" | "warn" | "error" => {},
160            _ => return Err(GoblinError::config_error(format!(
161                "Invalid log level: {}. Must be one of: trace, debug, info, warn, error",
162                self.logging.level
163            ))),
164        }
165
166        // Validate timeout is reasonable
167        if self.default_timeout == 0 {
168            return Err(GoblinError::config_error(
169                "Default timeout must be greater than 0"
170            ));
171        }
172
173        // Validate max concurrent is reasonable
174        if self.execution.max_concurrent == 0 {
175            return Err(GoblinError::config_error(
176                "Max concurrent executions must be greater than 0"
177            ));
178        }
179
180        Ok(())
181    }
182
183    /// Merge this configuration with another, with the other taking precedence
184    pub fn merge_with(mut self, other: EngineConfig) -> Self {
185        if other.scripts_dir.is_some() {
186            self.scripts_dir = other.scripts_dir;
187        }
188        if other.plans_dir.is_some() {
189            self.plans_dir = other.plans_dir;
190        }
191        if other.default_timeout != default_timeout() {
192            self.default_timeout = other.default_timeout;
193        }
194        
195        // Merge environment variables
196        for (key, value) in other.environment {
197            self.environment.insert(key, value);
198        }
199        
200        if other.require_tests {
201            self.require_tests = other.require_tests;
202        }
203        
204        // Merge logging config
205        if other.logging.level != default_log_level() {
206            self.logging.level = other.logging.level;
207        }
208        if !other.logging.stdout {
209            self.logging.stdout = other.logging.stdout;
210        }
211        if other.logging.file.is_some() {
212            self.logging.file = other.logging.file;
213        }
214        if !other.logging.timestamps {
215            self.logging.timestamps = other.logging.timestamps;
216        }
217        
218        // Merge execution config
219        if other.execution.max_concurrent != default_max_concurrent() {
220            self.execution.max_concurrent = other.execution.max_concurrent;
221        }
222        if !other.execution.fail_fast {
223            self.execution.fail_fast = other.execution.fail_fast;
224        }
225        if other.execution.temp_dir.is_some() {
226            self.execution.temp_dir = other.execution.temp_dir;
227        }
228        if !other.execution.cleanup_temp_files {
229            self.execution.cleanup_temp_files = other.execution.cleanup_temp_files;
230        }
231        
232        self
233    }
234
235    /// Create a sample configuration file content
236    pub fn sample_config() -> String {
237        r#"# Goblin Engine Configuration
238
239# Directory containing script subdirectories with goblin.toml files
240scripts_dir = "./scripts"
241
242# Directory containing plan TOML files  
243plans_dir = "./plans"
244
245# Default timeout for script execution (seconds)
246default_timeout = 500
247
248# Whether to require tests for all scripts by default
249require_tests = false
250
251# Global environment variables passed to all scripts
252[environment]
253# EXAMPLE_VAR = "example_value"
254
255[logging]
256# Log level: trace, debug, info, warn, error
257level = "info"
258# Log to stdout
259stdout = true
260# Optional log file path
261# file = "./goblin.log"
262# Include timestamps in logs
263timestamps = true
264
265[execution]
266# Maximum number of concurrent script executions
267max_concurrent = 4
268# Stop plan execution on first error
269fail_fast = true
270# Directory for temporary files (uses system temp if not specified)
271# temp_dir = "./temp"
272# Clean up temporary files after execution
273cleanup_temp_files = true
274"#.to_string()
275    }
276}
277
278fn default_timeout() -> u64 {
279    500
280}
281
282fn default_log_level() -> String {
283    "info".to_string()
284}
285
286fn default_max_concurrent() -> usize {
287    4
288}
289
290fn default_true() -> bool {
291    true
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn test_default_config() {
300        let config = EngineConfig::default();
301        assert_eq!(config.default_timeout, 500);
302        assert_eq!(config.logging.level, "info");
303        assert_eq!(config.execution.max_concurrent, 4);
304        assert!(!config.require_tests);
305        assert!(config.logging.stdout);
306        assert!(config.execution.fail_fast);
307    }
308
309    #[test]
310    fn test_config_from_toml() {
311        let toml_content = r#"
312            scripts_dir = "./scripts"
313            plans_dir = "./plans"
314            default_timeout = 300
315            require_tests = true
316            
317            [logging]
318            level = "debug"
319            stdout = false
320            
321            [execution]
322            max_concurrent = 8
323            fail_fast = false
324            
325            [environment]
326            TEST_VAR = "test_value"
327        "#;
328
329        let config = EngineConfig::from_toml_str(toml_content).unwrap();
330        assert_eq!(config.default_timeout, 300);
331        assert!(config.require_tests);
332        assert_eq!(config.logging.level, "debug");
333        assert!(!config.logging.stdout);
334        assert_eq!(config.execution.max_concurrent, 8);
335        assert!(!config.execution.fail_fast);
336        assert_eq!(config.environment.get("TEST_VAR").unwrap(), "test_value");
337    }
338
339    #[test]
340    fn test_config_validation() {
341        // Test invalid log level
342        let invalid_config = EngineConfig {
343            logging: LoggingConfig {
344                level: "invalid".to_string(),
345                ..Default::default()
346            },
347            ..Default::default()
348        };
349        assert!(invalid_config.validate().is_err());
350
351        // Test zero timeout
352        let zero_timeout_config = EngineConfig {
353            default_timeout: 0,
354            ..Default::default()
355        };
356        assert!(zero_timeout_config.validate().is_err());
357
358        // Test zero max concurrent
359        let zero_concurrent_config = EngineConfig {
360            execution: ExecutionConfig {
361                max_concurrent: 0,
362                ..Default::default()
363            },
364            ..Default::default()
365        };
366        assert!(zero_concurrent_config.validate().is_err());
367    }
368
369    #[test]
370    fn test_config_merge() {
371        let base_config = EngineConfig {
372            default_timeout: 300,
373            require_tests: false,
374            ..Default::default()
375        };
376
377        let override_config = EngineConfig {
378            default_timeout: 600,
379            require_tests: true,
380            ..Default::default()
381        };
382
383        let merged = base_config.merge_with(override_config);
384        assert_eq!(merged.default_timeout, 600);
385        assert!(merged.require_tests);
386    }
387
388    #[test]
389    fn test_sample_config() {
390        let sample = EngineConfig::sample_config();
391        assert!(sample.contains("scripts_dir"));
392        assert!(sample.contains("[logging]"));
393        assert!(sample.contains("[execution]"));
394    }
395}