1#![allow(clippy::all)]
2#![allow(dead_code)]
3use 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#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct Config {
15 #[serde(default)]
17 pub files: Vec<FileMapping>,
18
19 #[serde(default)]
21 pub hooks: HookConfig,
22
23 pub worktree_base: Option<PathBuf>,
25
26 #[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: Some(PathBuf::from("worktrees")),
41 branch_prefix: default_branch_prefix(),
42 }
43 }
44}
45
46impl Config {
47 pub async fn load(path: Option<&Path>) -> Result<Self> {
49 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 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 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 Ok(Self::default())
84 }
85
86 pub async fn save(&self, path: &Path) -> Result<()> {
88 let content = toml::to_string_pretty(self).context("Failed to serialize config")?;
89
90 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 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 pub fn merge(global: Self, project: Self) -> Self {
160 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 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 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 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 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 let config = if cfg!(test) {
227 Self::default()
229 } else {
230 Self::example()
232 };
233 config.save(&config_path).await?;
234
235 Ok(config_path)
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242
243 #[test]
244 fn test_default_config() {
245 let config = Config::default();
246 assert_eq!(config.branch_prefix, "agent/");
247 assert!(config.files.is_empty());
248 }
249
250 #[test]
251 fn test_example_config() {
252 let config = Config::example();
253 assert!(!config.files.is_empty());
254 assert_eq!(config.files[0].path, PathBuf::from(".env"));
255 assert_eq!(config.files[0].mapping_type, MappingType::Symlink);
256 assert_eq!(config.files[1].mapping_type, MappingType::Copy);
257 assert!(!config.hooks.post_create.is_empty());
258 }
259
260 #[test]
261 fn test_hook_command_example() {
262 let config = Config::example();
263 let first_hook = &config.hooks.post_create[0];
264 assert_eq!(first_hook.command, "echo");
265 assert_eq!(first_hook.timeout, 60);
266 assert!(!first_hook.continue_on_error);
267 }
268
269 #[tokio::test]
270 async fn test_init_creates_file() {
271 use tempfile::TempDir;
272
273 let temp_dir = TempDir::new().unwrap();
275 let config_path = temp_dir.path().join("twin.toml");
276
277 let result_path = Config::init(Some(config_path.clone()), false)
279 .await
280 .unwrap();
281
282 assert_eq!(result_path, config_path);
284 assert!(config_path.exists());
285
286 let content = tokio::fs::read_to_string(&config_path).await.unwrap();
288 let _config: Config = toml::from_str(&content).unwrap();
289 }
290
291 #[tokio::test]
292 async fn test_init_fails_if_exists() {
293 use tempfile::TempDir;
294
295 let temp_dir = TempDir::new().unwrap();
297 let config_path = temp_dir.path().join("twin.toml");
298
299 Config::init(Some(config_path.clone()), false)
301 .await
302 .unwrap();
303
304 let result = Config::init(Some(config_path.clone()), false).await;
306 assert!(result.is_err());
307 assert!(result.unwrap_err().to_string().contains("already exists"));
308 }
309
310 #[tokio::test]
311 async fn test_init_force_overwrites() {
312 use tempfile::TempDir;
313
314 let temp_dir = TempDir::new().unwrap();
316 let config_path = temp_dir.path().join("twin.toml");
317
318 Config::init(Some(config_path.clone()), false)
320 .await
321 .unwrap();
322
323 tokio::fs::write(&config_path, "# custom content\n")
325 .await
326 .unwrap();
327
328 Config::init(Some(config_path.clone()), true).await.unwrap();
330
331 let content = tokio::fs::read_to_string(&config_path).await.unwrap();
333 assert!(!content.starts_with("# custom content"));
334 let _config: Config = toml::from_str(&content).unwrap();
335 }
336
337 #[tokio::test]
338 async fn test_init_default_path() {
339 use std::env;
340 use tempfile::TempDir;
341
342 let temp_dir = TempDir::new().unwrap();
344 let original_dir = env::current_dir().unwrap();
345 env::set_current_dir(temp_dir.path()).unwrap();
346
347 let result_path = Config::init(None, false).await.unwrap();
349
350 assert_eq!(result_path.file_name().unwrap(), "twin.toml");
352 assert!(result_path.exists());
353
354 env::set_current_dir(original_dir).unwrap();
356 }
357}