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}
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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(default)]
33pub struct ClassifyConfig {
34 pub prompt: Option<String>,
36 pub parallel: Option<usize>,
38 pub cache: Option<bool>,
40 pub labels: Vec<String>,
42}
43
44impl Config {
45 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 pub fn load() -> Result<Self, CoreError> {
56 let path = Self::default_path()?;
57 Self::load_from(&path)
58 }
59
60 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 pub fn save(&self) -> Result<(), CoreError> {
71 let path = Self::default_path()?;
72 self.save_to(&path)
73 }
74
75 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 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 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 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 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 pub fn find(&self, email: &str) -> Option<&Account> {
134 self.accounts.iter().find(|a| a.email == email)
135 }
136
137 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 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 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 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 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 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 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}