1use std::path::{Path, PathBuf};
7
8use serde::{Deserialize, Serialize};
9
10use crate::account::Account;
11use crate::error::CoreError;
12
13#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(default)]
15pub struct Config {
16 pub accounts: Vec<Account>,
17 pub defaults: Defaults,
18 pub trusted_senders: Vec<String>,
19 pub classify: ClassifyConfig,
20 #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
23 pub guardrails: std::collections::BTreeMap<String, String>,
24}
25
26#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(default)]
28pub struct Defaults {
29 pub send: Option<String>,
30 pub calendar: Option<String>,
31}
32
33#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(default)]
37pub struct ClassifyConfig {
38 pub prompt: Option<String>,
40 pub parallel: Option<usize>,
42 pub cache: Option<bool>,
44 pub labels: Vec<String>,
46}
47
48impl Config {
49 pub fn default_path() -> Result<PathBuf, CoreError> {
51 let dir = crate::paths::config_dir().ok_or(CoreError::NoConfigDir)?;
52 std::fs::create_dir_all(&dir)?;
53 Ok(dir.join("config.yaml"))
54 }
55
56 pub fn load() -> Result<Self, CoreError> {
58 let path = Self::default_path()?;
59 Self::load_from(&path)
60 }
61
62 pub fn load_from(path: &Path) -> Result<Self, CoreError> {
64 if !path.exists() {
65 return Ok(Self::default());
66 }
67 let text = std::fs::read_to_string(path)?;
68 Ok(serde_norway::from_str(&text)?)
69 }
70
71 pub fn save(&self) -> Result<(), CoreError> {
73 let path = Self::default_path()?;
74 self.save_to(&path)
75 }
76
77 pub fn save_to(&self, path: &Path) -> Result<(), CoreError> {
79 let text = serde_norway::to_string(self)?;
80 let tmp = path.with_extension("yaml.tmp");
83 std::fs::write(&tmp, text)?;
84 std::fs::rename(&tmp, path)?;
85 Ok(())
86 }
87
88 pub fn add_account(&mut self, account: Account) {
91 if let Some(existing) = self.accounts.iter_mut().find(|a| a.email == account.email) {
92 *existing = account;
93 return;
94 }
95 if self.accounts.is_empty() {
96 self.defaults.send = Some(account.email.clone());
97 self.defaults.calendar = Some(account.email.clone());
98 }
99 self.accounts.push(account);
100 }
101
102 pub fn remove_account(&mut self, email: &str) -> Option<Account> {
105 let idx = self.accounts.iter().position(|a| a.email == email)?;
106 let removed = self.accounts.remove(idx);
107 if self.defaults.send.as_deref() == Some(email) {
108 self.defaults.send = None;
109 }
110 if self.defaults.calendar.as_deref() == Some(email) {
111 self.defaults.calendar = None;
112 }
113 Some(removed)
114 }
115
116 pub fn set_default_send(&mut self, email: &str) -> Result<(), CoreError> {
118 if !self.accounts.iter().any(|a| a.email == email) {
119 return Err(CoreError::UnknownAccount {
120 email: email.to_string(),
121 });
122 }
123 self.defaults.send = Some(email.to_string());
124 Ok(())
125 }
126
127 pub fn set_default_calendar(&mut self, email: &str) -> Result<(), CoreError> {
129 if !self.accounts.iter().any(|a| a.email == email) {
130 return Err(CoreError::UnknownAccount {
131 email: email.to_string(),
132 });
133 }
134 self.defaults.calendar = Some(email.to_string());
135 Ok(())
136 }
137
138 pub fn find(&self, email: &str) -> Option<&Account> {
140 self.accounts.iter().find(|a| a.email == email)
141 }
142
143 pub fn add_trusted_sender(&mut self, email: &str) {
145 let lower = email.to_lowercase();
146 if !self
147 .trusted_senders
148 .iter()
149 .any(|s| s.to_lowercase() == lower)
150 {
151 self.trusted_senders.push(email.to_string());
152 }
153 }
154
155 pub fn remove_trusted_sender(&mut self, email: &str) -> bool {
158 let lower = email.to_lowercase();
159 let before = self.trusted_senders.len();
160 self.trusted_senders.retain(|s| s.to_lowercase() != lower);
161 before != self.trusted_senders.len()
162 }
163
164 pub fn is_sender_trusted(&self, email: &str) -> bool {
166 let lower = email.to_lowercase();
167 self.trusted_senders
168 .iter()
169 .any(|s| s.to_lowercase() == lower)
170 }
171
172 pub fn get_key(&self, key: &str) -> Option<String> {
174 match key {
175 "classify.prompt" => self.classify.prompt.clone(),
176 "classify.parallel" => self.classify.parallel.map(|n| n.to_string()),
177 "classify.cache" => self.classify.cache.map(|b| b.to_string()),
178 "classify.labels" => {
179 if self.classify.labels.is_empty() {
180 None
181 } else {
182 Some(self.classify.labels.join(","))
183 }
184 }
185 _ => {
186 if let Some(class) = key.strip_prefix("guardrails.") {
187 return self.guardrails.get(class).cloned();
188 }
189 None
190 }
191 }
192 }
193
194 pub const GUARDRAIL_CLASSES: [&'static str; 6] =
196 ["send", "delete", "cancel", "rsvp", "bulk", "unsubscribe"];
197
198 pub fn set_key(&mut self, key: &str, value: &str) -> Result<(), CoreError> {
201 match key {
202 "classify.prompt" => self.classify.prompt = Some(value.to_string()),
203 "classify.parallel" => {
204 let n: usize = value
205 .trim()
206 .parse()
207 .map_err(|_| CoreError::InvalidConfigValue {
208 key: key.to_string(),
209 value: value.to_string(),
210 })?;
211 self.classify.parallel = Some(n);
212 }
213 "classify.cache" => {
214 let b = match value.trim() {
215 "true" => true,
216 "false" => false,
217 _ => {
218 return Err(CoreError::InvalidConfigValue {
219 key: key.to_string(),
220 value: value.to_string(),
221 });
222 }
223 };
224 self.classify.cache = Some(b);
225 }
226 "classify.labels" => {
227 self.classify.labels = value
228 .split(',')
229 .map(|s| s.trim().to_string())
230 .filter(|s| !s.is_empty())
231 .collect();
232 }
233 _ => {
234 if let Some(class) = key.strip_prefix("guardrails.") {
235 if !Self::GUARDRAIL_CLASSES.contains(&class) {
236 return Err(CoreError::UnknownConfigKey {
237 key: key.to_string(),
238 });
239 }
240 if !["allow", "confirm", "deny"].contains(&value.trim()) {
241 return Err(CoreError::InvalidConfigValue {
242 key: key.to_string(),
243 value: value.to_string(),
244 });
245 }
246 self.guardrails
247 .insert(class.to_string(), value.trim().to_string());
248 return Ok(());
249 }
250 return Err(CoreError::UnknownConfigKey {
251 key: key.to_string(),
252 });
253 }
254 }
255 Ok(())
256 }
257
258 pub fn unset_key(&mut self, key: &str) -> Result<(), CoreError> {
260 match key {
261 "classify.prompt" => self.classify.prompt = None,
262 "classify.parallel" => self.classify.parallel = None,
263 "classify.cache" => self.classify.cache = None,
264 "classify.labels" => self.classify.labels.clear(),
265 _ => {
266 if let Some(class) = key.strip_prefix("guardrails.") {
267 if !Self::GUARDRAIL_CLASSES.contains(&class) {
268 return Err(CoreError::UnknownConfigKey {
269 key: key.to_string(),
270 });
271 }
272 self.guardrails.remove(class);
273 return Ok(());
274 }
275 return Err(CoreError::UnknownConfigKey {
276 key: key.to_string(),
277 });
278 }
279 }
280 Ok(())
281 }
282
283 pub const KNOWN_KEYS: &'static [&'static str] = &[
285 "classify.prompt",
286 "classify.parallel",
287 "classify.cache",
288 "classify.labels",
289 ];
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295 use chrono::TimeZone;
296
297 fn make_account(email: &str) -> Account {
298 Account {
299 email: email.into(),
300 tenant_id: "tid".into(),
301 home_account_id: "home".into(),
302 added_at: chrono::Utc.with_ymd_and_hms(2026, 5, 13, 22, 0, 0).unwrap(),
303 storage: crate::TokenStorage::default(),
304 }
305 }
306
307 #[test]
308 fn empty_config_serializes_and_deserializes() {
309 let c = Config::default();
310 let yaml = serde_norway::to_string(&c).unwrap();
311 let c2: Config = serde_norway::from_str(&yaml).unwrap();
312 assert_eq!(c, c2);
313 }
314
315 fn golden_config() -> Config {
318 let mut c = Config::default();
319 c.add_account(make_account("a@b.com"));
320 let mut file_acct = make_account("personal@outlook.com");
321 file_acct.tenant_id = Account::PERSONAL_MSA_TENANT.into();
322 file_acct.storage = crate::TokenStorage::File;
323 c.add_account(file_acct);
324 c.set_default_calendar("personal@outlook.com").unwrap();
325 c.trusted_senders = vec!["maria@mklab.se".into(), "yes".into(), "".into()];
326 c.classify.prompt = Some("Classify: invoice or receipt?\nAnswer with one word.\n".into());
327 c.classify.parallel = Some(8);
328 c.classify.cache = Some(false);
329 c.classify.labels = vec!["invoice".into(), "null".into(), "1.0".into()];
330 c.guardrails.insert("send".into(), "confirm".into());
331 c.guardrails.insert("delete".into(), "deny".into());
332 c
333 }
334
335 #[test]
336 fn config_yaml_output_is_byte_stable() {
337 let tmp = tempfile::TempDir::new().unwrap();
338 let path = tmp.path().join("config.yaml");
339 golden_config().save_to(&path).unwrap();
340 let written = std::fs::read_to_string(&path).unwrap();
341 assert_eq!(written, GOLDEN_YAML);
342 assert_eq!(Config::load_from(&path).unwrap(), golden_config());
343 let empty = serde_norway::to_string(&Config::default()).unwrap();
344 assert_eq!(empty, GOLDEN_EMPTY_YAML);
345 }
346
347 const GOLDEN_YAML: &str = r"accounts:
348- email: a@b.com
349 tenant_id: tid
350 home_account_id: home
351 added_at: 2026-05-13T22:00:00Z
352 storage: keychain
353- email: personal@outlook.com
354 tenant_id: 9188040d-6c67-4c5b-b112-36a304b66dad
355 home_account_id: home
356 added_at: 2026-05-13T22:00:00Z
357 storage: file
358defaults:
359 send: a@b.com
360 calendar: personal@outlook.com
361trusted_senders:
362- maria@mklab.se
363- yes
364- ''
365classify:
366 prompt: |
367 Classify: invoice or receipt?
368 Answer with one word.
369 parallel: 8
370 cache: false
371 labels:
372 - invoice
373 - 'null'
374 - '1.0'
375guardrails:
376 delete: deny
377 send: confirm
378";
379 const GOLDEN_EMPTY_YAML: &str = r"accounts: []
380defaults:
381 send: null
382 calendar: null
383trusted_senders: []
384classify:
385 prompt: null
386 parallel: null
387 cache: null
388 labels: []
389";
390
391 #[test]
392 fn first_added_account_becomes_both_defaults() {
393 let mut c = Config::default();
394 c.add_account(make_account("a@b.com"));
395 assert_eq!(c.defaults.send.as_deref(), Some("a@b.com"));
396 assert_eq!(c.defaults.calendar.as_deref(), Some("a@b.com"));
397 }
398
399 #[test]
400 fn second_added_account_does_not_change_defaults() {
401 let mut c = Config::default();
402 c.add_account(make_account("a@b.com"));
403 c.add_account(make_account("c@d.com"));
404 assert_eq!(c.defaults.send.as_deref(), Some("a@b.com"));
405 assert_eq!(c.defaults.calendar.as_deref(), Some("a@b.com"));
406 assert_eq!(c.accounts.len(), 2);
407 }
408
409 #[test]
410 fn removing_default_account_clears_default() {
411 let mut c = Config::default();
412 c.add_account(make_account("a@b.com"));
413 c.add_account(make_account("c@d.com"));
414 c.remove_account("a@b.com");
415 assert_eq!(c.defaults.send, None);
416 assert_eq!(c.defaults.calendar, None);
417 }
418
419 #[test]
420 fn set_default_send_for_unknown_account_errors() {
421 let mut c = Config::default();
422 c.add_account(make_account("a@b.com"));
423 assert!(matches!(
424 c.set_default_send("ghost@nowhere.com"),
425 Err(CoreError::UnknownAccount { .. })
426 ));
427 }
428
429 #[test]
430 fn config_roundtrips_through_file() {
431 let tmp = tempfile::TempDir::new().unwrap();
432 let path = tmp.path().join("config.yaml");
433
434 let mut c = Config::default();
435 c.add_account(make_account("a@b.com"));
436 c.add_account(make_account("c@d.com"));
437 c.set_default_calendar("c@d.com").unwrap();
438 c.save_to(&path).unwrap();
439
440 let c2 = Config::load_from(&path).unwrap();
441 assert_eq!(c, c2);
442 }
443
444 #[test]
445 fn add_trusted_sender_is_idempotent() {
446 let mut c = Config::default();
447 c.add_trusted_sender("a@b.com");
448 c.add_trusted_sender("a@b.com");
449 assert_eq!(c.trusted_senders.len(), 1);
450 }
451
452 #[test]
453 fn add_trusted_sender_is_case_insensitive() {
454 let mut c = Config::default();
455 c.add_trusted_sender("Maria@MKLab.se");
456 c.add_trusted_sender("maria@mklab.se");
457 assert_eq!(c.trusted_senders.len(), 1);
458 }
459
460 #[test]
461 fn remove_trusted_sender_returns_true_when_present() {
462 let mut c = Config::default();
463 c.add_trusted_sender("a@b.com");
464 assert!(c.remove_trusted_sender("a@b.com"));
465 assert!(c.trusted_senders.is_empty());
466 }
467
468 #[test]
469 fn remove_trusted_sender_returns_false_when_absent() {
470 let mut c = Config::default();
471 assert!(!c.remove_trusted_sender("ghost@nowhere.com"));
472 }
473
474 #[test]
475 fn remove_trusted_sender_is_case_insensitive() {
476 let mut c = Config::default();
477 c.add_trusted_sender("Maria@MKLab.se");
478 assert!(c.remove_trusted_sender("MARIA@mklab.SE"));
479 assert!(c.trusted_senders.is_empty());
480 }
481
482 #[test]
483 fn is_sender_trusted_case_insensitive() {
484 let mut c = Config::default();
485 c.add_trusted_sender("Maria@MKLab.se");
486 assert!(c.is_sender_trusted("maria@mklab.se"));
487 assert!(c.is_sender_trusted("MARIA@MKLAB.SE"));
488 assert!(!c.is_sender_trusted("anna@mklab.se"));
489 }
490
491 #[test]
492 fn config_with_missing_trusted_senders_loads_as_empty() {
493 let yaml = "accounts: []\ndefaults: {}\n";
494 let c: Config = serde_norway::from_str(yaml).unwrap();
495 assert!(c.trusted_senders.is_empty());
496 }
497
498 #[test]
499 fn classify_config_defaults_are_empty() {
500 let c = Config::default();
501 assert!(c.classify.prompt.is_none());
502 assert!(c.classify.parallel.is_none());
503 assert!(c.classify.cache.is_none());
504 assert!(c.classify.labels.is_empty());
505 }
506
507 #[test]
508 fn classify_config_roundtrips_through_yaml() {
509 let mut c = Config::default();
510 c.classify.prompt = Some("Classify it".into());
511 c.classify.parallel = Some(8);
512 c.classify.cache = Some(true);
513 c.classify.labels = vec!["invoice".into(), "receipt".into()];
514 let yaml = serde_norway::to_string(&c).unwrap();
515 let back: Config = serde_norway::from_str(&yaml).unwrap();
516 assert_eq!(back.classify.prompt.as_deref(), Some("Classify it"));
517 assert_eq!(back.classify.parallel, Some(8));
518 assert_eq!(back.classify.labels, vec!["invoice", "receipt"]);
519 }
520
521 #[test]
522 fn config_set_get_unset_roundtrip() {
523 let mut c = Config::default();
524 c.set_key("classify.parallel", "8").unwrap();
525 assert_eq!(c.get_key("classify.parallel"), Some("8".to_string()));
526 c.set_key("classify.labels", "invoice,receipt,ticket")
527 .unwrap();
528 assert_eq!(
529 c.get_key("classify.labels"),
530 Some("invoice,receipt,ticket".to_string())
531 );
532 c.set_key("classify.cache", "true").unwrap();
533 assert_eq!(c.get_key("classify.cache"), Some("true".to_string()));
534 c.unset_key("classify.parallel").unwrap();
535 assert_eq!(c.get_key("classify.parallel"), None);
536 }
537
538 #[test]
539 fn config_set_rejects_unknown_key_and_bad_value() {
540 let mut c = Config::default();
541 assert!(c.set_key("classify.nope", "x").is_err());
542 assert!(c.set_key("classify.parallel", "notanumber").is_err());
543 assert!(c.set_key("classify.cache", "maybe").is_err());
544 }
545}