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        let resolved = Self::resolve_profile(
75            &active,
76            prof,
77            &cf.security,
78            &cf.logging,
79            &cf.activity,
80        )?;
81        Ok(resolved)
82    }
83
84    fn resolve_profile(
85        active: &str,
86        prof: &Profile,
87        sec: &SecurityPolicy,
88        log_cfg: &LoggingConfig,
89        activity_cfg: &ActivityConfig,
90    ) -> Result<ResolvedConfig, ConfigError> {
91        let vault_root = expand_path(&prof.vault_root)?;
92        let sub = |s: &str| s.replace("{{vault_root}}", &vault_root.to_string_lossy());
93
94        let templates_dir = expand_path(&sub(&prof.templates_dir))?;
95        let captures_dir = expand_path(&sub(&prof.captures_dir))?;
96        let macros_dir = expand_path(&sub(&prof.macros_dir))?;
97        let typedefs_dir = match &prof.typedefs_dir {
98            Some(dir) => expand_path(&sub(dir))?,
99            None => default_typedefs_dir(),
100        };
101
102        // Resolve excluded folders
103        let excluded_folders: Vec<PathBuf> = prof
104            .excluded_folders
105            .iter()
106            .filter_map(|folder| {
107                let expanded = sub(folder);
108                expand_path(&expanded).ok()
109            })
110            .collect();
111
112        // Resolve log file path if present
113        let logging = if let Some(ref file) = log_cfg.file {
114            let expanded_file = expand_path(&sub(&file.to_string_lossy()))?;
115            LoggingConfig {
116                level: log_cfg.level.clone(),
117                file_level: log_cfg.file_level.clone(),
118                file: Some(expanded_file),
119            }
120        } else {
121            log_cfg.clone()
122        };
123
124        Ok(ResolvedConfig {
125            active_profile: active.to_string(),
126            vault_root,
127            templates_dir,
128            captures_dir,
129            macros_dir,
130            typedefs_dir,
131            excluded_folders,
132            security: sec.clone(),
133            logging,
134            activity: activity_cfg.clone(),
135        })
136    }
137}
138
139pub fn default_config_path() -> PathBuf {
140    if let Ok(xdg) = env::var("XDG_CONFIG_HOME") {
141        return Path::new(&xdg).join("mdvault").join("config.toml");
142    }
143    let home = home_dir().unwrap_or_else(|| PathBuf::from("~"));
144    home.join(".config").join("mdvault").join("config.toml")
145}
146
147/// Default directory for Lua type definitions.
148/// Global location: ~/.config/mdvault/types/
149pub fn default_typedefs_dir() -> PathBuf {
150    if let Ok(xdg) = env::var("XDG_CONFIG_HOME") {
151        return Path::new(&xdg).join("mdvault").join("types");
152    }
153    let home = home_dir().unwrap_or_else(|| PathBuf::from("~"));
154    home.join(".config").join("mdvault").join("types")
155}
156
157fn expand_path(input: &str) -> Result<PathBuf, ConfigError> {
158    let expanded = full(input).map_err(|_| ConfigError::NoHome)?;
159    Ok(PathBuf::from(expanded.to_string()))
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use std::io::Write;
166    use tempfile::NamedTempFile;
167
168    #[test]
169    fn test_load_valid_config() {
170        let mut file = NamedTempFile::new().unwrap();
171        let config_content = r#"
172version = 1
173profile = "default"
174
175[profiles.default]
176vault_root = "/tmp/notes"
177templates_dir = "{{vault_root}}/templates"
178captures_dir = "{{vault_root}}/captures"
179macros_dir = "{{vault_root}}/macros"
180
181[security]
182allow_shell = false
183allow_http = false
184"#;
185        write!(file, "{}", config_content).unwrap();
186
187        let loaded = ConfigLoader::load(Some(file.path()), None).unwrap();
188
189        assert_eq!(loaded.active_profile, "default");
190        assert_eq!(loaded.vault_root.to_str().unwrap(), "/tmp/notes");
191        assert_eq!(loaded.templates_dir.to_str().unwrap(), "/tmp/notes/templates");
192    }
193
194    #[test]
195    fn test_load_missing_file() {
196        let path = Path::new("/non/existent/config.toml");
197        let result = ConfigLoader::load(Some(path), None);
198        assert!(matches!(result, Err(ConfigError::NotFound(_))));
199    }
200
201    #[test]
202    fn test_load_invalid_toml() {
203        let mut file = NamedTempFile::new().unwrap();
204        write!(file, "invalid toml content").unwrap();
205
206        let result = ConfigLoader::load(Some(file.path()), None);
207        assert!(matches!(result, Err(ConfigError::ParseError(_, _))));
208    }
209
210    #[test]
211    fn test_profile_override() {
212        let mut file = NamedTempFile::new().unwrap();
213        let config_content = r#"
214version = 1
215profile = "default"
216
217[profiles.default]
218vault_root = "/tmp/default"
219templates_dir = "/tmp/default/t"
220captures_dir = "/tmp/default/c"
221macros_dir = "/tmp/default/m"
222
223[profiles.work]
224vault_root = "/tmp/work"
225templates_dir = "/tmp/work/t"
226captures_dir = "/tmp/work/c"
227macros_dir = "/tmp/work/m"
228
229[security]
230allow_shell = false
231allow_http = false
232"#;
233        write!(file, "{}", config_content).unwrap();
234
235        let loaded = ConfigLoader::load(Some(file.path()), Some("work")).unwrap();
236        assert_eq!(loaded.active_profile, "work");
237        assert_eq!(loaded.vault_root.to_str().unwrap(), "/tmp/work");
238    }
239
240    #[test]
241    fn test_missing_profile() {
242        let mut file = NamedTempFile::new().unwrap();
243        let config_content = r#"
244version = 1
245profile = "default"
246
247[profiles.default]
248vault_root = "/tmp/default"
249templates_dir = "/tmp/default/t"
250captures_dir = "/tmp/default/c"
251macros_dir = "/tmp/default/m"
252
253[security]
254allow_shell = false
255allow_http = false
256"#;
257        write!(file, "{}", config_content).unwrap();
258
259        let result = ConfigLoader::load(Some(file.path()), Some("missing"));
260        assert!(matches!(result, Err(ConfigError::ProfileNotFound(_))));
261    }
262}