1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::PathBuf;
4use crate::error::{GoblinError, Result};
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct EngineConfig {
9 #[serde(default)]
11 pub scripts_dir: Option<PathBuf>,
12
13 #[serde(default)]
15 pub plans_dir: Option<PathBuf>,
16
17 #[serde(default = "default_timeout")]
19 pub default_timeout: u64,
20
21 #[serde(default)]
23 pub environment: HashMap<String, String>,
24
25 #[serde(default)]
27 pub require_tests: bool,
28
29 #[serde(default)]
31 pub logging: LoggingConfig,
32
33 #[serde(default)]
35 pub execution: ExecutionConfig,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct LoggingConfig {
41 #[serde(default = "default_log_level")]
43 pub level: String,
44
45 #[serde(default = "default_true")]
47 pub stdout: bool,
48
49 #[serde(default)]
51 pub file: Option<PathBuf>,
52
53 #[serde(default = "default_true")]
55 pub timestamps: bool,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct ExecutionConfig {
61 #[serde(default = "default_max_concurrent")]
63 pub max_concurrent: usize,
64
65 #[serde(default = "default_true")]
67 pub fail_fast: bool,
68
69 #[serde(default)]
71 pub temp_dir: Option<PathBuf>,
72
73 #[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 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 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 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 pub fn validate(&self) -> Result<()> {
137 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 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 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 if self.default_timeout == 0 {
168 return Err(GoblinError::config_error(
169 "Default timeout must be greater than 0"
170 ));
171 }
172
173 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 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 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 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 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 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 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 let zero_timeout_config = EngineConfig {
353 default_timeout: 0,
354 ..Default::default()
355 };
356 assert!(zero_timeout_config.validate().is_err());
357
358 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}