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}
21
22#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(default)]
24pub struct Defaults {
25    pub send: Option<String>,
26    pub calendar: Option<String>,
27}
28
29/// User-configurable defaults for `pidge ai classify`. Every field is
30/// optional; an unset field falls back to a built-in default at call time.
31#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(default)]
33pub struct ClassifyConfig {
34    /// Default classification prompt (instructions + valid outputs).
35    pub prompt: Option<String>,
36    /// Default batch concurrency.
37    pub parallel: Option<usize>,
38    /// Whether to cache classifications by message-id + prompt hash.
39    pub cache: Option<bool>,
40    /// Optional allowed-label set for validation.
41    pub labels: Vec<String>,
42}
43
44impl Config {
45    /// Default path: `${XDG_CONFIG_HOME:-~/.config}/pidge/config.yaml`.
46    pub fn default_path() -> Result<PathBuf, CoreError> {
47        let dir = dirs::config_dir()
48            .ok_or(CoreError::NoConfigDir)?
49            .join("pidge");
50        std::fs::create_dir_all(&dir)?;
51        Ok(dir.join("config.yaml"))
52    }
53
54    /// Load the config from the default path. If the file doesn't exist, returns `Config::default()`.
55    pub fn load() -> Result<Self, CoreError> {
56        let path = Self::default_path()?;
57        Self::load_from(&path)
58    }
59
60    /// Load from a specific path. Useful for tests.
61    pub fn load_from(path: &Path) -> Result<Self, CoreError> {
62        if !path.exists() {
63            return Ok(Self::default());
64        }
65        let text = std::fs::read_to_string(path)?;
66        Ok(serde_yaml::from_str(&text)?)
67    }
68
69    /// Save the config to the default path.
70    pub fn save(&self) -> Result<(), CoreError> {
71        let path = Self::default_path()?;
72        self.save_to(&path)
73    }
74
75    /// Save to a specific path. Useful for tests.
76    pub fn save_to(&self, path: &Path) -> Result<(), CoreError> {
77        let text = serde_yaml::to_string(self)?;
78        std::fs::write(path, text)?;
79        Ok(())
80    }
81
82    /// Add or replace an account by email. If this is the first account,
83    /// also sets it as the default send AND default calendar account.
84    pub fn add_account(&mut self, account: Account) {
85        if let Some(existing) = self.accounts.iter_mut().find(|a| a.email == account.email) {
86            *existing = account;
87            return;
88        }
89        if self.accounts.is_empty() {
90            self.defaults.send = Some(account.email.clone());
91            self.defaults.calendar = Some(account.email.clone());
92        }
93        self.accounts.push(account);
94    }
95
96    /// Remove an account by email. Returns the removed account.
97    /// If the removed account was a default, that default is cleared.
98    pub fn remove_account(&mut self, email: &str) -> Option<Account> {
99        let idx = self.accounts.iter().position(|a| a.email == email)?;
100        let removed = self.accounts.remove(idx);
101        if self.defaults.send.as_deref() == Some(email) {
102            self.defaults.send = None;
103        }
104        if self.defaults.calendar.as_deref() == Some(email) {
105            self.defaults.calendar = None;
106        }
107        Some(removed)
108    }
109
110    /// Set the default send account. Errors if the email isn't a known signed-in account.
111    pub fn set_default_send(&mut self, email: &str) -> Result<(), CoreError> {
112        if !self.accounts.iter().any(|a| a.email == email) {
113            return Err(CoreError::UnknownAccount {
114                email: email.to_string(),
115            });
116        }
117        self.defaults.send = Some(email.to_string());
118        Ok(())
119    }
120
121    /// Set the default calendar account. Errors if the email isn't a known signed-in account.
122    pub fn set_default_calendar(&mut self, email: &str) -> Result<(), CoreError> {
123        if !self.accounts.iter().any(|a| a.email == email) {
124            return Err(CoreError::UnknownAccount {
125                email: email.to_string(),
126            });
127        }
128        self.defaults.calendar = Some(email.to_string());
129        Ok(())
130    }
131
132    /// Find an account by email.
133    pub fn find(&self, email: &str) -> Option<&Account> {
134        self.accounts.iter().find(|a| a.email == email)
135    }
136
137    /// Add an email to the trusted-senders list (case-insensitive). Idempotent.
138    pub fn add_trusted_sender(&mut self, email: &str) {
139        let lower = email.to_lowercase();
140        if !self
141            .trusted_senders
142            .iter()
143            .any(|s| s.to_lowercase() == lower)
144        {
145            self.trusted_senders.push(email.to_string());
146        }
147    }
148
149    /// Remove an email from the trusted-senders list (case-insensitive).
150    /// Returns true if it was present, false if it wasn't (idempotent either way).
151    pub fn remove_trusted_sender(&mut self, email: &str) -> bool {
152        let lower = email.to_lowercase();
153        let before = self.trusted_senders.len();
154        self.trusted_senders.retain(|s| s.to_lowercase() != lower);
155        before != self.trusted_senders.len()
156    }
157
158    /// Case-insensitive check for whether an email is in the trusted-senders list.
159    pub fn is_sender_trusted(&self, email: &str) -> bool {
160        let lower = email.to_lowercase();
161        self.trusted_senders
162            .iter()
163            .any(|s| s.to_lowercase() == lower)
164    }
165
166    /// Read a dotted config key as a display string, or `None` if unset.
167    pub fn get_key(&self, key: &str) -> Option<String> {
168        match key {
169            "classify.prompt" => self.classify.prompt.clone(),
170            "classify.parallel" => self.classify.parallel.map(|n| n.to_string()),
171            "classify.cache" => self.classify.cache.map(|b| b.to_string()),
172            "classify.labels" => {
173                if self.classify.labels.is_empty() {
174                    None
175                } else {
176                    Some(self.classify.labels.join(","))
177                }
178            }
179            _ => None,
180        }
181    }
182
183    /// Set a dotted config key from a string value. Errors on unknown key or
184    /// unparseable value.
185    pub fn set_key(&mut self, key: &str, value: &str) -> Result<(), CoreError> {
186        match key {
187            "classify.prompt" => self.classify.prompt = Some(value.to_string()),
188            "classify.parallel" => {
189                let n: usize = value
190                    .trim()
191                    .parse()
192                    .map_err(|_| CoreError::InvalidConfigValue {
193                        key: key.to_string(),
194                        value: value.to_string(),
195                    })?;
196                self.classify.parallel = Some(n);
197            }
198            "classify.cache" => {
199                let b = match value.trim() {
200                    "true" => true,
201                    "false" => false,
202                    _ => {
203                        return Err(CoreError::InvalidConfigValue {
204                            key: key.to_string(),
205                            value: value.to_string(),
206                        });
207                    }
208                };
209                self.classify.cache = Some(b);
210            }
211            "classify.labels" => {
212                self.classify.labels = value
213                    .split(',')
214                    .map(|s| s.trim().to_string())
215                    .filter(|s| !s.is_empty())
216                    .collect();
217            }
218            _ => {
219                return Err(CoreError::UnknownConfigKey {
220                    key: key.to_string(),
221                });
222            }
223        }
224        Ok(())
225    }
226
227    /// Revert a dotted config key to its unset/default state.
228    pub fn unset_key(&mut self, key: &str) -> Result<(), CoreError> {
229        match key {
230            "classify.prompt" => self.classify.prompt = None,
231            "classify.parallel" => self.classify.parallel = None,
232            "classify.cache" => self.classify.cache = None,
233            "classify.labels" => self.classify.labels.clear(),
234            _ => {
235                return Err(CoreError::UnknownConfigKey {
236                    key: key.to_string(),
237                });
238            }
239        }
240        Ok(())
241    }
242
243    /// Every settable config key, for `pidge config show`/help.
244    pub const KNOWN_KEYS: &'static [&'static str] = &[
245        "classify.prompt",
246        "classify.parallel",
247        "classify.cache",
248        "classify.labels",
249    ];
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use chrono::TimeZone;
256
257    fn make_account(email: &str) -> Account {
258        Account {
259            email: email.into(),
260            tenant_id: "tid".into(),
261            home_account_id: "home".into(),
262            added_at: chrono::Utc.with_ymd_and_hms(2026, 5, 13, 22, 0, 0).unwrap(),
263            storage: crate::TokenStorage::default(),
264        }
265    }
266
267    #[test]
268    fn empty_config_serializes_and_deserializes() {
269        let c = Config::default();
270        let yaml = serde_yaml::to_string(&c).unwrap();
271        let c2: Config = serde_yaml::from_str(&yaml).unwrap();
272        assert_eq!(c, c2);
273    }
274
275    #[test]
276    fn first_added_account_becomes_both_defaults() {
277        let mut c = Config::default();
278        c.add_account(make_account("a@b.com"));
279        assert_eq!(c.defaults.send.as_deref(), Some("a@b.com"));
280        assert_eq!(c.defaults.calendar.as_deref(), Some("a@b.com"));
281    }
282
283    #[test]
284    fn second_added_account_does_not_change_defaults() {
285        let mut c = Config::default();
286        c.add_account(make_account("a@b.com"));
287        c.add_account(make_account("c@d.com"));
288        assert_eq!(c.defaults.send.as_deref(), Some("a@b.com"));
289        assert_eq!(c.defaults.calendar.as_deref(), Some("a@b.com"));
290        assert_eq!(c.accounts.len(), 2);
291    }
292
293    #[test]
294    fn removing_default_account_clears_default() {
295        let mut c = Config::default();
296        c.add_account(make_account("a@b.com"));
297        c.add_account(make_account("c@d.com"));
298        c.remove_account("a@b.com");
299        assert_eq!(c.defaults.send, None);
300        assert_eq!(c.defaults.calendar, None);
301    }
302
303    #[test]
304    fn set_default_send_for_unknown_account_errors() {
305        let mut c = Config::default();
306        c.add_account(make_account("a@b.com"));
307        assert!(matches!(
308            c.set_default_send("ghost@nowhere.com"),
309            Err(CoreError::UnknownAccount { .. })
310        ));
311    }
312
313    #[test]
314    fn config_roundtrips_through_file() {
315        let tmp = tempfile::TempDir::new().unwrap();
316        let path = tmp.path().join("config.yaml");
317
318        let mut c = Config::default();
319        c.add_account(make_account("a@b.com"));
320        c.add_account(make_account("c@d.com"));
321        c.set_default_calendar("c@d.com").unwrap();
322        c.save_to(&path).unwrap();
323
324        let c2 = Config::load_from(&path).unwrap();
325        assert_eq!(c, c2);
326    }
327
328    #[test]
329    fn add_trusted_sender_is_idempotent() {
330        let mut c = Config::default();
331        c.add_trusted_sender("a@b.com");
332        c.add_trusted_sender("a@b.com");
333        assert_eq!(c.trusted_senders.len(), 1);
334    }
335
336    #[test]
337    fn add_trusted_sender_is_case_insensitive() {
338        let mut c = Config::default();
339        c.add_trusted_sender("Maria@MKLab.se");
340        c.add_trusted_sender("maria@mklab.se");
341        assert_eq!(c.trusted_senders.len(), 1);
342    }
343
344    #[test]
345    fn remove_trusted_sender_returns_true_when_present() {
346        let mut c = Config::default();
347        c.add_trusted_sender("a@b.com");
348        assert!(c.remove_trusted_sender("a@b.com"));
349        assert!(c.trusted_senders.is_empty());
350    }
351
352    #[test]
353    fn remove_trusted_sender_returns_false_when_absent() {
354        let mut c = Config::default();
355        assert!(!c.remove_trusted_sender("ghost@nowhere.com"));
356    }
357
358    #[test]
359    fn remove_trusted_sender_is_case_insensitive() {
360        let mut c = Config::default();
361        c.add_trusted_sender("Maria@MKLab.se");
362        assert!(c.remove_trusted_sender("MARIA@mklab.SE"));
363        assert!(c.trusted_senders.is_empty());
364    }
365
366    #[test]
367    fn is_sender_trusted_case_insensitive() {
368        let mut c = Config::default();
369        c.add_trusted_sender("Maria@MKLab.se");
370        assert!(c.is_sender_trusted("maria@mklab.se"));
371        assert!(c.is_sender_trusted("MARIA@MKLAB.SE"));
372        assert!(!c.is_sender_trusted("anna@mklab.se"));
373    }
374
375    #[test]
376    fn config_with_missing_trusted_senders_loads_as_empty() {
377        let yaml = "accounts: []\ndefaults: {}\n";
378        let c: Config = serde_yaml::from_str(yaml).unwrap();
379        assert!(c.trusted_senders.is_empty());
380    }
381
382    #[test]
383    fn classify_config_defaults_are_empty() {
384        let c = Config::default();
385        assert!(c.classify.prompt.is_none());
386        assert!(c.classify.parallel.is_none());
387        assert!(c.classify.cache.is_none());
388        assert!(c.classify.labels.is_empty());
389    }
390
391    #[test]
392    fn classify_config_roundtrips_through_yaml() {
393        let mut c = Config::default();
394        c.classify.prompt = Some("Classify it".into());
395        c.classify.parallel = Some(8);
396        c.classify.cache = Some(true);
397        c.classify.labels = vec!["invoice".into(), "receipt".into()];
398        let yaml = serde_yaml::to_string(&c).unwrap();
399        let back: Config = serde_yaml::from_str(&yaml).unwrap();
400        assert_eq!(back.classify.prompt.as_deref(), Some("Classify it"));
401        assert_eq!(back.classify.parallel, Some(8));
402        assert_eq!(back.classify.labels, vec!["invoice", "receipt"]);
403    }
404
405    #[test]
406    fn config_set_get_unset_roundtrip() {
407        let mut c = Config::default();
408        c.set_key("classify.parallel", "8").unwrap();
409        assert_eq!(c.get_key("classify.parallel"), Some("8".to_string()));
410        c.set_key("classify.labels", "invoice,receipt,ticket")
411            .unwrap();
412        assert_eq!(
413            c.get_key("classify.labels"),
414            Some("invoice,receipt,ticket".to_string())
415        );
416        c.set_key("classify.cache", "true").unwrap();
417        assert_eq!(c.get_key("classify.cache"), Some("true".to_string()));
418        c.unset_key("classify.parallel").unwrap();
419        assert_eq!(c.get_key("classify.parallel"), None);
420    }
421
422    #[test]
423    fn config_set_rejects_unknown_key_and_bad_value() {
424        let mut c = Config::default();
425        assert!(c.set_key("classify.nope", "x").is_err());
426        assert!(c.set_key("classify.parallel", "notanumber").is_err());
427        assert!(c.set_key("classify.cache", "maybe").is_err());
428    }
429}