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