Skip to main content

git_sync_rs/
config.rs

1use crate::error::{Result, SyncError};
2use directories::ProjectDirs;
3use serde::{Deserialize, Serialize};
4use std::env;
5use std::fs;
6use std::path::{Path, PathBuf};
7use tracing::{debug, info};
8
9/// Complete configuration for git-sync-rs
10#[derive(Debug, Clone, Default, Deserialize, Serialize)]
11pub struct Config {
12    #[serde(default)]
13    pub defaults: DefaultConfig,
14
15    #[serde(default)]
16    pub repositories: Vec<RepositoryConfig>,
17}
18
19/// Default configuration values
20#[derive(Debug, Clone, Deserialize, Serialize)]
21pub struct DefaultConfig {
22    #[serde(default = "default_sync_interval")]
23    pub sync_interval: u64, // seconds
24
25    #[serde(default = "default_sync_new_files")]
26    pub sync_new_files: bool,
27
28    #[serde(default)]
29    pub skip_hooks: bool,
30
31    #[serde(default = "default_commit_message")]
32    pub commit_message: String,
33
34    #[serde(default = "default_remote")]
35    pub remote: String,
36
37    /// When true, create a fallback branch on merge conflicts instead of failing
38    #[serde(default)]
39    pub conflict_branch: bool,
40}
41
42impl Default for DefaultConfig {
43    fn default() -> Self {
44        Self {
45            sync_interval: default_sync_interval(),
46            sync_new_files: default_sync_new_files(),
47            skip_hooks: false,
48            commit_message: default_commit_message(),
49            remote: default_remote(),
50            conflict_branch: false,
51        }
52    }
53}
54
55/// Repository-specific configuration
56#[derive(Debug, Clone, Deserialize, Serialize)]
57pub struct RepositoryConfig {
58    pub path: PathBuf,
59
60    #[serde(default)]
61    pub sync_new_files: Option<bool>,
62
63    #[serde(default)]
64    pub skip_hooks: Option<bool>,
65
66    #[serde(default)]
67    pub commit_message: Option<String>,
68
69    #[serde(default)]
70    pub remote: Option<String>,
71
72    #[serde(default)]
73    pub branch: Option<String>,
74
75    #[serde(default)]
76    pub watch: bool,
77
78    #[serde(default)]
79    pub interval: Option<u64>, // seconds
80
81    /// When true, create a fallback branch on merge conflicts instead of failing
82    #[serde(default)]
83    pub conflict_branch: Option<bool>,
84}
85
86// Default value functions for serde
87fn default_sync_interval() -> u64 {
88    60
89}
90
91fn default_sync_new_files() -> bool {
92    true
93}
94
95fn default_commit_message() -> String {
96    "changes from {hostname} on {timestamp}".to_string()
97}
98
99fn default_remote() -> String {
100    "origin".to_string()
101}
102
103/// Configuration loader that merges multiple sources with correct precedence
104pub struct ConfigLoader {
105    config_path: Option<PathBuf>,
106    cached_config: std::cell::RefCell<Option<Config>>,
107}
108
109impl ConfigLoader {
110    /// Create a new config loader
111    pub fn new() -> Self {
112        Self {
113            config_path: None,
114            cached_config: std::cell::RefCell::new(None),
115        }
116    }
117
118    /// Set explicit config file path
119    pub fn with_config_path(mut self, path: impl AsRef<Path>) -> Self {
120        self.config_path = Some(path.as_ref().to_path_buf());
121        self
122    }
123}
124
125impl Default for ConfigLoader {
126    fn default() -> Self {
127        Self::new()
128    }
129}
130
131impl ConfigLoader {
132    /// Load configuration from all sources and merge with correct precedence
133    pub fn load(&self) -> Result<Config> {
134        // Check cache first
135        if let Some(cached) = self.cached_config.borrow().as_ref() {
136            return Ok(cached.clone());
137        }
138
139        // Start with defaults
140        let mut config = Config::default();
141
142        // Layer 1: Load from TOML config file (lowest priority)
143        if let Some(toml_config) = self.load_toml_config()? {
144            debug!("Loaded TOML configuration");
145            config = toml_config;
146        }
147
148        // Layer 2: Apply environment variables (medium priority)
149        self.apply_env_vars(&mut config);
150
151        // Note: Command-line args are applied in main.rs (highest priority)
152
153        // Cache the result
154        *self.cached_config.borrow_mut() = Some(config.clone());
155
156        Ok(config)
157    }
158
159    /// Load repository-specific config for a given path
160    pub fn load_for_repo(&self, repo_path: &Path) -> Result<RepositoryConfig> {
161        let config = self.load()?;
162
163        // Find matching repository config
164        let repo_config = config
165            .repositories
166            .into_iter()
167            .find(|r| r.path == repo_path)
168            .unwrap_or_else(|| {
169                // Create default config for this repo
170                RepositoryConfig {
171                    path: repo_path.to_path_buf(),
172                    sync_new_files: None,
173                    skip_hooks: None,
174                    commit_message: None,
175                    remote: None,
176                    branch: None,
177                    watch: false,
178                    interval: None,
179                    conflict_branch: None,
180                }
181            });
182
183        Ok(repo_config)
184    }
185
186    /// Convert to SyncConfig for the synchronizer
187    pub fn to_sync_config(
188        &self,
189        repo_path: &Path,
190        cli_new_files: Option<bool>,
191        cli_remote: Option<String>,
192    ) -> Result<crate::sync::SyncConfig> {
193        let config = self.load()?;
194        let repo_config = self.load_for_repo(repo_path)?;
195
196        // Merge with precedence: CLI > env > repo config > defaults
197        Ok(crate::sync::SyncConfig {
198            sync_new_files: cli_new_files
199                .or(env::var("GIT_SYNC_NEW_FILES")
200                    .ok()
201                    .and_then(|v| v.parse().ok()))
202                .or(repo_config.sync_new_files)
203                .unwrap_or(config.defaults.sync_new_files),
204
205            skip_hooks: repo_config.skip_hooks.unwrap_or(config.defaults.skip_hooks),
206
207            commit_message: repo_config
208                .commit_message
209                .or(Some(config.defaults.commit_message)),
210
211            remote_name: cli_remote
212                .or(env::var("GIT_SYNC_REMOTE").ok())
213                .or(repo_config.remote)
214                .unwrap_or(config.defaults.remote),
215
216            branch_name: repo_config.branch.clone().unwrap_or_default(), // Will be auto-detected
217
218            conflict_branch: repo_config
219                .conflict_branch
220                .unwrap_or(config.defaults.conflict_branch),
221
222            target_branch: repo_config.branch, // The branch we want to track
223        })
224    }
225
226    /// Load TOML configuration file
227    fn load_toml_config(&self) -> Result<Option<Config>> {
228        let config_path = if let Some(path) = &self.config_path {
229            // Use explicit path
230            path.clone()
231        } else {
232            // Use default XDG path
233            let project_dirs = ProjectDirs::from("", "", "git-sync-rs").ok_or_else(|| {
234                SyncError::Other("Could not determine config directory".to_string())
235            })?;
236
237            project_dirs.config_dir().join("config.toml")
238        };
239
240        if !config_path.exists() {
241            debug!("Config file not found at {:?}", config_path);
242            return Ok(None);
243        }
244
245        info!("Loading config from {:?}", config_path);
246        let contents = fs::read_to_string(&config_path)?;
247        let mut config: Config = toml::from_str(&contents)
248            .map_err(|e| SyncError::Other(format!("Failed to parse config: {}", e)))?;
249
250        // Expand tildes in repository paths
251        for repo in &mut config.repositories {
252            let expanded = shellexpand::tilde(&repo.path.to_string_lossy()).to_string();
253            repo.path = PathBuf::from(expanded);
254        }
255
256        Ok(Some(config))
257    }
258
259    /// Apply environment variables to config
260    fn apply_env_vars(&self, config: &mut Config) {
261        // GIT_SYNC_INTERVAL
262        if let Ok(interval) = env::var("GIT_SYNC_INTERVAL") {
263            if let Ok(secs) = interval.parse::<u64>() {
264                debug!("Setting sync interval from env: {}s", secs);
265                config.defaults.sync_interval = secs;
266            }
267        }
268
269        // GIT_SYNC_NEW_FILES
270        if let Ok(new_files) = env::var("GIT_SYNC_NEW_FILES") {
271            if let Ok(enabled) = new_files.parse::<bool>() {
272                debug!("Setting sync_new_files from env: {}", enabled);
273                config.defaults.sync_new_files = enabled;
274            }
275        }
276
277        // GIT_SYNC_REMOTE
278        if let Ok(remote) = env::var("GIT_SYNC_REMOTE") {
279            debug!("Setting remote from env: {}", remote);
280            config.defaults.remote = remote;
281        }
282
283        // GIT_SYNC_COMMIT_MESSAGE
284        if let Ok(msg) = env::var("GIT_SYNC_COMMIT_MESSAGE") {
285            debug!("Setting commit message from env");
286            config.defaults.commit_message = msg;
287        }
288
289        // GIT_SYNC_DIRECTORY - add as a repository if not already configured
290        if let Ok(dir) = env::var("GIT_SYNC_DIRECTORY") {
291            let expanded = shellexpand::tilde(&dir).to_string();
292            let path = PathBuf::from(expanded);
293            if !config.repositories.iter().any(|r| r.path == path) {
294                debug!("Adding repository from GIT_SYNC_DIRECTORY env: {:?}", path);
295                config.repositories.push(RepositoryConfig {
296                    path,
297                    sync_new_files: None,
298                    skip_hooks: None,
299                    commit_message: None,
300                    remote: None,
301                    branch: None,
302                    watch: true, // Assume watch mode when using env var
303                    interval: None,
304                    conflict_branch: None,
305                });
306            }
307        }
308    }
309}
310
311/// Create an example config file
312pub fn create_example_config() -> String {
313    r#"# git-sync-rs configuration file
314
315[defaults]
316# Default sync interval in seconds (for watch mode)
317sync_interval = 60
318
319# Whether to sync untracked files by default
320sync_new_files = true
321
322# Skip git hooks when committing
323skip_hooks = false
324
325# Commit message template
326# Available placeholders: {hostname}, {timestamp}
327# {timestamp} format: YYYY-MM-DD HH:MM:SS AM/PM TZ (e.g., 2024-03-15 02:30:45 PM PST)
328commit_message = "changes from {hostname} on {timestamp}"
329
330# Default remote name
331remote = "origin"
332
333# Create fallback branch on merge conflicts instead of failing
334# When enabled, conflicts cause git-sync to switch to a branch like
335# git-sync/{hostname}-{timestamp} and continue syncing there.
336# It will automatically return to the target branch when possible.
337conflict_branch = false
338
339# Example repository configurations
340[[repositories]]
341path = "/home/user/notes"
342sync_new_files = true
343remote = "origin"
344branch = "main"
345watch = true
346interval = 30  # Override sync interval for this repo
347conflict_branch = true  # Enable fallback branch for this repo
348
349[[repositories]]
350path = "/home/user/dotfiles"
351sync_new_files = false
352watch = true
353# Uses defaults for other settings
354"#
355    .to_string()
356}