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 = 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    /// Load the config from the default path. If the file doesn't exist, returns `Config::default()`.
59    pub fn load() -> Result<Self, CoreError> {
60        let path = Self::default_path()?;
61        Self::load_from(&path)
62    }
63
64    /// Load from a specific path. Useful for tests.
65    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_yaml::from_str(&text)?)
71    }
72
73    /// Save the config to the default path.
74    pub fn save(&self) -> Result<(), CoreError> {
75        let path = Self::default_path()?;
76        self.save_to(&path)
77    }
78
79    /// Save to a specific path. Useful for tests.
80    pub fn save_to(&self, path: &Path) -> Result<(), CoreError> {
81        let text = serde_yaml::to_string(self)?;
82        // Written whole or not at all: a crash mid-write must not leave a
83        // half file that later fails to parse (guardrails then fail closed).
84        let tmp = path.with_extension("yaml.tmp");
85        std::fs::write(&tmp, text)?;
86        std::fs::rename(&tmp, path)?;
87        Ok(())
88    }
89
90    /// Add or replace an account by email. If this is the first account,
91    /// also sets it as the default send AND default calendar account.
92    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    /// Remove an account by email. Returns the removed account.
105    /// If the removed account was a default, that default is cleared.
106    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    /// Set the default send account. Errors if the email isn't a known signed-in account.
119    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    /// Set the default calendar account. Errors if the email isn't a known signed-in account.
130    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    /// Find an account by email.
141    pub fn find(&self, email: &str) -> Option<&Account> {
142        self.accounts.iter().find(|a| a.email == email)
143    }
144
145    /// Add an email to the trusted-senders list (case-insensitive). Idempotent.
146    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    /// Remove an email from the trusted-senders list (case-insensitive).
158    /// Returns true if it was present, false if it wasn't (idempotent either way).
159    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    /// Case-insensitive check for whether an email is in the trusted-senders list.
167    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    /// Read a dotted config key as a display string, or `None` if unset.
175    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    /// Valid guardrail action classes.
197    pub const GUARDRAIL_CLASSES: [&'static str; 6] =
198        ["send", "delete", "cancel", "rsvp", "bulk", "unsubscribe"];
199
200    /// Set a dotted config key from a string value. Errors on unknown key or
201    /// unparseable value.
202    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    /// Revert a dotted config key to its unset/default state.
261    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    /// Every settable config key, for `pidge config show`/help.
286    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_yaml::to_string(&c).unwrap();
313        let c2: Config = serde_yaml::from_str(&yaml).unwrap();
314        assert_eq!(c, c2);
315    }
316
317    #[test]
318    fn first_added_account_becomes_both_defaults() {
319        let mut c = Config::default();
320        c.add_account(make_account("a@b.com"));
321        assert_eq!(c.defaults.send.as_deref(), Some("a@b.com"));
322        assert_eq!(c.defaults.calendar.as_deref(), Some("a@b.com"));
323    }
324
325    #[test]
326    fn second_added_account_does_not_change_defaults() {
327        let mut c = Config::default();
328        c.add_account(make_account("a@b.com"));
329        c.add_account(make_account("c@d.com"));
330        assert_eq!(c.defaults.send.as_deref(), Some("a@b.com"));
331        assert_eq!(c.defaults.calendar.as_deref(), Some("a@b.com"));
332        assert_eq!(c.accounts.len(), 2);
333    }
334
335    #[test]
336    fn removing_default_account_clears_default() {
337        let mut c = Config::default();
338        c.add_account(make_account("a@b.com"));
339        c.add_account(make_account("c@d.com"));
340        c.remove_account("a@b.com");
341        assert_eq!(c.defaults.send, None);
342        assert_eq!(c.defaults.calendar, None);
343    }
344
345    #[test]
346    fn set_default_send_for_unknown_account_errors() {
347        let mut c = Config::default();
348        c.add_account(make_account("a@b.com"));
349        assert!(matches!(
350            c.set_default_send("ghost@nowhere.com"),
351            Err(CoreError::UnknownAccount { .. })
352        ));
353    }
354
355    #[test]
356    fn config_roundtrips_through_file() {
357        let tmp = tempfile::TempDir::new().unwrap();
358        let path = tmp.path().join("config.yaml");
359
360        let mut c = Config::default();
361        c.add_account(make_account("a@b.com"));
362        c.add_account(make_account("c@d.com"));
363        c.set_default_calendar("c@d.com").unwrap();
364        c.save_to(&path).unwrap();
365
366        let c2 = Config::load_from(&path).unwrap();
367        assert_eq!(c, c2);
368    }
369
370    #[test]
371    fn add_trusted_sender_is_idempotent() {
372        let mut c = Config::default();
373        c.add_trusted_sender("a@b.com");
374        c.add_trusted_sender("a@b.com");
375        assert_eq!(c.trusted_senders.len(), 1);
376    }
377
378    #[test]
379    fn add_trusted_sender_is_case_insensitive() {
380        let mut c = Config::default();
381        c.add_trusted_sender("Maria@MKLab.se");
382        c.add_trusted_sender("maria@mklab.se");
383        assert_eq!(c.trusted_senders.len(), 1);
384    }
385
386    #[test]
387    fn remove_trusted_sender_returns_true_when_present() {
388        let mut c = Config::default();
389        c.add_trusted_sender("a@b.com");
390        assert!(c.remove_trusted_sender("a@b.com"));
391        assert!(c.trusted_senders.is_empty());
392    }
393
394    #[test]
395    fn remove_trusted_sender_returns_false_when_absent() {
396        let mut c = Config::default();
397        assert!(!c.remove_trusted_sender("ghost@nowhere.com"));
398    }
399
400    #[test]
401    fn remove_trusted_sender_is_case_insensitive() {
402        let mut c = Config::default();
403        c.add_trusted_sender("Maria@MKLab.se");
404        assert!(c.remove_trusted_sender("MARIA@mklab.SE"));
405        assert!(c.trusted_senders.is_empty());
406    }
407
408    #[test]
409    fn is_sender_trusted_case_insensitive() {
410        let mut c = Config::default();
411        c.add_trusted_sender("Maria@MKLab.se");
412        assert!(c.is_sender_trusted("maria@mklab.se"));
413        assert!(c.is_sender_trusted("MARIA@MKLAB.SE"));
414        assert!(!c.is_sender_trusted("anna@mklab.se"));
415    }
416
417    #[test]
418    fn config_with_missing_trusted_senders_loads_as_empty() {
419        let yaml = "accounts: []\ndefaults: {}\n";
420        let c: Config = serde_yaml::from_str(yaml).unwrap();
421        assert!(c.trusted_senders.is_empty());
422    }
423
424    #[test]
425    fn classify_config_defaults_are_empty() {
426        let c = Config::default();
427        assert!(c.classify.prompt.is_none());
428        assert!(c.classify.parallel.is_none());
429        assert!(c.classify.cache.is_none());
430        assert!(c.classify.labels.is_empty());
431    }
432
433    #[test]
434    fn classify_config_roundtrips_through_yaml() {
435        let mut c = Config::default();
436        c.classify.prompt = Some("Classify it".into());
437        c.classify.parallel = Some(8);
438        c.classify.cache = Some(true);
439        c.classify.labels = vec!["invoice".into(), "receipt".into()];
440        let yaml = serde_yaml::to_string(&c).unwrap();
441        let back: Config = serde_yaml::from_str(&yaml).unwrap();
442        assert_eq!(back.classify.prompt.as_deref(), Some("Classify it"));
443        assert_eq!(back.classify.parallel, Some(8));
444        assert_eq!(back.classify.labels, vec!["invoice", "receipt"]);
445    }
446
447    #[test]
448    fn config_set_get_unset_roundtrip() {
449        let mut c = Config::default();
450        c.set_key("classify.parallel", "8").unwrap();
451        assert_eq!(c.get_key("classify.parallel"), Some("8".to_string()));
452        c.set_key("classify.labels", "invoice,receipt,ticket")
453            .unwrap();
454        assert_eq!(
455            c.get_key("classify.labels"),
456            Some("invoice,receipt,ticket".to_string())
457        );
458        c.set_key("classify.cache", "true").unwrap();
459        assert_eq!(c.get_key("classify.cache"), Some("true".to_string()));
460        c.unset_key("classify.parallel").unwrap();
461        assert_eq!(c.get_key("classify.parallel"), None);
462    }
463
464    #[test]
465    fn config_set_rejects_unknown_key_and_bad_value() {
466        let mut c = Config::default();
467        assert!(c.set_key("classify.nope", "x").is_err());
468        assert!(c.set_key("classify.parallel", "notanumber").is_err());
469        assert!(c.set_key("classify.cache", "maybe").is_err());
470    }
471}