1use 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 #[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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(default)]
37pub struct ClassifyConfig {
38 pub prompt: Option<String>,
40 pub parallel: Option<usize>,
42 pub cache: Option<bool>,
44 pub labels: Vec<String>,
46}
47
48impl Config {
49 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 pub fn load() -> Result<Self, CoreError> {
60 let path = Self::default_path()?;
61 Self::load_from(&path)
62 }
63
64 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 pub fn save(&self) -> Result<(), CoreError> {
75 let path = Self::default_path()?;
76 self.save_to(&path)
77 }
78
79 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 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 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 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 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 pub fn find(&self, email: &str) -> Option<&Account> {
138 self.accounts.iter().find(|a| a.email == email)
139 }
140
141 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 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 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 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 pub const GUARDRAIL_CLASSES: [&'static str; 6] =
194 ["send", "delete", "cancel", "rsvp", "bulk", "unsubscribe"];
195
196 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 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 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}