twin_cli/
config.rs

1#![allow(clippy::all)]
2#![allow(dead_code)]
3/// 設定管理モジュール
4use anyhow::{Context, Result};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use tokio::fs;
9
10use crate::core::{FileMapping, HookCommand, HookConfig, MappingType};
11
12/// アプリケーション全体の設定
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct Config {
15    /// Git管理外ファイルのマッピング設定
16    #[serde(default)]
17    pub files: Vec<FileMapping>,
18
19    /// フック設定(環境作成・削除時に実行するコマンド)
20    #[serde(default)]
21    pub hooks: HookConfig,
22
23    /// Worktreeのベースディレクトリ
24    pub worktree_base: Option<PathBuf>,
25
26    /// デフォルトのブランチプレフィックス
27    #[serde(default = "default_branch_prefix")]
28    pub branch_prefix: String,
29}
30
31fn default_branch_prefix() -> String {
32    "agent/".to_string()
33}
34
35impl Default for Config {
36    fn default() -> Self {
37        Self {
38            files: Vec::new(),
39            hooks: HookConfig::default(),
40            worktree_base: None,
41            branch_prefix: default_branch_prefix(),
42        }
43    }
44}
45
46impl Config {
47    /// 設定ファイルを読み込む(ファイルが存在しない場合はデフォルト値を返す)
48    pub async fn load(path: Option<&Path>) -> Result<Self> {
49        // パスが指定されていて、ファイルが存在する場合は読み込む
50        if let Some(p) = path {
51            if p.exists() {
52                let content = fs::read_to_string(p)
53                    .await
54                    .with_context(|| format!("Failed to read config file: {}", p.display()))?;
55                return toml::from_str(&content)
56                    .with_context(|| format!("Failed to parse config file: {}", p.display()));
57            }
58        }
59
60        // プロジェクト設定を探す
61        if let Some(config_path) = Self::find_config_path(Path::new(".")).await {
62            let content = fs::read_to_string(&config_path).await.with_context(|| {
63                format!("Failed to read config file: {}", config_path.display())
64            })?;
65            return toml::from_str(&content).with_context(|| {
66                format!("Failed to parse config file: {}", config_path.display())
67            });
68        }
69
70        // グローバル設定を試す
71        if let Ok(global_path) = Self::global_config_path() {
72            if global_path.exists() {
73                let content = fs::read_to_string(&global_path).await.with_context(|| {
74                    format!("Failed to read config file: {}", global_path.display())
75                })?;
76                return toml::from_str(&content).with_context(|| {
77                    format!("Failed to parse config file: {}", global_path.display())
78                });
79            }
80        }
81
82        // デフォルト設定を返す
83        Ok(Self::default())
84    }
85
86    /// 設定ファイルを保存
87    pub async fn save(&self, path: &Path) -> Result<()> {
88        let content = toml::to_string_pretty(self).context("Failed to serialize config")?;
89
90        // 親ディレクトリが存在しない場合は作成
91        if let Some(parent) = path.parent() {
92            fs::create_dir_all(parent).await?;
93        }
94
95        fs::write(path, content)
96            .await
97            .with_context(|| format!("Failed to write config file: {}", path.display()))
98    }
99
100    /// デフォルト設定ファイルを作成
101    pub fn example() -> Self {
102        let mut env_vars = HashMap::new();
103        env_vars.insert("NODE_ENV".to_string(), "production".to_string());
104
105        Self {
106            files: vec![
107                FileMapping {
108                    path: PathBuf::from(".env"),
109                    mapping_type: MappingType::Symlink,
110                    description: Some("環境変数ファイル(共有)".to_string()),
111                    skip_if_exists: false,
112                },
113                FileMapping {
114                    path: PathBuf::from(".env.local"),
115                    mapping_type: MappingType::Copy,
116                    description: Some("ローカル環境変数(各環境で独立)".to_string()),
117                    skip_if_exists: false,
118                },
119                FileMapping {
120                    path: PathBuf::from(".vscode/settings.local.json"),
121                    mapping_type: MappingType::Symlink,
122                    description: Some("VS Codeローカル設定".to_string()),
123                    skip_if_exists: true,
124                },
125            ],
126            hooks: HookConfig {
127                pre_create: vec![],
128                post_create: vec![
129                    HookCommand {
130                        command: "echo".to_string(),
131                        args: vec!["Setting up environment...".to_string()],
132                        env: HashMap::new(),
133                        timeout: 60,
134                        continue_on_error: false,
135                    },
136                    HookCommand {
137                        command: "npm".to_string(),
138                        args: vec!["install".to_string()],
139                        env: env_vars.clone(),
140                        timeout: 300,
141                        continue_on_error: false,
142                    },
143                ],
144                pre_remove: vec![HookCommand {
145                    command: "echo".to_string(),
146                    args: vec!["Cleaning up environment...".to_string()],
147                    env: HashMap::new(),
148                    timeout: 60,
149                    continue_on_error: true,
150                }],
151                post_remove: vec![],
152            },
153            worktree_base: Some(PathBuf::from("./worktrees")),
154            branch_prefix: "agent/".to_string(),
155        }
156    }
157
158    /// グローバル設定とプロジェクト設定をマージ
159    pub fn merge(global: Self, project: Self) -> Self {
160        // プロジェクト設定を優先し、未設定の項目はグローバル設定を使用
161        Self {
162            files: if !project.files.is_empty() {
163                project.files
164            } else {
165                global.files
166            },
167            hooks: if project.hooks != HookConfig::default() {
168                project.hooks
169            } else {
170                global.hooks
171            },
172            worktree_base: project.worktree_base.or(global.worktree_base),
173            branch_prefix: if project.branch_prefix != default_branch_prefix() {
174                project.branch_prefix
175            } else {
176                global.branch_prefix
177            },
178        }
179    }
180
181    /// 設定ファイルのパスを取得(プロジェクトルートから検索)
182    pub async fn find_config_path(start_path: &Path) -> Option<PathBuf> {
183        let mut current = start_path.to_path_buf();
184
185        loop {
186            let config_path = current.join("twin.toml");
187            if config_path.exists() {
188                return Some(config_path);
189            }
190
191            let dot_config_path = current.join(".twin.toml");
192            if dot_config_path.exists() {
193                return Some(dot_config_path);
194            }
195
196            if !current.pop() {
197                break;
198            }
199        }
200
201        None
202    }
203
204    /// グローバル設定ファイルのパスを取得
205    pub fn global_config_path() -> Result<PathBuf> {
206        let proj_dirs = directories::ProjectDirs::from("com", "twin", "twin")
207            .context("Failed to get project directories")?;
208        Ok(proj_dirs.config_dir().join("config.toml"))
209    }
210
211    /// 設定ファイルを初期化(twin initコマンド用)
212    pub async fn init(path: Option<PathBuf>, force: bool) -> Result<PathBuf> {
213        let config_path = path.unwrap_or_else(|| PathBuf::from("twin.toml"));
214
215        // ファイルが既に存在する場合
216        if config_path.exists() && !force {
217            anyhow::bail!(
218                "Config file already exists: {}. Use --force to overwrite.",
219                config_path.display()
220            );
221        }
222
223        // サンプル設定を作成
224        let config = Self::example();
225        config.save(&config_path).await?;
226
227        Ok(config_path)
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn test_default_config() {
237        let config = Config::default();
238        assert_eq!(config.branch_prefix, "agent/");
239        assert!(config.files.is_empty());
240    }
241
242    #[test]
243    fn test_example_config() {
244        let config = Config::example();
245        assert!(!config.files.is_empty());
246        assert_eq!(config.files[0].path, PathBuf::from(".env"));
247        assert_eq!(config.files[0].mapping_type, MappingType::Symlink);
248        assert_eq!(config.files[1].mapping_type, MappingType::Copy);
249        assert!(!config.hooks.post_create.is_empty());
250    }
251
252    #[test]
253    fn test_hook_command_example() {
254        let config = Config::example();
255        let first_hook = &config.hooks.post_create[0];
256        assert_eq!(first_hook.command, "echo");
257        assert_eq!(first_hook.timeout, 60);
258        assert!(!first_hook.continue_on_error);
259    }
260}