Skip to main content

pidge_core/
config.rs

1//! Persistent configuration file for pidge.
2//!
3//! Path: `${XDG_CONFIG_HOME:-~/.config}/pidge/config.yaml`.
4//! Contains only non-sensitive metadata; tokens live in the OS keychain.
5
6use 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    /// Guardrails: action class -> "allow" | "confirm" | "deny".
21    /// Classes: send, delete, cancel, rsvp, bulk, unsubscribe.
22    #[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/// User-configurable defaults for `pidge ai classify`. Every field is
34/// optional; an unset field falls back to a built-in default at call time.
35#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(default)]
37pub struct ClassifyConfig {
38    /// Default classification prompt (instructions + valid outputs).
39    pub prompt: Option<String>,
40    /// Default batch concurrency.
41    pub parallel: Option<usize>,
42    /// Whether to cache classifications by message-id + prompt hash.
43    pub cache: Option<bool>,
44    /// Optional allowed-label set for validation.
45    pub labels: Vec<String>,
46}
47
48impl Config {
49    /// Default path: `${XDG_CONFIG_HOME:-~/.config}/pidge/config.yaml`.
50    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    /// Load the config from the default path. If the file doesn't exist, returns `Config::default()`.
57    pub fn load() -> Result<Self, CoreError> {
58        let path = Self::default_path()?;
59        Self::load_from(&path)
60    }
61
62    /// Load from a specific path. Useful for tests.
63    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    /// Save the config to the default path.
72    pub fn save(&self) -> Result<(), CoreError> {
73        let path = Self::default_path()?;
74        self.save_to(&path)
75    }
76
77    /// Save to a specific path. Useful for tests.
78    pub fn save_to(&self, path: &Path) -> Result<(), CoreError> {
79        let text = serde_norway::to_string(self)?;
80        // Written whole or not at all: a crash mid-write must not leave a
81        // half file that later fails to parse (guardrails then fail closed).
82        let tmp = path.with_extension("yaml.tmp");
83        std::fs::write(&tmp, text)?;
84        std::fs::rename(&tmp, path)?;
85        Ok(())
86    }
87
88    /// Add or replace an account by email. If this is the first account,
89    /// also sets it as the default send AND default calendar account.
90    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    /// Remove an account by email. Returns the removed account.
103    /// If the removed account was a default, that default is cleared.
104    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    /// Set the default send account. Errors if the email isn't a known signed-in account.
117    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    /// Set the default calendar account. Errors if the email isn't a known signed-in account.
128    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    /// Find an account by email.
139    pub fn find(&self, email: &str) -> Option<&Account> {
140        self.accounts.iter().find(|a| a.email == email)
141    }
142
143    /// Add an email to the trusted-senders list (case-insensitive). Idempotent.
144    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    /// Remove an email from the trusted-senders list (case-insensitive).
156    /// Returns true if it was present, false if it wasn't (idempotent either way).
157    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    /// Case-insensitive check for whether an email is in the trusted-senders list.
165    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    /// Read a dotted config key as a display string, or `None` if unset.
173    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    /// Valid guardrail action classes.
195    pub const GUARDRAIL_CLASSES: [&'static str; 6] =
196        ["send", "delete", "cancel", "rsvp", "bulk", "unsubscribe"];
197
198    /// Set a dotted config key from a string value. Errors on unknown key or
199    /// unparseable value.
200    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    /// Revert a dotted config key to its unset/default state.
259    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    /// Every settable config key, for `pidge config show`/help.
284    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    /// Pins the exact bytes `save_to` writes, so a YAML library change can
316    /// never silently alter users' config files.
317    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}