1use crate::constants::{defaults, paths, zstd};
2use crate::error::{BakerError, Result};
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::env;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone, Deserialize, Serialize)]
10pub struct BackupItem {
11 pub path: String,
12 #[serde(default)]
13 pub ignore: Vec<String>,
14 pub destination_prefix: Option<String>,
15 #[serde(default)]
16 pub zstd: Vec<String>,
17 pub zstd_level: Option<i32>,
18}
19
20#[derive(Debug, Clone, Deserialize, Serialize)]
21pub struct BucketConfig {
22 pub name: String,
23 pub prefix: Option<String>,
24}
25
26#[derive(Debug, Clone, Deserialize, Serialize, Default)]
27pub struct RestoreConfig {
28 pub path: Option<String>,
29 pub bucket_prefix: Option<String>,
30 #[serde(default)]
31 pub ignore: Vec<String>,
32}
33
34#[derive(Debug, Clone, Deserialize, Serialize)]
35pub struct StorageConfig {
36 pub profile: String,
37 pub region: Option<String>,
38 pub credentials_path: Option<String>,
39 pub bucket: BucketConfig,
40 #[serde(default)]
41 pub restore: Option<RestoreConfig>,
42}
43
44#[derive(Debug, Clone, Deserialize, Serialize)]
45pub struct DaemonSchedule {
46 pub every: String,
47 #[serde(default)]
48 pub exercise_restore: bool,
49}
50
51#[derive(Debug, Clone, Deserialize, Serialize)]
52pub struct BackupConfig {
53 pub daemon_schedule: Option<DaemonSchedule>,
54 pub storage: StorageConfig,
55 pub to_backup: HashMap<String, BackupItem>,
56}
57
58pub struct ConfigParser {
59 config: BackupConfig,
60 config_path: Option<PathBuf>,
61}
62
63impl ConfigParser {
64 pub fn new(config_path: Option<&str>) -> Result<Self> {
65 let resolved_path = if let Some(path) = config_path {
66 let p = resolve_path(path);
67 if p.exists() {
68 Some(p)
69 } else {
70 return Err(BakerError::ConfigNotFound(vec![path.to_string()]));
71 }
72 } else {
73 Self::find_config_file(None)
74 };
75
76 let resolved_path = resolved_path.ok_or_else(|| {
77 BakerError::ConfigNotFound(
78 paths::CONFIG_SEARCH
79 .iter()
80 .map(|s| s.to_string())
81 .collect(),
82 )
83 })?;
84
85 println!("Loading config from: {}", resolved_path.display());
86
87 let file_content = fs::read_to_string(&resolved_path)?;
88 let substituted_content = substitute_env_vars(&file_content);
89 let mut config: BackupConfig = serde_yaml::from_str(&substituted_content)?;
90
91 Self::validate(&config)?;
92 Self::apply_defaults(&mut config);
93
94 Ok(Self {
95 config,
96 config_path: Some(resolved_path),
97 })
98 }
99
100 pub fn find_config_file(config_path: Option<&str>) -> Option<PathBuf> {
102 if let Some(path) = config_path {
103 let resolved = resolve_path(path);
104 if resolved.exists() {
105 return Some(resolved);
106 }
107 return None;
108 }
109
110 for path in paths::CONFIG_SEARCH {
111 let resolved = resolve_path(path);
112 if resolved.exists() {
113 return Some(resolved);
114 }
115 }
116 None
117 }
118
119 fn validate(config: &BackupConfig) -> Result<()> {
120 if config.storage.profile.is_empty() {
121 return Err(BakerError::Config(
122 "Missing required field: storage.profile".to_string(),
123 ));
124 }
125
126 if config.storage.bucket.name.is_empty() {
127 return Err(BakerError::Config(
128 "Missing required field: storage.bucket.name".to_string(),
129 ));
130 }
131
132 if config.to_backup.is_empty() {
133 return Err(BakerError::Config(
134 "to_backup must contain at least one item".to_string(),
135 ));
136 }
137
138 for (alias, item) in &config.to_backup {
140 if item.path.is_empty() {
141 return Err(BakerError::Config(format!(
142 "Missing path for backup item: {}",
143 alias
144 )));
145 }
146
147 if let Some(level) = item.zstd_level {
149 if level < zstd::MIN_LEVEL || level > zstd::MAX_LEVEL {
150 return Err(BakerError::Config(format!(
151 "Invalid zstd_level for {}: must be between {} and {}",
152 alias,
153 zstd::MIN_LEVEL,
154 zstd::MAX_LEVEL
155 )));
156 }
157 }
158 }
159
160 Ok(())
161 }
162
163 fn apply_defaults(config: &mut BackupConfig) {
164 if config.storage.credentials_path.is_none() {
166 config.storage.credentials_path = Some(paths::AWS_CREDENTIALS.to_string());
167 }
168
169 if config.storage.restore.is_none() {
171 config.storage.restore = Some(RestoreConfig::default());
172 }
173
174 let restore = config.storage.restore.as_mut().unwrap();
175
176 if restore.path.is_none() {
178 let bucket_name = &config.storage.bucket.name;
179 let bucket_prefix = config.storage.bucket.prefix.as_deref().unwrap_or("");
180 restore.path = Some(format!("~/.backups/{}/{}", bucket_name, bucket_prefix));
181 }
182
183 if restore.bucket_prefix.is_none() {
185 restore.bucket_prefix = Some(String::new());
186 }
187
188 if let Some(ref creds_path) = config.storage.credentials_path {
190 config.storage.credentials_path = Some(resolve_path(creds_path).to_string_lossy().to_string());
191 }
192
193 if let Some(ref restore_path) = restore.path {
194 restore.path = Some(resolve_path(restore_path).to_string_lossy().to_string());
195 }
196
197 for item in config.to_backup.values_mut() {
198 item.path = resolve_path(&item.path).to_string_lossy().to_string();
199
200 if !item.zstd.is_empty() && item.zstd_level.is_none() {
202 item.zstd_level = Some(defaults::ZSTD_LEVEL);
203 }
204 }
205 }
206
207 pub fn get_config(&self) -> &BackupConfig {
208 &self.config
209 }
210
211 pub fn get_storage_config(&self) -> &StorageConfig {
212 &self.config.storage
213 }
214
215 pub fn get_backup_items(&self) -> &HashMap<String, BackupItem> {
216 &self.config.to_backup
217 }
218
219 pub fn get_config_path(&self) -> Option<&Path> {
220 self.config_path.as_deref()
221 }
222
223 pub fn get_default_config_paths() -> Vec<&'static str> {
224 paths::CONFIG_SEARCH.to_vec()
225 }
226}
227
228fn substitute_env_vars(content: &str) -> String {
230 let mut result = content.to_string();
231
232 let re_braces = regex::Regex::new(r"\$\{([^}]+)\}").unwrap();
234 result = re_braces
235 .replace_all(&result, |caps: ®ex::Captures| {
236 let var_name = &caps[1];
237 env::var(var_name).unwrap_or_default()
238 })
239 .to_string();
240
241 let re_dollar = regex::Regex::new(r"\$([A-Z_][A-Z0-9_]*)").unwrap();
243 result = re_dollar
244 .replace_all(&result, |caps: ®ex::Captures| {
245 let var_name = &caps[1];
246 env::var(var_name).unwrap_or_default()
247 })
248 .to_string();
249
250 result
251}
252
253pub fn resolve_path<P: AsRef<Path>>(path: P) -> PathBuf {
255 let path_str = path.as_ref().to_string_lossy();
256
257 let expanded = shellexpand::tilde(&path_str);
259
260 let expanded_path = PathBuf::from(expanded.as_ref());
262 if expanded_path.is_absolute() {
263 expanded_path
264 } else {
265 std::env::current_dir()
266 .unwrap_or_default()
267 .join(expanded_path)
268 }
269}
270
271pub fn get_original_backup_items(config_path: Option<&str>) -> HashMap<String, BackupItem> {
273 let resolved_path = ConfigParser::find_config_file(config_path);
274
275 let Some(resolved_path) = resolved_path else {
276 return HashMap::new();
277 };
278
279 if !resolved_path.exists() {
280 return HashMap::new();
281 }
282
283 let Ok(file_content) = fs::read_to_string(&resolved_path) else {
284 return HashMap::new();
285 };
286
287 let Ok(raw_config) = serde_yaml::from_str::<serde_yaml::Value>(&file_content) else {
288 return HashMap::new();
289 };
290
291 if let Some(to_backup) = raw_config.get("to_backup") {
292 serde_yaml::from_value(to_backup.clone()).unwrap_or_default()
293 } else {
294 HashMap::new()
295 }
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 #[test]
303 fn test_substitute_env_vars() {
304 env::set_var("TEST_VAR", "test_value");
305 let content = "path: ${TEST_VAR}/file";
306 let result = substitute_env_vars(content);
307 assert_eq!(result, "path: test_value/file");
308 env::remove_var("TEST_VAR");
309 }
310
311 #[test]
312 fn test_resolve_path_tilde() {
313 let path = resolve_path("~/test");
314 assert!(path.to_string_lossy().contains("test"));
315 assert!(!path.to_string_lossy().starts_with("~"));
316 }
317}