Skip to main content

swarm_engine_core/config/
global.rs

1//! グローバル設定
2//!
3//! `~/.swarm-engine/config.toml` の構造定義とマージロジック
4
5use std::path::{Path, PathBuf};
6
7use serde::{Deserialize, Serialize};
8
9use super::PathResolver;
10
11/// グローバル設定
12#[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/// 一般設定
26#[derive(Debug, Clone, Serialize, Deserialize)]
27#[serde(default)]
28#[derive(Default)]
29pub struct GeneralConfig {
30    /// デフォルトプロジェクトタイプ
31    pub default_project_type: ProjectType,
32    /// テレメトリ有効化
33    pub telemetry_enabled: bool,
34}
35
36/// プロジェクトタイプ
37#[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/// パス設定
47#[derive(Debug, Clone, Serialize, Deserialize)]
48#[serde(default)]
49#[derive(Default)]
50pub struct PathsConfig {
51    /// ユーザーデータディレクトリ
52    pub user_data_dir: Option<PathBuf>,
53    /// 追加シナリオ検索パス
54    pub scenario_search_paths: Vec<PathBuf>,
55    /// レポート出力先
56    pub report_output_dir: Option<PathBuf>,
57}
58
59/// Eval 設定
60#[derive(Debug, Clone, Serialize, Deserialize)]
61#[serde(default)]
62pub struct EvalConfig {
63    /// デフォルト実行回数
64    pub default_runs: u32,
65    /// デフォルトシード
66    pub default_seed: Option<u64>,
67    /// 並列実行数
68    pub default_parallel: u32,
69    /// ターゲット tick レイテンシ (ms)
70    pub target_tick_duration_ms: u64,
71}
72
73/// Gym 設定
74#[derive(Debug, Clone, Serialize, Deserialize)]
75#[serde(default)]
76pub struct GymConfig {
77    /// 学習データ保存先
78    pub data_dir: Option<PathBuf>,
79    /// デフォルトエピソード数
80    pub default_episodes: u32,
81}
82
83/// LLM 設定
84#[derive(Debug, Clone, Serialize, Deserialize)]
85#[serde(default)]
86pub struct LlmConfig {
87    /// デフォルトプロバイダー
88    pub default_provider: LlmProvider,
89    /// キャッシュ有効化
90    pub cache_enabled: bool,
91    /// キャッシュ TTL (時間)
92    pub cache_ttl_hours: u32,
93}
94
95/// LLM プロバイダー
96#[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/// ログ設定
106#[derive(Debug, Clone, Serialize, Deserialize)]
107#[serde(default)]
108pub struct LoggingConfig {
109    /// ログレベル
110    pub level: LogLevel,
111    /// ファイルログ有効化
112    pub file_enabled: bool,
113    /// 最大ファイルサイズ (MB)
114    pub max_size_mb: u32,
115    /// 最大ファイル数
116    pub max_files: u32,
117}
118
119/// ログレベル
120#[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/// Desktop 設定
132#[derive(Debug, Clone, Serialize, Deserialize)]
133#[serde(default)]
134pub struct DesktopConfig {
135    /// ウィンドウサイズ記憶
136    pub remember_window_size: bool,
137    /// 最近のプロジェクト数
138    pub recent_projects_limit: u32,
139    /// 自動リロード
140    pub auto_reload_scenarios: bool,
141    /// テーマ
142    pub theme: Theme,
143}
144
145/// テーマ
146#[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
155// =============================================================================
156// Default 実装
157// =============================================================================
158
159impl 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, // 1週間
185        }
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
211// =============================================================================
212// GlobalConfig 実装
213// =============================================================================
214
215impl GlobalConfig {
216    /// ファイルから読み込み
217    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    /// グローバル設定ファイルから読み込み
230    ///
231    /// `~/.swarm-engine/config.toml` が存在しない場合はデフォルト値を返す
232    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    /// プロジェクト設定とマージして最終設定を取得
248    ///
249    /// マージ順序: Default → Global → Project
250    pub fn load_merged() -> Self {
251        let mut config = Self::load_global();
252
253        // プロジェクト設定があればマージ
254        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    /// 別の設定をマージ(後勝ち)
271    pub fn merge(&mut self, other: Self) {
272        // general
273        self.general.default_project_type = other.general.default_project_type;
274        self.general.telemetry_enabled = other.general.telemetry_enabled;
275
276        // paths(配列は追加、Optionは上書き)
277        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        // eval
288        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        // gym
296        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        // llm
302        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        // logging
307        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        // desktop
313        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    /// ファイルに保存
320    pub fn save_to_file(&self, path: &Path) -> Result<(), ConfigError> {
321        let content = toml::to_string_pretty(self).map_err(ConfigError::Serialize)?;
322
323        // 親ディレクトリを作成
324        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    /// グローバル設定ファイルに保存
338    pub fn save_global(&self) -> Result<(), ConfigError> {
339        self.save_to_file(&PathResolver::global_config_file())
340    }
341
342    /// 解決済みユーザーデータディレクトリを取得
343    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    /// 解決済みレポートディレクトリを取得
351    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/// 設定エラー
360#[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        // 存在しないファイルの場合はデフォルト
452        let config = GlobalConfig::load_global();
453        assert_eq!(config.eval.default_runs, 30);
454    }
455}