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