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 let tmp = path.with_extension("yaml.tmp");
85 std::fs::write(&tmp, text)?;
86 std::fs::rename(&tmp, path)?;
87 Ok(())
88 }
89
90 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 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 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 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 pub fn find(&self, email: &str) -> Option<&Account> {
142 self.accounts.iter().find(|a| a.email == email)
143 }
144
145 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 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 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 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 pub const GUARDRAIL_CLASSES: [&'static str; 6] =
198 ["send", "delete", "cancel", "rsvp", "bulk", "unsubscribe"];
199
200 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 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 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}