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}
20
21#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(default)]
23pub struct Defaults {
24    pub send: Option<String>,
25    pub calendar: Option<String>,
26}
27
28impl Config {
29    /// Default path: `${XDG_CONFIG_HOME:-~/.config}/pidge/config.yaml`.
30    pub fn default_path() -> Result<PathBuf, CoreError> {
31        let dir = dirs::config_dir()
32            .ok_or(CoreError::NoConfigDir)?
33            .join("pidge");
34        std::fs::create_dir_all(&dir)?;
35        Ok(dir.join("config.yaml"))
36    }
37
38    /// Load the config from the default path. If the file doesn't exist, returns `Config::default()`.
39    pub fn load() -> Result<Self, CoreError> {
40        let path = Self::default_path()?;
41        Self::load_from(&path)
42    }
43
44    /// Load from a specific path. Useful for tests.
45    pub fn load_from(path: &Path) -> Result<Self, CoreError> {
46        if !path.exists() {
47            return Ok(Self::default());
48        }
49        let text = std::fs::read_to_string(path)?;
50        Ok(serde_yaml::from_str(&text)?)
51    }
52
53    /// Save the config to the default path.
54    pub fn save(&self) -> Result<(), CoreError> {
55        let path = Self::default_path()?;
56        self.save_to(&path)
57    }
58
59    /// Save to a specific path. Useful for tests.
60    pub fn save_to(&self, path: &Path) -> Result<(), CoreError> {
61        let text = serde_yaml::to_string(self)?;
62        std::fs::write(path, text)?;
63        Ok(())
64    }
65
66    /// Add or replace an account by email. If this is the first account,
67    /// also sets it as the default send AND default calendar account.
68    pub fn add_account(&mut self, account: Account) {
69        if let Some(existing) = self.accounts.iter_mut().find(|a| a.email == account.email) {
70            *existing = account;
71            return;
72        }
73        if self.accounts.is_empty() {
74            self.defaults.send = Some(account.email.clone());
75            self.defaults.calendar = Some(account.email.clone());
76        }
77        self.accounts.push(account);
78    }
79
80    /// Remove an account by email. Returns the removed account.
81    /// If the removed account was a default, that default is cleared.
82    pub fn remove_account(&mut self, email: &str) -> Option<Account> {
83        let idx = self.accounts.iter().position(|a| a.email == email)?;
84        let removed = self.accounts.remove(idx);
85        if self.defaults.send.as_deref() == Some(email) {
86            self.defaults.send = None;
87        }
88        if self.defaults.calendar.as_deref() == Some(email) {
89            self.defaults.calendar = None;
90        }
91        Some(removed)
92    }
93
94    /// Set the default send account. Errors if the email isn't a known signed-in account.
95    pub fn set_default_send(&mut self, email: &str) -> Result<(), CoreError> {
96        if !self.accounts.iter().any(|a| a.email == email) {
97            return Err(CoreError::UnknownAccount {
98                email: email.to_string(),
99            });
100        }
101        self.defaults.send = Some(email.to_string());
102        Ok(())
103    }
104
105    /// Set the default calendar account. Errors if the email isn't a known signed-in account.
106    pub fn set_default_calendar(&mut self, email: &str) -> Result<(), CoreError> {
107        if !self.accounts.iter().any(|a| a.email == email) {
108            return Err(CoreError::UnknownAccount {
109                email: email.to_string(),
110            });
111        }
112        self.defaults.calendar = Some(email.to_string());
113        Ok(())
114    }
115
116    /// Find an account by email.
117    pub fn find(&self, email: &str) -> Option<&Account> {
118        self.accounts.iter().find(|a| a.email == email)
119    }
120
121    /// Add an email to the trusted-senders list (case-insensitive). Idempotent.
122    pub fn add_trusted_sender(&mut self, email: &str) {
123        let lower = email.to_lowercase();
124        if !self
125            .trusted_senders
126            .iter()
127            .any(|s| s.to_lowercase() == lower)
128        {
129            self.trusted_senders.push(email.to_string());
130        }
131    }
132
133    /// Remove an email from the trusted-senders list (case-insensitive).
134    /// Returns true if it was present, false if it wasn't (idempotent either way).
135    pub fn remove_trusted_sender(&mut self, email: &str) -> bool {
136        let lower = email.to_lowercase();
137        let before = self.trusted_senders.len();
138        self.trusted_senders.retain(|s| s.to_lowercase() != lower);
139        before != self.trusted_senders.len()
140    }
141
142    /// Case-insensitive check for whether an email is in the trusted-senders list.
143    pub fn is_sender_trusted(&self, email: &str) -> bool {
144        let lower = email.to_lowercase();
145        self.trusted_senders
146            .iter()
147            .any(|s| s.to_lowercase() == lower)
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use chrono::TimeZone;
155
156    fn make_account(email: &str) -> Account {
157        Account {
158            email: email.into(),
159            tenant_id: "tid".into(),
160            home_account_id: "home".into(),
161            added_at: chrono::Utc.with_ymd_and_hms(2026, 5, 13, 22, 0, 0).unwrap(),
162            storage: crate::TokenStorage::default(),
163        }
164    }
165
166    #[test]
167    fn empty_config_serializes_and_deserializes() {
168        let c = Config::default();
169        let yaml = serde_yaml::to_string(&c).unwrap();
170        let c2: Config = serde_yaml::from_str(&yaml).unwrap();
171        assert_eq!(c, c2);
172    }
173
174    #[test]
175    fn first_added_account_becomes_both_defaults() {
176        let mut c = Config::default();
177        c.add_account(make_account("a@b.com"));
178        assert_eq!(c.defaults.send.as_deref(), Some("a@b.com"));
179        assert_eq!(c.defaults.calendar.as_deref(), Some("a@b.com"));
180    }
181
182    #[test]
183    fn second_added_account_does_not_change_defaults() {
184        let mut c = Config::default();
185        c.add_account(make_account("a@b.com"));
186        c.add_account(make_account("c@d.com"));
187        assert_eq!(c.defaults.send.as_deref(), Some("a@b.com"));
188        assert_eq!(c.defaults.calendar.as_deref(), Some("a@b.com"));
189        assert_eq!(c.accounts.len(), 2);
190    }
191
192    #[test]
193    fn removing_default_account_clears_default() {
194        let mut c = Config::default();
195        c.add_account(make_account("a@b.com"));
196        c.add_account(make_account("c@d.com"));
197        c.remove_account("a@b.com");
198        assert_eq!(c.defaults.send, None);
199        assert_eq!(c.defaults.calendar, None);
200    }
201
202    #[test]
203    fn set_default_send_for_unknown_account_errors() {
204        let mut c = Config::default();
205        c.add_account(make_account("a@b.com"));
206        assert!(matches!(
207            c.set_default_send("ghost@nowhere.com"),
208            Err(CoreError::UnknownAccount { .. })
209        ));
210    }
211
212    #[test]
213    fn config_roundtrips_through_file() {
214        let tmp = tempfile::TempDir::new().unwrap();
215        let path = tmp.path().join("config.yaml");
216
217        let mut c = Config::default();
218        c.add_account(make_account("a@b.com"));
219        c.add_account(make_account("c@d.com"));
220        c.set_default_calendar("c@d.com").unwrap();
221        c.save_to(&path).unwrap();
222
223        let c2 = Config::load_from(&path).unwrap();
224        assert_eq!(c, c2);
225    }
226
227    #[test]
228    fn add_trusted_sender_is_idempotent() {
229        let mut c = Config::default();
230        c.add_trusted_sender("a@b.com");
231        c.add_trusted_sender("a@b.com");
232        assert_eq!(c.trusted_senders.len(), 1);
233    }
234
235    #[test]
236    fn add_trusted_sender_is_case_insensitive() {
237        let mut c = Config::default();
238        c.add_trusted_sender("Maria@MKLab.se");
239        c.add_trusted_sender("maria@mklab.se");
240        assert_eq!(c.trusted_senders.len(), 1);
241    }
242
243    #[test]
244    fn remove_trusted_sender_returns_true_when_present() {
245        let mut c = Config::default();
246        c.add_trusted_sender("a@b.com");
247        assert!(c.remove_trusted_sender("a@b.com"));
248        assert!(c.trusted_senders.is_empty());
249    }
250
251    #[test]
252    fn remove_trusted_sender_returns_false_when_absent() {
253        let mut c = Config::default();
254        assert!(!c.remove_trusted_sender("ghost@nowhere.com"));
255    }
256
257    #[test]
258    fn remove_trusted_sender_is_case_insensitive() {
259        let mut c = Config::default();
260        c.add_trusted_sender("Maria@MKLab.se");
261        assert!(c.remove_trusted_sender("MARIA@mklab.SE"));
262        assert!(c.trusted_senders.is_empty());
263    }
264
265    #[test]
266    fn is_sender_trusted_case_insensitive() {
267        let mut c = Config::default();
268        c.add_trusted_sender("Maria@MKLab.se");
269        assert!(c.is_sender_trusted("maria@mklab.se"));
270        assert!(c.is_sender_trusted("MARIA@MKLAB.SE"));
271        assert!(!c.is_sender_trusted("anna@mklab.se"));
272    }
273
274    #[test]
275    fn config_with_missing_trusted_senders_loads_as_empty() {
276        let yaml = "accounts: []\ndefaults: {}\n";
277        let c: Config = serde_yaml::from_str(yaml).unwrap();
278        assert!(c.trusted_senders.is_empty());
279    }
280}