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
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#[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>, }
76
77fn 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
94pub struct ConfigLoader {
96 config_path: Option<PathBuf>,
97 cached_config: std::cell::RefCell<Option<Config>>,
98}
99
100impl ConfigLoader {
101 pub fn new() -> Self {
103 Self {
104 config_path: None,
105 cached_config: std::cell::RefCell::new(None),
106 }
107 }
108
109 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 pub fn load(&self) -> Result<Config> {
125 if let Some(cached) = self.cached_config.borrow().as_ref() {
127 return Ok(cached.clone());
128 }
129
130 let mut config = Config::default();
132
133 if let Some(toml_config) = self.load_toml_config()? {
135 debug!("Loaded TOML configuration");
136 config = toml_config;
137 }
138
139 self.apply_env_vars(&mut config);
141
142 *self.cached_config.borrow_mut() = Some(config.clone());
146
147 Ok(config)
148 }
149
150 pub fn load_for_repo(&self, repo_path: &Path) -> Result<RepositoryConfig> {
152 let config = self.load()?;
153
154 let repo_config = config
156 .repositories
157 .into_iter()
158 .find(|r| r.path == repo_path)
159 .unwrap_or_else(|| {
160 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 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 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(), })
208 }
209
210 fn load_toml_config(&self) -> Result<Option<Config>> {
212 let config_path = if let Some(path) = &self.config_path {
213 path.clone()
215 } else {
216 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 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 fn apply_env_vars(&self, config: &mut Config) {
245 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 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 if let Ok(remote) = env::var("GIT_SYNC_REMOTE") {
263 debug!("Setting remote from env: {}", remote);
264 config.defaults.remote = remote;
265 }
266
267 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 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, interval: None,
288 });
289 }
290 }
291 }
292}
293
294pub 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}