1mod defaults;
2mod resolve;
3mod types;
4
5pub use resolve::{
6 app_instance_name, config_dir, config_file_path, data_dir, load_config, load_config_from_path,
7 load_config_from_str, save_config, save_config_to_path, socket_path, ConfigError,
8};
9pub use types::*;
10
11#[cfg(test)]
12mod tests {
13 use super::*;
14 use std::sync::Mutex;
15 use tempfile::TempDir;
16
17 static ENV_LOCK: Mutex<()> = Mutex::new(());
19
20 #[test]
21 fn default_config_is_valid() {
22 let config = MxrConfig::default();
23 let serialized = toml::to_string(&config).expect("serialize default config");
24 let deserialized: MxrConfig =
25 toml::from_str(&serialized).expect("deserialize default config");
26 assert_eq!(deserialized.general.sync_interval, 60);
27 assert_eq!(deserialized.general.hook_timeout, 30);
28 assert_eq!(deserialized.search.max_results, 200);
29 assert_eq!(
30 deserialized.search.default_mode,
31 mxr_core::SearchMode::Lexical
32 );
33 assert!(!deserialized.search.semantic.enabled);
34 assert_eq!(
35 deserialized.search.semantic.active_profile,
36 mxr_core::SemanticProfile::BgeSmallEnV15
37 );
38 assert_eq!(deserialized.logging.event_retention_days, 90);
39 assert!(deserialized.accounts.is_empty());
40 }
41
42 #[test]
43 fn full_toml_round_trip() {
44 let toml_str = r#"
45[general]
46editor = "nvim"
47default_account = "personal"
48sync_interval = 120
49hook_timeout = 45
50attachment_dir = "/tmp/attachments"
51
52[accounts.personal]
53name = "Personal"
54email = "me@example.com"
55
56[accounts.personal.sync]
57type = "gmail"
58client_id = "abc123"
59client_secret = "secret"
60token_ref = "keyring:gmail-personal"
61
62[accounts.personal.send]
63type = "smtp"
64host = "smtp.example.com"
65port = 587
66username = "me@example.com"
67password_ref = "keyring:smtp-personal"
68use_tls = true
69
70[render]
71html_command = "w3m -dump -T text/html"
72reader_mode = false
73show_reader_stats = false
74
75[search]
76default_sort = "relevance"
77max_results = 50
78default_mode = "hybrid"
79
80[search.semantic]
81enabled = true
82auto_download_models = false
83active_profile = "multilingual-e5-small"
84max_pending_jobs = 32
85query_timeout_ms = 3000
86
87[snooze]
88morning_hour = 8
89evening_hour = 20
90weekend_day = "sunday"
91weekend_hour = 11
92
93[logging]
94level = "debug"
95max_size_mb = 100
96max_files = 5
97stderr = false
98event_retention_days = 30
99
100[appearance]
101theme = "catppuccin"
102sidebar = false
103date_format = "%m/%d"
104date_format_full = "%Y-%m-%d %H:%M:%S"
105subject_max_width = 80
106"#;
107
108 let config: MxrConfig = toml::from_str(toml_str).expect("parse full toml");
109 assert_eq!(config.general.editor.as_deref(), Some("nvim"));
110 assert_eq!(config.general.sync_interval, 120);
111 assert_eq!(config.general.hook_timeout, 45);
112 assert_eq!(config.accounts.len(), 1);
113
114 let personal = &config.accounts["personal"];
115 assert_eq!(personal.email, "me@example.com");
116
117 let serialized = toml::to_string(&config).expect("re-serialize");
118 let round_tripped: MxrConfig = toml::from_str(&serialized).expect("round-trip deserialize");
119 assert_eq!(round_tripped.search.max_results, 50);
120 assert_eq!(
121 round_tripped.search.default_mode,
122 mxr_core::SearchMode::Hybrid
123 );
124 assert!(round_tripped.search.semantic.enabled);
125 assert_eq!(
126 round_tripped.search.semantic.active_profile,
127 mxr_core::SemanticProfile::MultilingualE5Small
128 );
129 assert_eq!(round_tripped.logging.max_files, 5);
130 assert_eq!(round_tripped.appearance.theme, "catppuccin");
131 }
132
133 #[test]
134 fn partial_toml_uses_defaults() {
135 let toml_str = r#"
136[general]
137editor = "emacs"
138"#;
139
140 let config = load_config_from_str(toml_str).expect("parse partial toml");
141 assert_eq!(config.general.editor.as_deref(), Some("emacs"));
142 assert_eq!(config.general.sync_interval, 60);
144 assert_eq!(config.general.hook_timeout, 30);
145 assert!(config.render.reader_mode);
146 assert_eq!(config.search.max_results, 200);
147 assert_eq!(config.search.default_mode, mxr_core::SearchMode::Lexical);
148 assert_eq!(
149 config.search.semantic.active_profile,
150 mxr_core::SemanticProfile::BgeSmallEnV15
151 );
152 assert_eq!(config.snooze.morning_hour, 9);
153 assert_eq!(config.logging.event_retention_days, 90);
154 assert_eq!(config.appearance.subject_max_width, 60);
155 }
156
157 #[test]
158 fn env_override_sync_interval() {
159 let _guard = ENV_LOCK.lock().unwrap();
160 let tmp = TempDir::new().expect("create temp dir");
161 let config_path = tmp.path().join("config.toml");
162 std::fs::write(&config_path, "[general]\nsync_interval = 60\n").expect("write config");
163
164 unsafe { std::env::set_var("MXR_SYNC_INTERVAL", "30") };
166 let config = load_config_from_path(&config_path).expect("load config");
167 unsafe { std::env::remove_var("MXR_SYNC_INTERVAL") };
168
169 assert_eq!(config.general.sync_interval, 30);
170 }
171
172 #[test]
173 fn xdg_paths_correct() {
174 let _guard = ENV_LOCK.lock().unwrap();
175 unsafe { std::env::remove_var("MXR_INSTANCE") };
176
177 let cfg = config_dir();
178 assert!(
179 cfg.ends_with("mxr"),
180 "config_dir should end with 'mxr': {:?}",
181 cfg
182 );
183
184 let data = data_dir();
185 assert!(
186 data.ends_with(app_instance_name()),
187 "data_dir should end with instance name '{}': {:?}",
188 app_instance_name(),
189 data
190 );
191
192 let file = config_file_path();
193 assert!(
194 file.ends_with("config.toml"),
195 "config_file_path should end with 'config.toml': {:?}",
196 file
197 );
198
199 let socket = socket_path();
200 assert!(
201 socket.ends_with("mxr.sock"),
202 "socket_path should end with 'mxr.sock': {:?}",
203 socket
204 );
205 }
206
207 #[test]
208 fn instance_name_can_be_overridden() {
209 let _guard = ENV_LOCK.lock().unwrap();
210 unsafe { std::env::set_var("MXR_INSTANCE", "mxr-test") };
211 assert_eq!(app_instance_name(), "mxr-test");
212 assert!(data_dir().ends_with("mxr-test"));
213 unsafe { std::env::remove_var("MXR_INSTANCE") };
214 }
215
216 #[test]
217 fn path_overrides_can_be_set_via_env() {
218 let _guard = ENV_LOCK.lock().unwrap();
219 let tmp = TempDir::new().expect("create temp dir");
220 let config_dir_override = tmp.path().join("cfg");
221 let data_dir_override = tmp.path().join("data");
222 let socket_path_override = tmp.path().join("sock").join("mxr.sock");
223
224 unsafe {
225 std::env::set_var("MXR_CONFIG_DIR", &config_dir_override);
226 std::env::set_var("MXR_DATA_DIR", &data_dir_override);
227 std::env::set_var("MXR_SOCKET_PATH", &socket_path_override);
228 }
229
230 assert_eq!(config_dir(), config_dir_override);
231 assert_eq!(config_file_path(), config_dir_override.join("config.toml"));
232 assert_eq!(data_dir(), data_dir_override);
233 assert_eq!(socket_path(), socket_path_override);
234
235 unsafe {
236 std::env::remove_var("MXR_CONFIG_DIR");
237 std::env::remove_var("MXR_DATA_DIR");
238 std::env::remove_var("MXR_SOCKET_PATH");
239 }
240 }
241
242 #[test]
243 fn missing_file_returns_defaults() {
244 let _guard = ENV_LOCK.lock().unwrap();
245 let tmp = TempDir::new().expect("create temp dir");
246 let config_path = tmp.path().join("nonexistent.toml");
247
248 unsafe {
250 std::env::remove_var("MXR_EDITOR");
251 std::env::remove_var("MXR_SYNC_INTERVAL");
252 std::env::remove_var("MXR_DEFAULT_ACCOUNT");
253 std::env::remove_var("MXR_ATTACHMENT_DIR");
254 std::env::remove_var("MXR_CONFIG_DIR");
255 std::env::remove_var("MXR_DATA_DIR");
256 std::env::remove_var("MXR_SOCKET_PATH");
257 }
258
259 let config = load_config_from_path(&config_path).expect("load missing file");
260 assert_eq!(config.general.sync_interval, 60);
261 assert!(config.accounts.is_empty());
262 assert!(config.render.reader_mode);
263 }
264
265 #[test]
266 fn invalid_toml_returns_error() {
267 let tmp = TempDir::new().expect("create temp dir");
268 let config_path = tmp.path().join("bad.toml");
269 std::fs::write(&config_path, "this is not [valid toml {{{{").expect("write bad config");
270
271 let result = load_config_from_path(&config_path);
272 assert!(result.is_err());
273 match result.unwrap_err() {
274 ConfigError::ParseToml { path, .. } => {
275 assert_eq!(path, config_path);
276 }
277 other => panic!("expected ParseToml, got: {:?}", other),
278 }
279 }
280
281 #[test]
282 fn account_config_variants() {
283 let toml_str = r#"
284[accounts.work]
285name = "Work"
286email = "work@corp.com"
287
288[accounts.work.sync]
289type = "gmail"
290client_id = "work-client-id"
291token_ref = "keyring:gmail-work"
292
293[accounts.work.send]
294type = "smtp"
295host = "smtp.corp.com"
296port = 465
297username = "work@corp.com"
298password_ref = "keyring:smtp-work"
299use_tls = true
300
301[accounts.newsletter]
302name = "Newsletter"
303email = "news@corp.com"
304
305[accounts.newsletter.send]
306type = "gmail"
307"#;
308
309 let config = load_config_from_str(toml_str).expect("parse account variants");
310 assert_eq!(config.accounts.len(), 2);
311
312 let work = &config.accounts["work"];
313 assert!(matches!(work.sync, Some(SyncProviderConfig::Gmail { .. })));
314 assert!(matches!(work.send, Some(SendProviderConfig::Smtp { .. })));
315
316 if let Some(SendProviderConfig::Smtp { port, use_tls, .. }) = &work.send {
317 assert_eq!(*port, 465);
318 assert!(*use_tls);
319 }
320
321 let newsletter = &config.accounts["newsletter"];
322 assert!(newsletter.sync.is_none());
323 assert!(matches!(newsletter.send, Some(SendProviderConfig::Gmail)));
324 }
325
326 #[test]
327 fn imap_sync_variant_parses() {
328 let toml_str = r#"
329[accounts.fastmail]
330name = "Fastmail"
331email = "me@fastmail.com"
332
333[accounts.fastmail.sync]
334type = "imap"
335host = "imap.fastmail.com"
336port = 993
337username = "me@fastmail.com"
338password_ref = "keyring:fastmail-imap"
339use_tls = true
340
341[accounts.fastmail.send]
342type = "smtp"
343host = "smtp.fastmail.com"
344port = 465
345username = "me@fastmail.com"
346password_ref = "keyring:fastmail-smtp"
347use_tls = true
348"#;
349
350 let config = load_config_from_str(toml_str).expect("parse imap account");
351 let fastmail = &config.accounts["fastmail"];
352 assert!(matches!(
353 fastmail.sync,
354 Some(SyncProviderConfig::Imap { .. })
355 ));
356 assert!(matches!(
357 fastmail.send,
358 Some(SendProviderConfig::Smtp { .. })
359 ));
360 }
361}