1use crate::core::error::ConfigError;
4use serde::{Deserialize, Serialize};
5use std::collections::BTreeMap;
6use std::path::{Path, PathBuf};
7
8pub fn expand_home(path: &str) -> String {
10 if let Some(stripped) = path.strip_prefix("~/") {
11 if let Ok(home) = std::env::var("HOME") {
12 return format!("{}/{}", home.trim_end_matches('/'), stripped);
13 }
14 }
15 path.to_string()
16}
17
18pub fn default_worktree_root(repo_name: &str) -> String {
20 if let Ok(home) = std::env::var("HOME") {
21 format!(
22 "{}/.git-claw/worktrees/{}",
23 home.trim_end_matches('/'),
24 repo_name
25 )
26 } else {
27 format!("~/.git-claw/worktrees/{}", repo_name)
28 }
29}
30
31pub fn global_config_path() -> Option<PathBuf> {
34 std::env::var("GIT_CLAW_GLOBAL_CONFIG")
35 .ok()
36 .map(PathBuf::from)
37 .or_else(|| {
38 std::env::var("HOME")
39 .ok()
40 .map(|h| PathBuf::from(h).join(".git-claw/config.toml"))
41 })
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
46#[serde(rename_all = "lowercase")]
47pub enum CacheStrategy {
48 #[default]
49 Shared,
50 Isolated,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub struct ProjectConfig {
56 #[serde(default = "default_main_branch")]
57 pub main_branch: String,
58 #[serde(default)]
59 pub worktree_root: String,
60 #[serde(default)]
61 pub remote: Option<String>,
62}
63
64fn default_main_branch() -> String {
65 "main".to_string()
66}
67
68#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct HooksConfig {
71 pub post_start: Option<String>,
72 pub pre_finish: Option<String>,
73 pub post_finish: Option<String>,
74}
75
76#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct CacheConfig {
79 #[serde(default)]
80 pub strategy: CacheStrategy,
81 #[serde(default)]
82 pub directories: Vec<String>,
83}
84
85#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct FilesConfig {
88 #[serde(default)]
89 pub copy: Vec<String>,
90}
91
92#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
94pub struct DockerConfig {
95 #[serde(default)]
96 pub compose_file: Option<String>,
97 #[serde(default)]
98 pub network: Option<String>,
99 #[serde(default)]
100 pub shared_services: Vec<String>,
101}
102
103impl DockerConfig {
104 pub fn is_enabled(&self) -> bool {
106 self.compose_file.is_some() || self.network.is_some() || !self.shared_services.is_empty()
107 }
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
130pub struct EnvironmentConfig {
131 #[serde(default)]
132 pub r#type: Option<String>,
133 #[serde(default)]
134 pub name: Option<String>,
135 #[serde(default)]
136 pub copy_files: Vec<String>,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
141pub struct Config {
142 #[serde(default)]
143 pub project: Option<ProjectConfig>,
144 #[serde(default)]
145 pub ports: BTreeMap<String, u16>,
146 #[serde(default)]
147 pub hooks: HooksConfig,
148 #[serde(default)]
149 pub cache: CacheConfig,
150 #[serde(default)]
151 pub files: FilesConfig,
152 #[serde(default)]
153 pub docker: DockerConfig,
154 #[serde(default)]
155 pub environment: EnvironmentConfig,
156}
157
158impl Config {
159 pub fn default_for_repo(repo_name: &str) -> Self {
161 Self {
162 project: Some(ProjectConfig {
163 main_branch: "main".to_string(),
164 worktree_root: default_worktree_root(repo_name),
165 remote: None,
166 }),
167 ports: BTreeMap::new(),
168 hooks: HooksConfig::default(),
169 cache: CacheConfig::default(),
170 files: FilesConfig::default(),
171 docker: DockerConfig::default(),
172 environment: EnvironmentConfig::default(),
173 }
174 }
175
176 pub fn main_branch(&self) -> &str {
178 self.project
179 .as_ref()
180 .map(|p| p.main_branch.as_str())
181 .unwrap_or("main")
182 }
183
184 pub fn remote(&self) -> Option<&str> {
186 self.project.as_ref().and_then(|p| p.remote.as_deref())
187 }
188
189 pub fn worktree_root(&self, repo_name: &str) -> String {
191 let raw = self
192 .project
193 .as_ref()
194 .and_then(|p| {
195 if p.worktree_root.trim().is_empty() {
196 None
197 } else {
198 Some(p.worktree_root.clone())
199 }
200 })
201 .unwrap_or_else(|| default_worktree_root(repo_name));
202 expand_home(&raw)
203 }
204
205 pub fn merge_with(&mut self, other: Config) {
207 if let Some(other_proj) = other.project {
208 if let Some(ref mut self_proj) = self.project {
209 if !other_proj.main_branch.trim().is_empty() {
210 self_proj.main_branch = other_proj.main_branch;
211 }
212 if !other_proj.worktree_root.trim().is_empty() {
213 self_proj.worktree_root = other_proj.worktree_root;
214 }
215 if other_proj.remote.is_some() {
216 self_proj.remote = other_proj.remote;
217 }
218 } else {
219 self.project = Some(other_proj);
220 }
221 }
222
223 for (k, v) in other.ports {
224 self.ports.insert(k, v);
225 }
226
227 if other.hooks.post_start.is_some() {
228 self.hooks.post_start = other.hooks.post_start;
229 }
230 if other.hooks.pre_finish.is_some() {
231 self.hooks.pre_finish = other.hooks.pre_finish;
232 }
233 if other.hooks.post_finish.is_some() {
234 self.hooks.post_finish = other.hooks.post_finish;
235 }
236
237 if other.cache.strategy != CacheStrategy::default() {
238 self.cache.strategy = other.cache.strategy;
239 }
240 if !other.cache.directories.is_empty() {
241 self.cache.directories = other.cache.directories;
242 }
243
244 if !other.files.copy.is_empty() {
245 self.files.copy = other.files.copy;
246 }
247
248 if other.docker.compose_file.is_some() {
249 self.docker.compose_file = other.docker.compose_file;
250 }
251 if other.docker.network.is_some() {
252 self.docker.network = other.docker.network;
253 }
254 if !other.docker.shared_services.is_empty() {
255 self.docker.shared_services = other.docker.shared_services;
256 }
257
258 if other.environment.r#type.is_some() {
259 self.environment.r#type = other.environment.r#type;
260 }
261 if other.environment.name.is_some() {
262 self.environment.name = other.environment.name;
263 }
264 if !other.environment.copy_files.is_empty() {
265 self.environment.copy_files = other.environment.copy_files;
266 }
267 }
268
269 pub fn parse_toml_str(toml_str: &str, repo_name: &str) -> Result<Self, ConfigError> {
271 let mut parsed: Config = toml::from_str(toml_str).map_err(|e| ConfigError::ParseError {
272 message: e.to_string(),
273 })?;
274
275 if let Some(ref mut project) = parsed.project {
277 if project.worktree_root.trim().is_empty() {
278 project.worktree_root = default_worktree_root(repo_name);
279 }
280 } else {
281 parsed.project = Some(ProjectConfig {
282 main_branch: "main".to_string(),
283 worktree_root: default_worktree_root(repo_name),
284 remote: None,
285 });
286 }
287
288 Ok(parsed)
289 }
290
291 pub fn load_or_default(path: &Path, repo_name: &str) -> Result<Self, ConfigError> {
296 let mut base = Self::default_for_repo(repo_name);
297
298 if let Some(global_path) = global_config_path() {
300 if global_path.exists() {
301 let global_content =
302 std::fs::read_to_string(&global_path).map_err(|e| ConfigError::IoError {
303 path: global_path.to_string_lossy().to_string(),
304 source: e,
305 })?;
306 let global_config = Self::parse_toml_str(&global_content, repo_name)?;
307 base.merge_with(global_config);
308 }
309 }
310
311 if path.exists() {
313 let local_content =
314 std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
315 path: path.to_string_lossy().to_string(),
316 source: e,
317 })?;
318 let local_config = Self::parse_toml_str(&local_content, repo_name)?;
319 base.merge_with(local_config);
320 }
321
322 Ok(base)
323 }
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329
330 #[test]
331 fn test_environment_config_parse_toml() {
332 let toml_str = r#"
333[project]
334main_branch = "main"
335
336[environment]
337type = "pyenv"
338name = "3.11.0"
339copy_files = [".python-version", ".nvmrc"]
340"#;
341 let config = Config::parse_toml_str(toml_str, "test-repo").expect("parses toml");
342 assert_eq!(config.environment.r#type.as_deref(), Some("pyenv"));
343 assert_eq!(config.environment.name.as_deref(), Some("3.11.0"));
344 assert_eq!(
345 config.environment.copy_files,
346 vec![".python-version".to_string(), ".nvmrc".to_string()]
347 );
348 }
349
350 #[test]
351 fn test_environment_config_merge_with() {
352 let mut base = Config::default_for_repo("test-repo");
353 assert_eq!(base.environment.r#type, None);
354 assert_eq!(base.environment.name, None);
355 assert!(base.environment.copy_files.is_empty());
356
357 let mut override_cfg = Config::default_for_repo("test-repo");
358 override_cfg.environment.r#type = Some("uv".to_string());
359 override_cfg.environment.name = Some("custom-env".to_string());
360 override_cfg.environment.copy_files = vec![".envrc".to_string()];
361
362 base.merge_with(override_cfg);
363
364 assert_eq!(base.environment.r#type.as_deref(), Some("uv"));
365 assert_eq!(base.environment.name.as_deref(), Some("custom-env"));
366 assert_eq!(base.environment.copy_files, vec![".envrc".to_string()]);
367 }
368}