Skip to main content

git_claw/core/
config.rs

1//! Configuration model and zero-config defaults loader.
2
3use crate::core::error::ConfigError;
4use serde::{Deserialize, Serialize};
5use std::collections::BTreeMap;
6use std::path::{Path, PathBuf};
7
8/// Expands leading '~/' to the user's home directory.
9pub 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
18/// Returns the default centralized worktree root: `~/.git-claw/worktrees/<repo_name>`.
19pub 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
31/// Resolves the global user configuration path.
32/// Can be overridden with `GIT_CLAW_GLOBAL_CONFIG` for testing.
33pub 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/// Cache sharing strategy for worktree dependencies.
45#[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/// Project-level configuration.
54#[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/// Lifecycle hooks.
69#[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/// Cache sharing configuration.
77#[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/// Untracked files duplication configuration.
86#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct FilesConfig {
88    #[serde(default)]
89    pub copy: Vec<String>,
90}
91
92/// Docker Compose override configuration.
93#[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    /// Returns true if any Docker configuration option is set.
105    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/// Environment and runtime version configuration.
111///
112/// Specifies runtime managers (`pyenv`, `uv`, `venv`), version names,
113/// and environment files to propagate into newly created worktrees.
114///
115/// # Examples
116///
117/// ```
118/// use git_claw::core::config::EnvironmentConfig;
119///
120/// let env = EnvironmentConfig {
121///     r#type: Some("pyenv".to_string()),
122///     name: Some("3.12.0".to_string()),
123///     copy_files: vec![".python-version".to_string()],
124/// };
125/// assert_eq!(env.r#type.as_deref(), Some("pyenv"));
126/// assert_eq!(env.name.as_deref(), Some("3.12.0"));
127/// assert_eq!(env.copy_files.len(), 1);
128/// ```
129#[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/// Strongly-typed configuration parsed from `.git-claw.toml` or `~/.git-claw/config.toml`.
140#[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    /// Constructs default configuration for a given repository name.
160    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    /// Returns the configured main branch name.
177    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    /// Returns the configured remote repository name, if specified.
185    pub fn remote(&self) -> Option<&str> {
186        self.project.as_ref().and_then(|p| p.remote.as_deref())
187    }
188
189    /// Returns the configured worktree root path, falling back to centralized default if empty.
190    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    /// Merges other config on top of self, where non-default fields in other take precedence.
206    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    /// Parses a TOML string into `Config`, filling missing fields with defaults.
270    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        // Ensure project config has deterministic defaults
276        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    /// Loads configuration respecting the two-tier hierarchy:
292    /// 1. Hardcoded system defaults.
293    /// 2. Merged with global user config (`~/.git-claw/config.toml` or `GIT_CLAW_GLOBAL_CONFIG`).
294    /// 3. Merged with local repository config (`path`).
295    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        // 1. Try loading global configuration
299        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        // 2. Try loading local repository configuration
312        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}