1use crate::errors::AppError;
6use crate::operations::clone::{DEFAULT_CONCURRENCY, MAX_CONCURRENCY};
7use serde::{Deserialize, Serialize};
8use std::path::{Path, PathBuf};
9
10#[derive(Debug, Clone, Serialize, Deserialize, Default)]
15pub struct ConfigCloneOptions {
16 #[serde(default)]
18 pub depth: u32,
19
20 #[serde(default)]
22 pub branch: String,
23
24 #[serde(default)]
26 pub recurse_submodules: bool,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, Default)]
31pub struct FilterOptions {
32 #[serde(default)]
34 pub include_archived: bool,
35
36 #[serde(default)]
38 pub include_forks: bool,
39
40 #[serde(default)]
42 pub orgs: Vec<String>,
43
44 #[serde(default)]
46 pub exclude_repos: Vec<String>,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
51#[serde(rename_all = "kebab-case")]
52pub enum SyncMode {
53 #[default]
55 Fetch,
56 Pull,
58}
59
60impl std::str::FromStr for SyncMode {
61 type Err = String;
62
63 fn from_str(s: &str) -> Result<Self, Self::Err> {
64 match s.to_lowercase().as_str() {
65 "fetch" => Ok(SyncMode::Fetch),
66 "pull" => Ok(SyncMode::Pull),
67 _ => Err(format!("Invalid sync mode: '{}'. Use 'fetch' or 'pull'", s)),
68 }
69 }
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct Config {
75 #[serde(default = "default_structure")]
78 pub structure: String,
79
80 #[serde(default = "default_concurrency")]
82 pub concurrency: usize,
83
84 #[serde(default)]
86 pub sync_mode: SyncMode,
87
88 #[serde(default)]
90 pub default_workspace: Option<String>,
91
92 #[serde(default = "default_refresh_interval")]
94 pub refresh_interval: u64,
95
96 #[serde(default)]
98 #[serde(rename = "clone")]
99 pub clone: ConfigCloneOptions,
100
101 #[serde(default)]
103 pub filters: FilterOptions,
104
105 #[serde(default)]
107 pub workspaces: Vec<String>,
108}
109
110fn default_structure() -> String {
111 "{org}/{repo}".to_string()
112}
113
114fn default_concurrency() -> usize {
115 DEFAULT_CONCURRENCY
116}
117
118fn default_refresh_interval() -> u64 {
119 30
120}
121
122impl Default for Config {
123 fn default() -> Self {
124 Self {
125 structure: default_structure(),
126 concurrency: default_concurrency(),
127 sync_mode: SyncMode::default(),
128 default_workspace: None,
129 refresh_interval: default_refresh_interval(),
130 clone: ConfigCloneOptions::default(),
131 filters: FilterOptions::default(),
132 workspaces: Vec::new(),
133 }
134 }
135}
136
137impl Config {
138 pub fn default_path() -> Result<PathBuf, AppError> {
143 if let Ok(override_dir) = std::env::var("GIT_SAME_CONFIG_DIR") {
144 let dir = PathBuf::from(&override_dir);
145 if dir.is_absolute() {
146 return Ok(dir.join("config.toml"));
147 }
148 }
149
150 #[cfg(target_os = "macos")]
151 let config_dir = {
152 let home = std::env::var("HOME")
153 .map_err(|_| AppError::config("HOME environment variable not set"))?;
154 PathBuf::from(home).join(".config/git-same")
155 };
156 #[cfg(not(target_os = "macos"))]
157 let config_dir = if let Some(dir) = directories::ProjectDirs::from("", "", "git-same") {
158 dir.config_dir().to_path_buf()
159 } else {
160 let home = std::env::var("HOME")
161 .or_else(|_| std::env::var("USERPROFILE"))
162 .map_err(|_| {
163 AppError::config("Neither HOME nor USERPROFILE environment variable is set")
164 })?;
165 PathBuf::from(home).join(".config/git-same")
166 };
167
168 Ok(config_dir.join("config.toml"))
169 }
170
171 pub fn load() -> Result<Self, AppError> {
173 Self::load_from(&Self::default_path()?)
174 }
175
176 pub fn load_from(path: &Path) -> Result<Self, AppError> {
178 if path.exists() {
179 let content = std::fs::read_to_string(path)
180 .map_err(|e| AppError::config(format!("Failed to read config file: {}", e)))?;
181 Self::parse(&content)
182 } else {
183 Ok(Config::default())
184 }
185 }
186
187 pub fn parse(content: &str) -> Result<Self, AppError> {
189 let config: Config = toml::from_str(content)
190 .map_err(|e| AppError::config(format!("Failed to parse config: {}", e)))?;
191 config.validate()?;
192 Ok(config)
193 }
194
195 pub fn validate(&self) -> Result<(), AppError> {
197 if !(1..=MAX_CONCURRENCY).contains(&self.concurrency) {
199 return Err(AppError::config(format!(
200 "concurrency must be between 1 and {}",
201 MAX_CONCURRENCY
202 )));
203 }
204
205 if !(5..=3600).contains(&self.refresh_interval) {
207 return Err(AppError::config(
208 "refresh_interval must be between 5 and 3600 seconds",
209 ));
210 }
211
212 Ok(())
213 }
214
215 pub fn default_toml() -> String {
217 format!(
218 r#"# Git-Same Configuration
219# See: https://github.com/zaai-com/git-same
220
221# Directory structure pattern
222# Placeholders: {{provider}}, {{org}}, {{repo}}
223structure = "{{org}}/{{repo}}"
224
225# Number of parallel clone/sync operations (1-{})
226# Keeping this bounded helps avoid provider rate limits and local resource contention.
227concurrency = {}
228
229# Sync behavior: "fetch" (safe) or "pull" (updates working tree)
230sync_mode = "fetch""#,
231 MAX_CONCURRENCY, DEFAULT_CONCURRENCY
232 ) + r#"
233
234[clone]
235# Clone depth (0 = full history)
236depth = 0
237
238# Clone submodules
239recurse_submodules = false
240
241[filters]
242# Include archived repositories
243include_archived = false
244
245# Include forked repositories
246include_forks = false
247
248# Filter to specific organizations (empty = all)
249# orgs = ["my-org", "other-org"]
250
251# Exclude specific repos
252# exclude_repos = ["org/repo-to-skip"]
253"#
254 }
255
256 pub fn save_default_workspace(workspace: Option<&str>) -> Result<(), AppError> {
258 Self::save_default_workspace_to(&Self::default_path()?, workspace)
259 }
260
261 pub fn save_default_workspace_to(path: &Path, workspace: Option<&str>) -> Result<(), AppError> {
265 let content = if path.exists() {
266 std::fs::read_to_string(path)
267 .map_err(|e| AppError::config(format!("Failed to read config: {}", e)))?
268 } else {
269 return Err(AppError::config(
270 "Config file not found. Run 'gisa init' first.",
271 ));
272 };
273
274 let new_line = match workspace {
275 Some(name) => {
276 let escaped = toml::Value::String(name.to_string()).to_string();
277 format!("default_workspace = {}", escaped)
278 }
279 None => String::new(),
280 };
281
282 let new_content = if content.contains("default_workspace") {
284 let mut lines: Vec<&str> = content.lines().collect();
285 lines.retain(|line| {
286 let trimmed = line.trim();
287 !trimmed.starts_with("default_workspace")
288 && !trimmed.starts_with("# default_workspace")
289 });
290 let mut result = lines.join("\n");
291 if !new_line.is_empty() {
292 if let Some(pos) = result.find("sync_mode") {
294 if let Some(nl) = result[pos..].find('\n') {
295 let insert_pos = pos + nl + 1;
296 result.insert_str(insert_pos, &format!("{}\n", new_line));
297 }
298 } else {
299 if let Some(pos) = result.find("\n\n") {
301 result.insert_str(pos + 1, &format!("\n{}\n", new_line));
302 } else {
303 result = format!("{}\n{}\n", new_line, result);
304 }
305 }
306 }
307 if !result.ends_with('\n') {
309 result.push('\n');
310 }
311 result
312 } else if !new_line.is_empty() {
313 let mut result = content.clone();
315 if let Some(pos) = result.find("sync_mode") {
316 if let Some(nl) = result[pos..].find('\n') {
317 let insert_pos = pos + nl + 1;
318 result.insert_str(insert_pos, &format!("\n{}\n", new_line));
319 }
320 } else {
321 if let Some(pos) = result.find("\n\n") {
323 result.insert_str(pos + 1, &format!("\n{}\n", new_line));
324 } else {
325 result = format!("{}\n{}\n", new_line, result);
326 }
327 }
328 result
329 } else {
330 content
332 };
333
334 std::fs::write(path, new_content)
335 .map_err(|e| AppError::config(format!("Failed to write config: {}", e)))?;
336 Ok(())
337 }
338
339 pub fn add_to_registry(path: &str) -> Result<(), AppError> {
341 Self::add_to_registry_at(&Self::default_path()?, path)
342 }
343
344 pub fn add_to_registry_at(config_path: &Path, path: &str) -> Result<(), AppError> {
346 if !config_path.exists() {
347 return Err(AppError::config(
348 "Config file not found. Run 'gisa init' first.",
349 ));
350 }
351 Self::modify_registry_at(config_path, Some(path), None)
352 }
353
354 pub fn remove_from_registry(path: &str) -> Result<(), AppError> {
356 Self::remove_from_registry_at(&Self::default_path()?, path)
357 }
358
359 pub fn remove_from_registry_at(config_path: &Path, path: &str) -> Result<(), AppError> {
361 if !config_path.exists() {
362 return Ok(());
363 }
364 Self::modify_registry_at(config_path, None, Some(path))
365 }
366
367 fn modify_registry_at(
369 config_path: &Path,
370 add: Option<&str>,
371 remove: Option<&str>,
372 ) -> Result<(), AppError> {
373 let content = std::fs::read_to_string(config_path)
374 .map_err(|e| AppError::config(format!("Failed to read config: {}", e)))?;
375
376 let mut doc: toml::Value = toml::from_str(&content)
377 .map_err(|e| AppError::config(format!("Failed to parse config: {}", e)))?;
378
379 let table = doc
380 .as_table_mut()
381 .ok_or_else(|| AppError::config("Invalid config: expected root table"))?;
382
383 if let Some(existing) = table.get("workspaces") {
384 if !existing.is_array() {
385 return Err(AppError::config(
386 "Invalid config: 'workspaces' must be an array",
387 ));
388 }
389 }
390
391 let workspaces = table
392 .entry("workspaces")
393 .or_insert_with(|| toml::Value::Array(Vec::new()));
394 let arr = workspaces
395 .as_array_mut()
396 .ok_or_else(|| AppError::config("Invalid config: 'workspaces' must be an array"))?;
397
398 if let Some(path_to_add) = add {
399 let val = toml::Value::String(path_to_add.to_string());
400 if !arr.contains(&val) {
401 arr.push(val);
402 }
403 }
404 if let Some(path_to_remove) = remove {
405 arr.retain(|v| v.as_str().map(|s| s != path_to_remove).unwrap_or(true));
406 }
407
408 let new_content = toml::to_string_pretty(&doc)
409 .map_err(|e| AppError::config(format!("Failed to serialize config: {}", e)))?;
410
411 std::fs::write(config_path, new_content)
412 .map_err(|e| AppError::config(format!("Failed to write config: {}", e)))?;
413
414 Ok(())
415 }
416}
417
418#[cfg(test)]
419#[path = "parser_tests.rs"]
420mod tests;