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