swarm_engine_core/config/
global.rs1use std::path::{Path, PathBuf};
6
7use serde::{Deserialize, Serialize};
8
9use super::PathResolver;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13#[serde(default)]
14#[derive(Default)]
15pub struct GlobalConfig {
16 pub general: GeneralConfig,
17 pub paths: PathsConfig,
18 pub eval: EvalConfig,
19 pub gym: GymConfig,
20 pub llm: LlmConfig,
21 pub logging: LoggingConfig,
22 pub desktop: DesktopConfig,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27#[serde(default)]
28#[derive(Default)]
29pub struct GeneralConfig {
30 pub default_project_type: ProjectType,
32 pub telemetry_enabled: bool,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
38#[serde(rename_all = "lowercase")]
39pub enum ProjectType {
40 #[default]
41 Eval,
42 Gym,
43 Both,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
48#[serde(default)]
49#[derive(Default)]
50pub struct PathsConfig {
51 pub user_data_dir: Option<PathBuf>,
53 pub scenario_search_paths: Vec<PathBuf>,
55 pub report_output_dir: Option<PathBuf>,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61#[serde(default)]
62pub struct EvalConfig {
63 pub default_runs: u32,
65 pub default_seed: Option<u64>,
67 pub default_parallel: u32,
69 pub target_tick_duration_ms: u64,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75#[serde(default)]
76pub struct GymConfig {
77 pub data_dir: Option<PathBuf>,
79 pub default_episodes: u32,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
85#[serde(default)]
86pub struct LlmConfig {
87 pub default_provider: LlmProvider,
89 pub cache_enabled: bool,
91 pub cache_ttl_hours: u32,
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
97#[serde(rename_all = "lowercase")]
98pub enum LlmProvider {
99 #[default]
100 OpenAI,
101 Anthropic,
102 Local,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
107#[serde(default)]
108pub struct LoggingConfig {
109 pub level: LogLevel,
111 pub file_enabled: bool,
113 pub max_size_mb: u32,
115 pub max_files: u32,
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
121#[serde(rename_all = "lowercase")]
122pub enum LogLevel {
123 Trace,
124 Debug,
125 #[default]
126 Info,
127 Warn,
128 Error,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
133#[serde(default)]
134pub struct DesktopConfig {
135 pub remember_window_size: bool,
137 pub recent_projects_limit: u32,
139 pub auto_reload_scenarios: bool,
141 pub theme: Theme,
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
147#[serde(rename_all = "lowercase")]
148pub enum Theme {
149 Light,
150 Dark,
151 #[default]
152 System,
153}
154
155impl Default for EvalConfig {
160 fn default() -> Self {
161 Self {
162 default_runs: 30,
163 default_seed: None,
164 default_parallel: 1,
165 target_tick_duration_ms: 10,
166 }
167 }
168}
169
170impl Default for GymConfig {
171 fn default() -> Self {
172 Self {
173 data_dir: None,
174 default_episodes: 1000,
175 }
176 }
177}
178
179impl Default for LlmConfig {
180 fn default() -> Self {
181 Self {
182 default_provider: LlmProvider::default(),
183 cache_enabled: true,
184 cache_ttl_hours: 168, }
186 }
187}
188
189impl Default for LoggingConfig {
190 fn default() -> Self {
191 Self {
192 level: LogLevel::default(),
193 file_enabled: true,
194 max_size_mb: 100,
195 max_files: 5,
196 }
197 }
198}
199
200impl Default for DesktopConfig {
201 fn default() -> Self {
202 Self {
203 remember_window_size: true,
204 recent_projects_limit: 10,
205 auto_reload_scenarios: true,
206 theme: Theme::default(),
207 }
208 }
209}
210
211impl GlobalConfig {
216 pub fn load_from_file(path: &Path) -> Result<Self, ConfigError> {
218 let content = std::fs::read_to_string(path).map_err(|e| ConfigError::Io {
219 path: path.to_path_buf(),
220 source: e,
221 })?;
222
223 toml::from_str(&content).map_err(|e| ConfigError::Parse {
224 path: path.to_path_buf(),
225 source: e,
226 })
227 }
228
229 pub fn load_global() -> Self {
233 let path = PathResolver::global_config_file();
234 if path.exists() {
235 match Self::load_from_file(&path) {
236 Ok(config) => config,
237 Err(e) => {
238 tracing::warn!("Failed to load global config: {}", e);
239 Self::default()
240 }
241 }
242 } else {
243 Self::default()
244 }
245 }
246
247 pub fn load_merged() -> Self {
251 let mut config = Self::load_global();
252
253 if let Some(project_path) = PathResolver::project_config_file() {
255 if project_path.exists() {
256 match Self::load_from_file(&project_path) {
257 Ok(project_config) => {
258 config.merge(project_config);
259 }
260 Err(e) => {
261 tracing::warn!("Failed to load project config: {}", e);
262 }
263 }
264 }
265 }
266
267 config
268 }
269
270 pub fn merge(&mut self, other: Self) {
272 self.general.default_project_type = other.general.default_project_type;
274 self.general.telemetry_enabled = other.general.telemetry_enabled;
275
276 if other.paths.user_data_dir.is_some() {
278 self.paths.user_data_dir = other.paths.user_data_dir;
279 }
280 self.paths
281 .scenario_search_paths
282 .extend(other.paths.scenario_search_paths);
283 if other.paths.report_output_dir.is_some() {
284 self.paths.report_output_dir = other.paths.report_output_dir;
285 }
286
287 self.eval.default_runs = other.eval.default_runs;
289 if other.eval.default_seed.is_some() {
290 self.eval.default_seed = other.eval.default_seed;
291 }
292 self.eval.default_parallel = other.eval.default_parallel;
293 self.eval.target_tick_duration_ms = other.eval.target_tick_duration_ms;
294
295 if other.gym.data_dir.is_some() {
297 self.gym.data_dir = other.gym.data_dir;
298 }
299 self.gym.default_episodes = other.gym.default_episodes;
300
301 self.llm.default_provider = other.llm.default_provider;
303 self.llm.cache_enabled = other.llm.cache_enabled;
304 self.llm.cache_ttl_hours = other.llm.cache_ttl_hours;
305
306 self.logging.level = other.logging.level;
308 self.logging.file_enabled = other.logging.file_enabled;
309 self.logging.max_size_mb = other.logging.max_size_mb;
310 self.logging.max_files = other.logging.max_files;
311
312 self.desktop.remember_window_size = other.desktop.remember_window_size;
314 self.desktop.recent_projects_limit = other.desktop.recent_projects_limit;
315 self.desktop.auto_reload_scenarios = other.desktop.auto_reload_scenarios;
316 self.desktop.theme = other.desktop.theme;
317 }
318
319 pub fn save_to_file(&self, path: &Path) -> Result<(), ConfigError> {
321 let content = toml::to_string_pretty(self).map_err(ConfigError::Serialize)?;
322
323 if let Some(parent) = path.parent() {
325 std::fs::create_dir_all(parent).map_err(|e| ConfigError::Io {
326 path: parent.to_path_buf(),
327 source: e,
328 })?;
329 }
330
331 std::fs::write(path, content).map_err(|e| ConfigError::Io {
332 path: path.to_path_buf(),
333 source: e,
334 })
335 }
336
337 pub fn save_global(&self) -> Result<(), ConfigError> {
339 self.save_to_file(&PathResolver::global_config_file())
340 }
341
342 pub fn resolved_user_data_dir(&self) -> PathBuf {
344 self.paths
345 .user_data_dir
346 .clone()
347 .unwrap_or_else(PathResolver::user_data_dir)
348 }
349
350 pub fn resolved_reports_dir(&self) -> PathBuf {
352 self.paths
353 .report_output_dir
354 .clone()
355 .unwrap_or_else(PathResolver::reports_dir)
356 }
357}
358
359#[derive(Debug, thiserror::Error)]
361pub enum ConfigError {
362 #[error("Failed to read config file {path}: {source}")]
363 Io {
364 path: PathBuf,
365 source: std::io::Error,
366 },
367 #[error("Failed to parse config file {path}: {source}")]
368 Parse {
369 path: PathBuf,
370 source: toml::de::Error,
371 },
372 #[error("Failed to serialize config: {0}")]
373 Serialize(toml::ser::Error),
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379 use tempfile::TempDir;
380
381 #[test]
382 fn test_default_config() {
383 let config = GlobalConfig::default();
384 assert_eq!(config.eval.default_runs, 30);
385 assert_eq!(config.logging.level, LogLevel::Info);
386 assert_eq!(config.desktop.theme, Theme::System);
387 }
388
389 #[test]
390 fn test_save_and_load() {
391 let temp_dir = TempDir::new().unwrap();
392 let path = temp_dir.path().join("config.toml");
393
394 let mut config = GlobalConfig::default();
395 config.eval.default_runs = 50;
396 config.logging.level = LogLevel::Debug;
397
398 config.save_to_file(&path).unwrap();
399
400 let loaded = GlobalConfig::load_from_file(&path).unwrap();
401 assert_eq!(loaded.eval.default_runs, 50);
402 assert_eq!(loaded.logging.level, LogLevel::Debug);
403 }
404
405 #[test]
406 fn test_merge_configs() {
407 let mut base = GlobalConfig::default();
408 base.eval.default_runs = 10;
409 base.paths.scenario_search_paths = vec![PathBuf::from("/base/path")];
410
411 let mut override_config = GlobalConfig::default();
412 override_config.eval.default_runs = 20;
413 override_config.paths.scenario_search_paths = vec![PathBuf::from("/override/path")];
414
415 base.merge(override_config);
416
417 assert_eq!(base.eval.default_runs, 20);
418 assert_eq!(base.paths.scenario_search_paths.len(), 2);
419 assert_eq!(
420 base.paths.scenario_search_paths[0],
421 PathBuf::from("/base/path")
422 );
423 assert_eq!(
424 base.paths.scenario_search_paths[1],
425 PathBuf::from("/override/path")
426 );
427 }
428
429 #[test]
430 fn test_parse_toml() {
431 let toml_str = r#"
432[general]
433default_project_type = "eval"
434telemetry_enabled = false
435
436[eval]
437default_runs = 100
438target_tick_duration_ms = 5
439
440[logging]
441level = "debug"
442"#;
443 let config: GlobalConfig = toml::from_str(toml_str).unwrap();
444 assert_eq!(config.eval.default_runs, 100);
445 assert_eq!(config.eval.target_tick_duration_ms, 5);
446 assert_eq!(config.logging.level, LogLevel::Debug);
447 }
448
449 #[test]
450 fn test_load_global_missing_file() {
451 let config = GlobalConfig::load_global();
453 assert_eq!(config.eval.default_runs, 30);
454 }
455}