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#[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#[derive(Debug, Clone, Deserialize, Serialize)]
21pub struct DefaultConfig {
22 #[serde(default = "default_sync_interval")]
23 pub sync_interval: u64, #[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 #[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#[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>, #[serde(default)]
83 pub conflict_branch: Option<bool>,
84}
85
86fn 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
103pub struct ConfigLoader {
105 config_path: Option<PathBuf>,
106 cached_config: std::cell::RefCell<Option<Config>>,
107}
108
109impl ConfigLoader {
110 pub fn new() -> Self {
112 Self {
113 config_path: None,
114 cached_config: std::cell::RefCell::new(None),
115 }
116 }
117
118 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 pub fn load(&self) -> Result<Config> {
134 if let Some(cached) = self.cached_config.borrow().as_ref() {
136 return Ok(cached.clone());
137 }
138
139 let mut config = Config::default();
141
142 if let Some(toml_config) = self.load_toml_config()? {
144 debug!("Loaded TOML configuration");
145 config = toml_config;
146 }
147
148 self.apply_env_vars(&mut config);
150
151 *self.cached_config.borrow_mut() = Some(config.clone());
155
156 Ok(config)
157 }
158
159 pub fn load_for_repo(&self, repo_path: &Path) -> Result<RepositoryConfig> {
161 let config = self.load()?;
162
163 let repo_config = config
165 .repositories
166 .into_iter()
167 .find(|r| r.path == repo_path)
168 .unwrap_or_else(|| {
169 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 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 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(), conflict_branch: repo_config
219 .conflict_branch
220 .unwrap_or(config.defaults.conflict_branch),
221
222 target_branch: repo_config.branch, })
224 }
225
226 fn load_toml_config(&self) -> Result<Option<Config>> {
228 let config_path = if let Some(path) = &self.config_path {
229 path.clone()
231 } else {
232 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 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 fn apply_env_vars(&self, config: &mut Config) {
261 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 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 if let Ok(remote) = env::var("GIT_SYNC_REMOTE") {
279 debug!("Setting remote from env: {}", remote);
280 config.defaults.remote = remote;
281 }
282
283 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 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, interval: None,
304 conflict_branch: None,
305 });
306 }
307 }
308 }
309}
310
311pub 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}