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)]
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 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 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 pub fn remote(&self) -> Option<&str> {
154 self.project.as_ref().and_then(|p| p.remote.as_deref())
155 }
156
157 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 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 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 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 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 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 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}