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