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/// Strongly-typed configuration parsed from `.git-claw.toml` or `~/.git-claw/config.toml`.
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
112pub struct Config {
113    #[serde(default)]
114    pub project: Option<ProjectConfig>,
115    #[serde(default)]
116    pub ports: BTreeMap<String, u16>,
117    #[serde(default)]
118    pub hooks: HooksConfig,
119    #[serde(default)]
120    pub cache: CacheConfig,
121    #[serde(default)]
122    pub files: FilesConfig,
123    #[serde(default)]
124    pub docker: DockerConfig,
125}
126
127impl Config {
128    /// Constructs default configuration for a given repository name.
129    pub fn default_for_repo(repo_name: &str) -> Self {
130        Self {
131            project: Some(ProjectConfig {
132                main_branch: "main".to_string(),
133                worktree_root: default_worktree_root(repo_name),
134                remote: None,
135            }),
136            ports: BTreeMap::new(),
137            hooks: HooksConfig::default(),
138            cache: CacheConfig::default(),
139            files: FilesConfig::default(),
140            docker: DockerConfig::default(),
141        }
142    }
143
144    /// Returns the configured main branch name.
145    pub fn main_branch(&self) -> &str {
146        self.project
147            .as_ref()
148            .map(|p| p.main_branch.as_str())
149            .unwrap_or("main")
150    }
151
152    /// Returns the configured remote repository name, if specified.
153    pub fn remote(&self) -> Option<&str> {
154        self.project.as_ref().and_then(|p| p.remote.as_deref())
155    }
156
157    /// Returns the configured worktree root path, falling back to centralized default if empty.
158    pub fn worktree_root(&self, repo_name: &str) -> String {
159        let raw = self
160            .project
161            .as_ref()
162            .and_then(|p| {
163                if p.worktree_root.trim().is_empty() {
164                    None
165                } else {
166                    Some(p.worktree_root.clone())
167                }
168            })
169            .unwrap_or_else(|| default_worktree_root(repo_name));
170        expand_home(&raw)
171    }
172
173    /// Merges other config on top of self, where non-default fields in other take precedence.
174    pub fn merge_with(&mut self, other: Config) {
175        if let Some(other_proj) = other.project {
176            if let Some(ref mut self_proj) = self.project {
177                if !other_proj.main_branch.trim().is_empty() {
178                    self_proj.main_branch = other_proj.main_branch;
179                }
180                if !other_proj.worktree_root.trim().is_empty() {
181                    self_proj.worktree_root = other_proj.worktree_root;
182                }
183                if other_proj.remote.is_some() {
184                    self_proj.remote = other_proj.remote;
185                }
186            } else {
187                self.project = Some(other_proj);
188            }
189        }
190
191        for (k, v) in other.ports {
192            self.ports.insert(k, v);
193        }
194
195        if other.hooks.post_start.is_some() {
196            self.hooks.post_start = other.hooks.post_start;
197        }
198        if other.hooks.pre_finish.is_some() {
199            self.hooks.pre_finish = other.hooks.pre_finish;
200        }
201        if other.hooks.post_finish.is_some() {
202            self.hooks.post_finish = other.hooks.post_finish;
203        }
204
205        if other.cache.strategy != CacheStrategy::default() {
206            self.cache.strategy = other.cache.strategy;
207        }
208        if !other.cache.directories.is_empty() {
209            self.cache.directories = other.cache.directories;
210        }
211
212        if !other.files.copy.is_empty() {
213            self.files.copy = other.files.copy;
214        }
215
216        if other.docker.compose_file.is_some() {
217            self.docker.compose_file = other.docker.compose_file;
218        }
219        if other.docker.network.is_some() {
220            self.docker.network = other.docker.network;
221        }
222        if !other.docker.shared_services.is_empty() {
223            self.docker.shared_services = other.docker.shared_services;
224        }
225    }
226
227    /// Parses a TOML string into `Config`, filling missing fields with defaults.
228    pub fn parse_toml_str(toml_str: &str, repo_name: &str) -> Result<Self, ConfigError> {
229        let mut parsed: Config = toml::from_str(toml_str).map_err(|e| ConfigError::ParseError {
230            message: e.to_string(),
231        })?;
232
233        // Ensure project config has deterministic defaults
234        if let Some(ref mut project) = parsed.project {
235            if project.worktree_root.trim().is_empty() {
236                project.worktree_root = default_worktree_root(repo_name);
237            }
238        } else {
239            parsed.project = Some(ProjectConfig {
240                main_branch: "main".to_string(),
241                worktree_root: default_worktree_root(repo_name),
242                remote: None,
243            });
244        }
245
246        Ok(parsed)
247    }
248
249    /// Loads configuration respecting the two-tier hierarchy:
250    /// 1. Hardcoded system defaults.
251    /// 2. Merged with global user config (`~/.git-claw/config.toml` or `GIT_CLAW_GLOBAL_CONFIG`).
252    /// 3. Merged with local repository config (`path`).
253    pub fn load_or_default(path: &Path, repo_name: &str) -> Result<Self, ConfigError> {
254        let mut base = Self::default_for_repo(repo_name);
255
256        // 1. Try loading global configuration
257        if let Some(global_path) = global_config_path() {
258            if global_path.exists() {
259                let global_content =
260                    std::fs::read_to_string(&global_path).map_err(|e| ConfigError::IoError {
261                        path: global_path.to_string_lossy().to_string(),
262                        source: e,
263                    })?;
264                let global_config = Self::parse_toml_str(&global_content, repo_name)?;
265                base.merge_with(global_config);
266            }
267        }
268
269        // 2. Try loading local repository configuration
270        if path.exists() {
271            let local_content =
272                std::fs::read_to_string(path).map_err(|e| ConfigError::IoError {
273                    path: path.to_string_lossy().to_string(),
274                    source: e,
275                })?;
276            let local_config = Self::parse_toml_str(&local_content, repo_name)?;
277            base.merge_with(local_config);
278        }
279
280        Ok(base)
281    }
282}