Skip to main content

bkr/
config.rs

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    /// Find config file in default locations
101    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        // Validate each backup item
139        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            // Validate zstd_level if provided
148            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        // Default credentials path
165        if config.storage.credentials_path.is_none() {
166            config.storage.credentials_path = Some(paths::AWS_CREDENTIALS.to_string());
167        }
168
169        // Default restore config
170        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        // Default restore path
177        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        // Default restore bucket_prefix (empty = restore from root)
184        if restore.bucket_prefix.is_none() {
185            restore.bucket_prefix = Some(String::new());
186        }
187
188        // Resolve all paths
189        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            // Default zstd_level
201            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
228/// Substitute environment variables in the format ${VAR_NAME} or $VAR_NAME
229fn substitute_env_vars(content: &str) -> String {
230    let mut result = content.to_string();
231
232    // Replace ${VAR_NAME} format
233    let re_braces = regex::Regex::new(r"\$\{([^}]+)\}").unwrap();
234    result = re_braces
235        .replace_all(&result, |caps: &regex::Captures| {
236            let var_name = &caps[1];
237            env::var(var_name).unwrap_or_default()
238        })
239        .to_string();
240
241    // Replace $VAR_NAME format (word boundaries)
242    let re_dollar = regex::Regex::new(r"\$([A-Z_][A-Z0-9_]*)").unwrap();
243    result = re_dollar
244        .replace_all(&result, |caps: &regex::Captures| {
245            let var_name = &caps[1];
246            env::var(var_name).unwrap_or_default()
247        })
248        .to_string();
249
250    result
251}
252
253/// Resolve a path, expanding ~ to home directory
254pub fn resolve_path<P: AsRef<Path>>(path: P) -> PathBuf {
255    let path_str = path.as_ref().to_string_lossy();
256
257    // Use shellexpand for ~ expansion
258    let expanded = shellexpand::tilde(&path_str);
259
260    // Resolve to absolute path
261    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
271/// Get the original (unexpanded) backup items from config file
272pub 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}