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: None,
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 = 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}