Skip to main content

git_same/config/
parser.rs

1//! Configuration file parser.
2//!
3//! Handles loading and parsing of config.toml files.
4
5use crate::errors::AppError;
6use crate::operations::clone::{DEFAULT_CONCURRENCY, MAX_CONCURRENCY};
7use serde::{Deserialize, Serialize};
8use std::path::{Path, PathBuf};
9
10/// Clone-specific configuration options (from config file).
11///
12/// Note: This is distinct from `git::CloneOptions` which is used for
13/// the actual git clone operation parameters.
14#[derive(Debug, Clone, Serialize, Deserialize, Default)]
15pub struct ConfigCloneOptions {
16    /// Shallow clone depth (0 = full history)
17    #[serde(default)]
18    pub depth: u32,
19
20    /// Specific branch to clone (empty = default branch)
21    #[serde(default)]
22    pub branch: String,
23
24    /// Whether to clone submodules
25    #[serde(default)]
26    pub recurse_submodules: bool,
27}
28
29/// Repository filter options.
30#[derive(Debug, Clone, Serialize, Deserialize, Default)]
31pub struct FilterOptions {
32    /// Include archived repositories
33    #[serde(default)]
34    pub include_archived: bool,
35
36    /// Include forked repositories
37    #[serde(default)]
38    pub include_forks: bool,
39
40    /// Filter to specific organizations (empty = all)
41    #[serde(default)]
42    pub orgs: Vec<String>,
43
44    /// Exclude specific repos by full name (e.g., "org/repo")
45    #[serde(default)]
46    pub exclude_repos: Vec<String>,
47}
48
49/// Sync mode for existing repositories.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
51#[serde(rename_all = "kebab-case")]
52pub enum SyncMode {
53    /// Only fetch (safe, doesn't modify working tree)
54    #[default]
55    Fetch,
56    /// Pull changes (modifies working tree)
57    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/// Full application configuration.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct Config {
75    /// Directory structure pattern
76    /// Placeholders: {provider}, {org}, {repo}
77    #[serde(default = "default_structure")]
78    pub structure: String,
79
80    /// Number of parallel operations
81    #[serde(default = "default_concurrency")]
82    pub concurrency: usize,
83
84    /// Sync behavior
85    #[serde(default)]
86    pub sync_mode: SyncMode,
87
88    /// Default workspace path (used when --workspace is not specified and multiple exist)
89    #[serde(default)]
90    pub default_workspace: Option<String>,
91
92    /// Dashboard auto-refresh interval in seconds (5–3600, default 30)
93    #[serde(default = "default_refresh_interval")]
94    pub refresh_interval: u64,
95
96    /// Clone options
97    #[serde(default)]
98    #[serde(rename = "clone")]
99    pub clone: ConfigCloneOptions,
100
101    /// Filter options
102    #[serde(default)]
103    pub filters: FilterOptions,
104
105    /// Registry of known workspace root paths (tilde-collapsed).
106    #[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    /// Returns the default config file path (~/.config/git-same/config.toml).
139    ///
140    /// When `GIT_SAME_CONFIG_DIR` is set to an absolute path, that directory is used instead.
141    /// This allows tests to override config location on Windows (where dirs-sys ignores APPDATA).
142    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    /// Load configuration from the default path, or return defaults if file doesn't exist.
172    pub fn load() -> Result<Self, AppError> {
173        Self::load_from(&Self::default_path()?)
174    }
175
176    /// Load configuration from a specific file, or return defaults if file doesn't exist.
177    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    /// Parse configuration from a TOML string.
188    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    /// Validate the configuration.
196    pub fn validate(&self) -> Result<(), AppError> {
197        // Validate concurrency
198        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        // Validate refresh_interval
206        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    /// Generate the default configuration file content.
216    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    /// Save the default_workspace setting to the config file at the default path.
257    pub fn save_default_workspace(workspace: Option<&str>) -> Result<(), AppError> {
258        Self::save_default_workspace_to(&Self::default_path()?, workspace)
259    }
260
261    /// Save the default_workspace setting to a specific config file.
262    ///
263    /// Uses targeted text replacement to preserve comments and formatting.
264    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        // Replace existing default_workspace line, or insert after sync_mode
283        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                // Insert after sync_mode line
293                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                    // Fallback: insert near the top (after first blank line)
300                    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            // Ensure trailing newline
308            if !result.ends_with('\n') {
309                result.push('\n');
310            }
311            result
312        } else if !new_line.is_empty() {
313            // Insert after sync_mode line
314            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                // Fallback: insert near the top (after first blank line)
322                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            // Nothing to do — clearing a field that doesn't exist
331            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    /// Add a workspace path to the global registry.
340    pub fn add_to_registry(path: &str) -> Result<(), AppError> {
341        Self::add_to_registry_at(&Self::default_path()?, path)
342    }
343
344    /// Add a workspace path to the registry in a specific config file.
345    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    /// Remove a workspace path from the global registry.
355    pub fn remove_from_registry(path: &str) -> Result<(), AppError> {
356        Self::remove_from_registry_at(&Self::default_path()?, path)
357    }
358
359    /// Remove a workspace path from the registry in a specific config file.
360    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    /// Add or remove a path from the workspaces registry in the config file.
368    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;