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        std::fs::write(path, text)?;
83        Ok(())
84    }
85
86    /// Add or replace an account by email. If this is the first account,
87    /// also sets it as the default send AND default calendar account.
88    pub fn add_account(&mut self, account: Account) {
89        if let Some(existing) = self.accounts.iter_mut().find(|a| a.email == account.email) {
90            *existing = account;
91            return;
92        }
93        if self.accounts.is_empty() {
94            self.defaults.send = Some(account.email.clone());
95            self.defaults.calendar = Some(account.email.clone());
96        }
97        self.accounts.push(account);
98    }
99
100    /// Remove an account by email. Returns the removed account.
101    /// If the removed account was a default, that default is cleared.
102    pub fn remove_account(&mut self, email: &str) -> Option<Account> {
103        let idx = self.accounts.iter().position(|a| a.email == email)?;
104        let removed = self.accounts.remove(idx);
105        if self.defaults.send.as_deref() == Some(email) {
106            self.defaults.send = None;
107        }
108        if self.defaults.calendar.as_deref() == Some(email) {
109            self.defaults.calendar = None;
110        }
111        Some(removed)
112    }
113
114    /// Set the default send account. Errors if the email isn't a known signed-in account.
115    pub fn set_default_send(&mut self, email: &str) -> Result<(), CoreError> {
116        if !self.accounts.iter().any(|a| a.email == email) {
117            return Err(CoreError::UnknownAccount {
118                email: email.to_string(),
119            });
120        }
121        self.defaults.send = Some(email.to_string());
122        Ok(())
123    }
124
125    /// Set the default calendar account. Errors if the email isn't a known signed-in account.
126    pub fn set_default_calendar(&mut self, email: &str) -> Result<(), CoreError> {
127        if !self.accounts.iter().any(|a| a.email == email) {
128            return Err(CoreError::UnknownAccount {
129                email: email.to_string(),
130            });
131        }
132        self.defaults.calendar = Some(email.to_string());
133        Ok(())
134    }
135
136    /// Find an account by email.
137    pub fn find(&self, email: &str) -> Option<&Account> {
138        self.accounts.iter().find(|a| a.email == email)
139    }
140
141    /// Add an email to the trusted-senders list (case-insensitive). Idempotent.
142    pub fn add_trusted_sender(&mut self, email: &str) {
143        let lower = email.to_lowercase();
144        if !self
145            .trusted_senders
146            .iter()
147            .any(|s| s.to_lowercase() == lower)
148        {
149            self.trusted_senders.push(email.to_string());
150        }
151    }
152
153    /// Remove an email from the trusted-senders list (case-insensitive).
154    /// Returns true if it was present, false if it wasn't (idempotent either way).
155    pub fn remove_trusted_sender(&mut self, email: &str) -> bool {
156        let lower = email.to_lowercase();
157        let before = self.trusted_senders.len();
158        self.trusted_senders.retain(|s| s.to_lowercase() != lower);
159        before != self.trusted_senders.len()
160    }
161
162    /// Case-insensitive check for whether an email is in the trusted-senders list.
163    pub fn is_sender_trusted(&self, email: &str) -> bool {
164        let lower = email.to_lowercase();
165        self.trusted_senders
166            .iter()
167            .any(|s| s.to_lowercase() == lower)
168    }
169
170    /// Read a dotted config key as a display string, or `None` if unset.
171    pub fn get_key(&self, key: &str) -> Option<String> {
172        match key {
173            "classify.prompt" => self.classify.prompt.clone(),
174            "classify.parallel" => self.classify.parallel.map(|n| n.to_string()),
175            "classify.cache" => self.classify.cache.map(|b| b.to_string()),
176            "classify.labels" => {
177                if self.classify.labels.is_empty() {
178                    None
179                } else {
180                    Some(self.classify.labels.join(","))
181                }
182            }
183            _ => {
184                if let Some(class) = key.strip_prefix("guardrails.") {
185                    return self.guardrails.get(class).cloned();
186                }
187                None
188            }
189        }
190    }
191
192    /// Valid guardrail action classes.
193    pub const GUARDRAIL_CLASSES: [&'static str; 6] =
194        ["send", "delete", "cancel", "rsvp", "bulk", "unsubscribe"];
195
196    /// Set a dotted config key from a string value. Errors on unknown key or
197    /// unparseable value.
198    pub fn set_key(&mut self, key: &str, value: &str) -> Result<(), CoreError> {
199        match key {
200            "classify.prompt" => self.classify.prompt = Some(value.to_string()),
201            "classify.parallel" => {
202                let n: usize = value
203                    .trim()
204                    .parse()
205                    .map_err(|_| CoreError::InvalidConfigValue {
206                        key: key.to_string(),
207                        value: value.to_string(),
208                    })?;
209                self.classify.parallel = Some(n);
210            }
211            "classify.cache" => {
212                let b = match value.trim() {
213                    "true" => true,
214                    "false" => false,
215                    _ => {
216                        return Err(CoreError::InvalidConfigValue {
217                            key: key.to_string(),
218                            value: value.to_string(),
219                        });
220                    }
221                };
222                self.classify.cache = Some(b);
223            }
224            "classify.labels" => {
225                self.classify.labels = value
226                    .split(',')
227                    .map(|s| s.trim().to_string())
228                    .filter(|s| !s.is_empty())
229                    .collect();
230            }
231            _ => {
232                if let Some(class) = key.strip_prefix("guardrails.") {
233                    if !Self::GUARDRAIL_CLASSES.contains(&class) {
234                        return Err(CoreError::UnknownConfigKey {
235                            key: key.to_string(),
236                        });
237                    }
238                    if !["allow", "confirm", "deny"].contains(&value.trim()) {
239                        return Err(CoreError::InvalidConfigValue {
240                            key: key.to_string(),
241                            value: value.to_string(),
242                        });
243                    }
244                    self.guardrails
245                        .insert(class.to_string(), value.trim().to_string());
246                    return Ok(());
247                }
248                return Err(CoreError::UnknownConfigKey {
249                    key: key.to_string(),
250                });
251            }
252        }
253        Ok(())
254    }
255
256    /// Revert a dotted config key to its unset/default state.
257    pub fn unset_key(&mut self, key: &str) -> Result<(), CoreError> {
258        match key {
259            "classify.prompt" => self.classify.prompt = None,
260            "classify.parallel" => self.classify.parallel = None,
261            "classify.cache" => self.classify.cache = None,
262            "classify.labels" => self.classify.labels.clear(),
263            _ => {
264                if let Some(class) = key.strip_prefix("guardrails.") {
265                    if !Self::GUARDRAIL_CLASSES.contains(&class) {
266                        return Err(CoreError::UnknownConfigKey {
267                            key: key.to_string(),
268                        });
269                    }
270                    self.guardrails.remove(class);
271                    return Ok(());
272                }
273                return Err(CoreError::UnknownConfigKey {
274                    key: key.to_string(),
275                });
276            }
277        }
278        Ok(())
279    }
280
281    /// Every settable config key, for `pidge config show`/help.
282    pub const KNOWN_KEYS: &'static [&'static str] = &[
283        "classify.prompt",
284        "classify.parallel",
285        "classify.cache",
286        "classify.labels",
287    ];
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293    use chrono::TimeZone;
294
295    fn make_account(email: &str) -> Account {
296        Account {
297            email: email.into(),
298            tenant_id: "tid".into(),
299            home_account_id: "home".into(),
300            added_at: chrono::Utc.with_ymd_and_hms(2026, 5, 13, 22, 0, 0).unwrap(),
301            storage: crate::TokenStorage::default(),
302        }
303    }
304
305    #[test]
306    fn empty_config_serializes_and_deserializes() {
307        let c = Config::default();
308        let yaml = serde_yaml::to_string(&c).unwrap();
309        let c2: Config = serde_yaml::from_str(&yaml).unwrap();
310        assert_eq!(c, c2);
311    }
312
313    #[test]
314    fn first_added_account_becomes_both_defaults() {
315        let mut c = Config::default();
316        c.add_account(make_account("a@b.com"));
317        assert_eq!(c.defaults.send.as_deref(), Some("a@b.com"));
318        assert_eq!(c.defaults.calendar.as_deref(), Some("a@b.com"));
319    }
320
321    #[test]
322    fn second_added_account_does_not_change_defaults() {
323        let mut c = Config::default();
324        c.add_account(make_account("a@b.com"));
325        c.add_account(make_account("c@d.com"));
326        assert_eq!(c.defaults.send.as_deref(), Some("a@b.com"));
327        assert_eq!(c.defaults.calendar.as_deref(), Some("a@b.com"));
328        assert_eq!(c.accounts.len(), 2);
329    }
330
331    #[test]
332    fn removing_default_account_clears_default() {
333        let mut c = Config::default();
334        c.add_account(make_account("a@b.com"));
335        c.add_account(make_account("c@d.com"));
336        c.remove_account("a@b.com");
337        assert_eq!(c.defaults.send, None);
338        assert_eq!(c.defaults.calendar, None);
339    }
340
341    #[test]
342    fn set_default_send_for_unknown_account_errors() {
343        let mut c = Config::default();
344        c.add_account(make_account("a@b.com"));
345        assert!(matches!(
346            c.set_default_send("ghost@nowhere.com"),
347            Err(CoreError::UnknownAccount { .. })
348        ));
349    }
350
351    #[test]
352    fn config_roundtrips_through_file() {
353        let tmp = tempfile::TempDir::new().unwrap();
354        let path = tmp.path().join("config.yaml");
355
356        let mut c = Config::default();
357        c.add_account(make_account("a@b.com"));
358        c.add_account(make_account("c@d.com"));
359        c.set_default_calendar("c@d.com").unwrap();
360        c.save_to(&path).unwrap();
361
362        let c2 = Config::load_from(&path).unwrap();
363        assert_eq!(c, c2);
364    }
365
366    #[test]
367    fn add_trusted_sender_is_idempotent() {
368        let mut c = Config::default();
369        c.add_trusted_sender("a@b.com");
370        c.add_trusted_sender("a@b.com");
371        assert_eq!(c.trusted_senders.len(), 1);
372    }
373
374    #[test]
375    fn add_trusted_sender_is_case_insensitive() {
376        let mut c = Config::default();
377        c.add_trusted_sender("Maria@MKLab.se");
378        c.add_trusted_sender("maria@mklab.se");
379        assert_eq!(c.trusted_senders.len(), 1);
380    }
381
382    #[test]
383    fn remove_trusted_sender_returns_true_when_present() {
384        let mut c = Config::default();
385        c.add_trusted_sender("a@b.com");
386        assert!(c.remove_trusted_sender("a@b.com"));
387        assert!(c.trusted_senders.is_empty());
388    }
389
390    #[test]
391    fn remove_trusted_sender_returns_false_when_absent() {
392        let mut c = Config::default();
393        assert!(!c.remove_trusted_sender("ghost@nowhere.com"));
394    }
395
396    #[test]
397    fn remove_trusted_sender_is_case_insensitive() {
398        let mut c = Config::default();
399        c.add_trusted_sender("Maria@MKLab.se");
400        assert!(c.remove_trusted_sender("MARIA@mklab.SE"));
401        assert!(c.trusted_senders.is_empty());
402    }
403
404    #[test]
405    fn is_sender_trusted_case_insensitive() {
406        let mut c = Config::default();
407        c.add_trusted_sender("Maria@MKLab.se");
408        assert!(c.is_sender_trusted("maria@mklab.se"));
409        assert!(c.is_sender_trusted("MARIA@MKLAB.SE"));
410        assert!(!c.is_sender_trusted("anna@mklab.se"));
411    }
412
413    #[test]
414    fn config_with_missing_trusted_senders_loads_as_empty() {
415        let yaml = "accounts: []\ndefaults: {}\n";
416        let c: Config = serde_yaml::from_str(yaml).unwrap();
417        assert!(c.trusted_senders.is_empty());
418    }
419
420    #[test]
421    fn classify_config_defaults_are_empty() {
422        let c = Config::default();
423        assert!(c.classify.prompt.is_none());
424        assert!(c.classify.parallel.is_none());
425        assert!(c.classify.cache.is_none());
426        assert!(c.classify.labels.is_empty());
427    }
428
429    #[test]
430    fn classify_config_roundtrips_through_yaml() {
431        let mut c = Config::default();
432        c.classify.prompt = Some("Classify it".into());
433        c.classify.parallel = Some(8);
434        c.classify.cache = Some(true);
435        c.classify.labels = vec!["invoice".into(), "receipt".into()];
436        let yaml = serde_yaml::to_string(&c).unwrap();
437        let back: Config = serde_yaml::from_str(&yaml).unwrap();
438        assert_eq!(back.classify.prompt.as_deref(), Some("Classify it"));
439        assert_eq!(back.classify.parallel, Some(8));
440        assert_eq!(back.classify.labels, vec!["invoice", "receipt"]);
441    }
442
443    #[test]
444    fn config_set_get_unset_roundtrip() {
445        let mut c = Config::default();
446        c.set_key("classify.parallel", "8").unwrap();
447        assert_eq!(c.get_key("classify.parallel"), Some("8".to_string()));
448        c.set_key("classify.labels", "invoice,receipt,ticket")
449            .unwrap();
450        assert_eq!(
451            c.get_key("classify.labels"),
452            Some("invoice,receipt,ticket".to_string())
453        );
454        c.set_key("classify.cache", "true").unwrap();
455        assert_eq!(c.get_key("classify.cache"), Some("true".to_string()));
456        c.unset_key("classify.parallel").unwrap();
457        assert_eq!(c.get_key("classify.parallel"), None);
458    }
459
460    #[test]
461    fn config_set_rejects_unknown_key_and_bad_value() {
462        let mut c = Config::default();
463        assert!(c.set_key("classify.nope", "x").is_err());
464        assert!(c.set_key("classify.parallel", "notanumber").is_err());
465        assert!(c.set_key("classify.cache", "maybe").is_err());
466    }
467}