Skip to main content

mdvault_core/config/
loader.rs

1use crate::config::types::{
2    ActivityConfig, ConfigFile, LoggingConfig, Profile, ResolvedConfig, SecurityPolicy,
3};
4use shellexpand::full;
5use std::path::{Path, PathBuf};
6use std::{env, fs};
7
8use dirs::home_dir;
9use thiserror::Error;
10
11#[derive(Debug, Error)]
12pub enum ConfigError {
13    #[error("config file not found at {0}")]
14    NotFound(String),
15
16    #[error("failed to read config file {0}: {1}")]
17    ReadError(String, #[source] std::io::Error),
18
19    #[error("failed to parse TOML in {0}: {1}")]
20    ParseError(String, #[source] toml::de::Error),
21
22    #[error("profile '{0}' not found")]
23    ProfileNotFound(String),
24
25    #[error("no profiles defined in config")]
26    NoProfiles,
27
28    #[error("version {0} is unsupported (expected 1)")]
29    BadVersion(u32),
30
31    #[error("home directory not available to expand '~'")]
32    NoHome,
33}
34
35pub struct ConfigLoader;
36
37impl ConfigLoader {
38    pub fn load(
39        config_path: Option<&Path>,
40        profile_override: Option<&str>,
41    ) -> Result<ResolvedConfig, ConfigError> {
42        let path = match config_path {
43            Some(p) => p.to_path_buf(),
44            None => default_config_path(),
45        };
46
47        if !path.exists() {
48            return Err(ConfigError::NotFound(path.display().to_string()));
49        }
50
51        let s = fs::read_to_string(&path)
52            .map_err(|e| ConfigError::ReadError(path.display().to_string(), e))?;
53
54        let cf: ConfigFile = toml::from_str(&s)
55            .map_err(|e| ConfigError::ParseError(path.display().to_string(), e))?;
56
57        if cf.version != 1 {
58            return Err(ConfigError::BadVersion(cf.version));
59        }
60        if cf.profiles.is_empty() {
61            return Err(ConfigError::NoProfiles);
62        }
63
64        let active = profile_override
65            .map(ToOwned::to_owned)
66            .or(cf.profile.clone())
67            .unwrap_or_else(|| "default".to_string());
68
69        let prof = cf
70            .profiles
71            .get(&active)
72            .ok_or_else(|| ConfigError::ProfileNotFound(active.clone()))?;
73
74        // Compute config directory for typedefs fallback resolution
75        let config_dir = path
76            .parent()
77            .map(|p| p.to_path_buf())
78            .unwrap_or_else(default_config_dir);
79
80        let resolved = Self::resolve_profile(
81            &active,
82            prof,
83            &cf.security,
84            &cf.logging,
85            &cf.activity,
86            &config_dir,
87        )?;
88        Ok(resolved)
89    }
90
91    fn resolve_profile(
92        active: &str,
93        prof: &Profile,
94        sec: &SecurityPolicy,
95        log_cfg: &LoggingConfig,
96        activity_cfg: &ActivityConfig,
97        config_dir: &Path,
98    ) -> Result<ResolvedConfig, ConfigError> {
99        let vault_root = expand_path(&prof.vault_root)?;
100        let sub = |s: &str| s.replace("{{vault_root}}", &vault_root.to_string_lossy());
101
102        let templates_dir = expand_path(&sub(&prof.templates_dir))?;
103        let captures_dir = expand_path(&sub(&prof.captures_dir))?;
104        let macros_dir = expand_path(&sub(&prof.macros_dir))?;
105        // Compute fallback typedefs dir from the config file's sibling "types/" directory.
106        // This respects both real and test config paths.
107        let default_td_dir = config_dir.join("types");
108        let (typedefs_dir, typedefs_fallback_dir) = match &prof.typedefs_dir {
109            Some(dir) => {
110                let resolved = expand_path(&sub(dir))?;
111                // When the configured dir differs from the default, use the default as fallback
112                let fallback = if resolved != default_td_dir && default_td_dir.exists() {
113                    Some(default_td_dir)
114                } else {
115                    None
116                };
117                (resolved, fallback)
118            }
119            None => (default_td_dir, None),
120        };
121
122        // Resolve excluded folders
123        let excluded_folders: Vec<PathBuf> = prof
124            .excluded_folders
125            .iter()
126            .filter_map(|folder| {
127                let expanded = sub(folder);
128                expand_path(&expanded).ok()
129            })
130            .collect();
131
132        // Resolve log file path if present
133        let logging = if let Some(ref file) = log_cfg.file {
134            let expanded_file = expand_path(&sub(&file.to_string_lossy()))?;
135            LoggingConfig {
136                level: log_cfg.level.clone(),
137                file_level: log_cfg.file_level.clone(),
138                file: Some(expanded_file),
139            }
140        } else {
141            log_cfg.clone()
142        };
143
144        Ok(ResolvedConfig {
145            active_profile: active.to_string(),
146            vault_root,
147            templates_dir,
148            captures_dir,
149            macros_dir,
150            typedefs_dir,
151            typedefs_fallback_dir,
152            excluded_folders,
153            security: sec.clone(),
154            logging,
155            activity: activity_cfg.clone(),
156        })
157    }
158}
159
160fn default_config_dir() -> PathBuf {
161    if let Ok(xdg) = env::var("XDG_CONFIG_HOME") {
162        return Path::new(&xdg).join("mdvault");
163    }
164    let home = home_dir().unwrap_or_else(|| PathBuf::from("~"));
165    home.join(".config").join("mdvault")
166}
167
168pub fn default_config_path() -> PathBuf {
169    if let Ok(xdg) = env::var("XDG_CONFIG_HOME") {
170        return Path::new(&xdg).join("mdvault").join("config.toml");
171    }
172    let home = home_dir().unwrap_or_else(|| PathBuf::from("~"));
173    home.join(".config").join("mdvault").join("config.toml")
174}
175
176
177fn expand_path(input: &str) -> Result<PathBuf, ConfigError> {
178    let expanded = full(input).map_err(|_| ConfigError::NoHome)?;
179    Ok(PathBuf::from(expanded.to_string()))
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use std::io::Write;
186    use tempfile::NamedTempFile;
187
188    #[test]
189    fn test_load_valid_config() {
190        let mut file = NamedTempFile::new().unwrap();
191        let config_content = r#"
192version = 1
193profile = "default"
194
195[profiles.default]
196vault_root = "/tmp/notes"
197templates_dir = "{{vault_root}}/templates"
198captures_dir = "{{vault_root}}/captures"
199macros_dir = "{{vault_root}}/macros"
200
201[security]
202allow_shell = false
203allow_http = false
204"#;
205        write!(file, "{}", config_content).unwrap();
206
207        let loaded = ConfigLoader::load(Some(file.path()), None).unwrap();
208
209        assert_eq!(loaded.active_profile, "default");
210        assert_eq!(loaded.vault_root.to_str().unwrap(), "/tmp/notes");
211        assert_eq!(loaded.templates_dir.to_str().unwrap(), "/tmp/notes/templates");
212    }
213
214    #[test]
215    fn test_load_missing_file() {
216        let path = Path::new("/non/existent/config.toml");
217        let result = ConfigLoader::load(Some(path), None);
218        assert!(matches!(result, Err(ConfigError::NotFound(_))));
219    }
220
221    #[test]
222    fn test_load_invalid_toml() {
223        let mut file = NamedTempFile::new().unwrap();
224        write!(file, "invalid toml content").unwrap();
225
226        let result = ConfigLoader::load(Some(file.path()), None);
227        assert!(matches!(result, Err(ConfigError::ParseError(_, _))));
228    }
229
230    #[test]
231    fn test_profile_override() {
232        let mut file = NamedTempFile::new().unwrap();
233        let config_content = r#"
234version = 1
235profile = "default"
236
237[profiles.default]
238vault_root = "/tmp/default"
239templates_dir = "/tmp/default/t"
240captures_dir = "/tmp/default/c"
241macros_dir = "/tmp/default/m"
242
243[profiles.work]
244vault_root = "/tmp/work"
245templates_dir = "/tmp/work/t"
246captures_dir = "/tmp/work/c"
247macros_dir = "/tmp/work/m"
248
249[security]
250allow_shell = false
251allow_http = false
252"#;
253        write!(file, "{}", config_content).unwrap();
254
255        let loaded = ConfigLoader::load(Some(file.path()), Some("work")).unwrap();
256        assert_eq!(loaded.active_profile, "work");
257        assert_eq!(loaded.vault_root.to_str().unwrap(), "/tmp/work");
258    }
259
260    #[test]
261    fn test_missing_profile() {
262        let mut file = NamedTempFile::new().unwrap();
263        let config_content = r#"
264version = 1
265profile = "default"
266
267[profiles.default]
268vault_root = "/tmp/default"
269templates_dir = "/tmp/default/t"
270captures_dir = "/tmp/default/c"
271macros_dir = "/tmp/default/m"
272
273[security]
274allow_shell = false
275allow_http = false
276"#;
277        write!(file, "{}", config_content).unwrap();
278
279        let result = ConfigLoader::load(Some(file.path()), Some("missing"));
280        assert!(matches!(result, Err(ConfigError::ProfileNotFound(_))));
281    }
282}