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