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!(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 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 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 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}