Skip to main content

mxr_config/
lib.rs

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    /// Mutex to serialize tests that touch environment variables.
18    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        // Rest should be defaults
143        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        // Set env var, load, then clean up
165        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        // Clear env vars that could interfere
249        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}