use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::account::Account;
use crate::error::CoreError;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub accounts: Vec<Account>,
pub defaults: Defaults,
pub trusted_senders: Vec<String>,
pub classify: ClassifyConfig,
#[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub guardrails: std::collections::BTreeMap<String, String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Defaults {
pub send: Option<String>,
pub calendar: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ClassifyConfig {
pub prompt: Option<String>,
pub parallel: Option<usize>,
pub cache: Option<bool>,
pub labels: Vec<String>,
}
impl Config {
pub fn default_path() -> Result<PathBuf, CoreError> {
let dir = dirs::config_dir()
.ok_or(CoreError::NoConfigDir)?
.join("pidge");
std::fs::create_dir_all(&dir)?;
Ok(dir.join("config.yaml"))
}
pub fn load() -> Result<Self, CoreError> {
let path = Self::default_path()?;
Self::load_from(&path)
}
pub fn load_from(path: &Path) -> Result<Self, CoreError> {
if !path.exists() {
return Ok(Self::default());
}
let text = std::fs::read_to_string(path)?;
Ok(serde_norway::from_str(&text)?)
}
pub fn save(&self) -> Result<(), CoreError> {
let path = Self::default_path()?;
self.save_to(&path)
}
pub fn save_to(&self, path: &Path) -> Result<(), CoreError> {
let text = serde_norway::to_string(self)?;
let tmp = path.with_extension("yaml.tmp");
std::fs::write(&tmp, text)?;
std::fs::rename(&tmp, path)?;
Ok(())
}
pub fn add_account(&mut self, account: Account) {
if let Some(existing) = self.accounts.iter_mut().find(|a| a.email == account.email) {
*existing = account;
return;
}
if self.accounts.is_empty() {
self.defaults.send = Some(account.email.clone());
self.defaults.calendar = Some(account.email.clone());
}
self.accounts.push(account);
}
pub fn remove_account(&mut self, email: &str) -> Option<Account> {
let idx = self.accounts.iter().position(|a| a.email == email)?;
let removed = self.accounts.remove(idx);
if self.defaults.send.as_deref() == Some(email) {
self.defaults.send = None;
}
if self.defaults.calendar.as_deref() == Some(email) {
self.defaults.calendar = None;
}
Some(removed)
}
pub fn set_default_send(&mut self, email: &str) -> Result<(), CoreError> {
if !self.accounts.iter().any(|a| a.email == email) {
return Err(CoreError::UnknownAccount {
email: email.to_string(),
});
}
self.defaults.send = Some(email.to_string());
Ok(())
}
pub fn set_default_calendar(&mut self, email: &str) -> Result<(), CoreError> {
if !self.accounts.iter().any(|a| a.email == email) {
return Err(CoreError::UnknownAccount {
email: email.to_string(),
});
}
self.defaults.calendar = Some(email.to_string());
Ok(())
}
pub fn find(&self, email: &str) -> Option<&Account> {
self.accounts.iter().find(|a| a.email == email)
}
pub fn add_trusted_sender(&mut self, email: &str) {
let lower = email.to_lowercase();
if !self
.trusted_senders
.iter()
.any(|s| s.to_lowercase() == lower)
{
self.trusted_senders.push(email.to_string());
}
}
pub fn remove_trusted_sender(&mut self, email: &str) -> bool {
let lower = email.to_lowercase();
let before = self.trusted_senders.len();
self.trusted_senders.retain(|s| s.to_lowercase() != lower);
before != self.trusted_senders.len()
}
pub fn is_sender_trusted(&self, email: &str) -> bool {
let lower = email.to_lowercase();
self.trusted_senders
.iter()
.any(|s| s.to_lowercase() == lower)
}
pub fn get_key(&self, key: &str) -> Option<String> {
match key {
"classify.prompt" => self.classify.prompt.clone(),
"classify.parallel" => self.classify.parallel.map(|n| n.to_string()),
"classify.cache" => self.classify.cache.map(|b| b.to_string()),
"classify.labels" => {
if self.classify.labels.is_empty() {
None
} else {
Some(self.classify.labels.join(","))
}
}
_ => {
if let Some(class) = key.strip_prefix("guardrails.") {
return self.guardrails.get(class).cloned();
}
None
}
}
}
pub const GUARDRAIL_CLASSES: [&'static str; 6] =
["send", "delete", "cancel", "rsvp", "bulk", "unsubscribe"];
pub fn set_key(&mut self, key: &str, value: &str) -> Result<(), CoreError> {
match key {
"classify.prompt" => self.classify.prompt = Some(value.to_string()),
"classify.parallel" => {
let n: usize = value
.trim()
.parse()
.map_err(|_| CoreError::InvalidConfigValue {
key: key.to_string(),
value: value.to_string(),
})?;
self.classify.parallel = Some(n);
}
"classify.cache" => {
let b = match value.trim() {
"true" => true,
"false" => false,
_ => {
return Err(CoreError::InvalidConfigValue {
key: key.to_string(),
value: value.to_string(),
});
}
};
self.classify.cache = Some(b);
}
"classify.labels" => {
self.classify.labels = value
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
_ => {
if let Some(class) = key.strip_prefix("guardrails.") {
if !Self::GUARDRAIL_CLASSES.contains(&class) {
return Err(CoreError::UnknownConfigKey {
key: key.to_string(),
});
}
if !["allow", "confirm", "deny"].contains(&value.trim()) {
return Err(CoreError::InvalidConfigValue {
key: key.to_string(),
value: value.to_string(),
});
}
self.guardrails
.insert(class.to_string(), value.trim().to_string());
return Ok(());
}
return Err(CoreError::UnknownConfigKey {
key: key.to_string(),
});
}
}
Ok(())
}
pub fn unset_key(&mut self, key: &str) -> Result<(), CoreError> {
match key {
"classify.prompt" => self.classify.prompt = None,
"classify.parallel" => self.classify.parallel = None,
"classify.cache" => self.classify.cache = None,
"classify.labels" => self.classify.labels.clear(),
_ => {
if let Some(class) = key.strip_prefix("guardrails.") {
if !Self::GUARDRAIL_CLASSES.contains(&class) {
return Err(CoreError::UnknownConfigKey {
key: key.to_string(),
});
}
self.guardrails.remove(class);
return Ok(());
}
return Err(CoreError::UnknownConfigKey {
key: key.to_string(),
});
}
}
Ok(())
}
pub const KNOWN_KEYS: &'static [&'static str] = &[
"classify.prompt",
"classify.parallel",
"classify.cache",
"classify.labels",
];
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn make_account(email: &str) -> Account {
Account {
email: email.into(),
tenant_id: "tid".into(),
home_account_id: "home".into(),
added_at: chrono::Utc.with_ymd_and_hms(2026, 5, 13, 22, 0, 0).unwrap(),
storage: crate::TokenStorage::default(),
}
}
#[test]
fn empty_config_serializes_and_deserializes() {
let c = Config::default();
let yaml = serde_norway::to_string(&c).unwrap();
let c2: Config = serde_norway::from_str(&yaml).unwrap();
assert_eq!(c, c2);
}
fn golden_config() -> Config {
let mut c = Config::default();
c.add_account(make_account("a@b.com"));
let mut file_acct = make_account("personal@outlook.com");
file_acct.tenant_id = Account::PERSONAL_MSA_TENANT.into();
file_acct.storage = crate::TokenStorage::File;
c.add_account(file_acct);
c.set_default_calendar("personal@outlook.com").unwrap();
c.trusted_senders = vec!["maria@mklab.se".into(), "yes".into(), "".into()];
c.classify.prompt = Some("Classify: invoice or receipt?\nAnswer with one word.\n".into());
c.classify.parallel = Some(8);
c.classify.cache = Some(false);
c.classify.labels = vec!["invoice".into(), "null".into(), "1.0".into()];
c.guardrails.insert("send".into(), "confirm".into());
c.guardrails.insert("delete".into(), "deny".into());
c
}
#[test]
fn config_yaml_output_is_byte_stable() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("config.yaml");
golden_config().save_to(&path).unwrap();
let written = std::fs::read_to_string(&path).unwrap();
assert_eq!(written, GOLDEN_YAML);
assert_eq!(Config::load_from(&path).unwrap(), golden_config());
let empty = serde_norway::to_string(&Config::default()).unwrap();
assert_eq!(empty, GOLDEN_EMPTY_YAML);
}
const GOLDEN_YAML: &str = r"accounts:
- email: a@b.com
tenant_id: tid
home_account_id: home
added_at: 2026-05-13T22:00:00Z
storage: keychain
- email: personal@outlook.com
tenant_id: 9188040d-6c67-4c5b-b112-36a304b66dad
home_account_id: home
added_at: 2026-05-13T22:00:00Z
storage: file
defaults:
send: a@b.com
calendar: personal@outlook.com
trusted_senders:
- maria@mklab.se
- yes
- ''
classify:
prompt: |
Classify: invoice or receipt?
Answer with one word.
parallel: 8
cache: false
labels:
- invoice
- 'null'
- '1.0'
guardrails:
delete: deny
send: confirm
";
const GOLDEN_EMPTY_YAML: &str = r"accounts: []
defaults:
send: null
calendar: null
trusted_senders: []
classify:
prompt: null
parallel: null
cache: null
labels: []
";
#[test]
fn first_added_account_becomes_both_defaults() {
let mut c = Config::default();
c.add_account(make_account("a@b.com"));
assert_eq!(c.defaults.send.as_deref(), Some("a@b.com"));
assert_eq!(c.defaults.calendar.as_deref(), Some("a@b.com"));
}
#[test]
fn second_added_account_does_not_change_defaults() {
let mut c = Config::default();
c.add_account(make_account("a@b.com"));
c.add_account(make_account("c@d.com"));
assert_eq!(c.defaults.send.as_deref(), Some("a@b.com"));
assert_eq!(c.defaults.calendar.as_deref(), Some("a@b.com"));
assert_eq!(c.accounts.len(), 2);
}
#[test]
fn removing_default_account_clears_default() {
let mut c = Config::default();
c.add_account(make_account("a@b.com"));
c.add_account(make_account("c@d.com"));
c.remove_account("a@b.com");
assert_eq!(c.defaults.send, None);
assert_eq!(c.defaults.calendar, None);
}
#[test]
fn set_default_send_for_unknown_account_errors() {
let mut c = Config::default();
c.add_account(make_account("a@b.com"));
assert!(matches!(
c.set_default_send("ghost@nowhere.com"),
Err(CoreError::UnknownAccount { .. })
));
}
#[test]
fn config_roundtrips_through_file() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("config.yaml");
let mut c = Config::default();
c.add_account(make_account("a@b.com"));
c.add_account(make_account("c@d.com"));
c.set_default_calendar("c@d.com").unwrap();
c.save_to(&path).unwrap();
let c2 = Config::load_from(&path).unwrap();
assert_eq!(c, c2);
}
#[test]
fn add_trusted_sender_is_idempotent() {
let mut c = Config::default();
c.add_trusted_sender("a@b.com");
c.add_trusted_sender("a@b.com");
assert_eq!(c.trusted_senders.len(), 1);
}
#[test]
fn add_trusted_sender_is_case_insensitive() {
let mut c = Config::default();
c.add_trusted_sender("Maria@MKLab.se");
c.add_trusted_sender("maria@mklab.se");
assert_eq!(c.trusted_senders.len(), 1);
}
#[test]
fn remove_trusted_sender_returns_true_when_present() {
let mut c = Config::default();
c.add_trusted_sender("a@b.com");
assert!(c.remove_trusted_sender("a@b.com"));
assert!(c.trusted_senders.is_empty());
}
#[test]
fn remove_trusted_sender_returns_false_when_absent() {
let mut c = Config::default();
assert!(!c.remove_trusted_sender("ghost@nowhere.com"));
}
#[test]
fn remove_trusted_sender_is_case_insensitive() {
let mut c = Config::default();
c.add_trusted_sender("Maria@MKLab.se");
assert!(c.remove_trusted_sender("MARIA@mklab.SE"));
assert!(c.trusted_senders.is_empty());
}
#[test]
fn is_sender_trusted_case_insensitive() {
let mut c = Config::default();
c.add_trusted_sender("Maria@MKLab.se");
assert!(c.is_sender_trusted("maria@mklab.se"));
assert!(c.is_sender_trusted("MARIA@MKLAB.SE"));
assert!(!c.is_sender_trusted("anna@mklab.se"));
}
#[test]
fn config_with_missing_trusted_senders_loads_as_empty() {
let yaml = "accounts: []\ndefaults: {}\n";
let c: Config = serde_norway::from_str(yaml).unwrap();
assert!(c.trusted_senders.is_empty());
}
#[test]
fn classify_config_defaults_are_empty() {
let c = Config::default();
assert!(c.classify.prompt.is_none());
assert!(c.classify.parallel.is_none());
assert!(c.classify.cache.is_none());
assert!(c.classify.labels.is_empty());
}
#[test]
fn classify_config_roundtrips_through_yaml() {
let mut c = Config::default();
c.classify.prompt = Some("Classify it".into());
c.classify.parallel = Some(8);
c.classify.cache = Some(true);
c.classify.labels = vec!["invoice".into(), "receipt".into()];
let yaml = serde_norway::to_string(&c).unwrap();
let back: Config = serde_norway::from_str(&yaml).unwrap();
assert_eq!(back.classify.prompt.as_deref(), Some("Classify it"));
assert_eq!(back.classify.parallel, Some(8));
assert_eq!(back.classify.labels, vec!["invoice", "receipt"]);
}
#[test]
fn config_set_get_unset_roundtrip() {
let mut c = Config::default();
c.set_key("classify.parallel", "8").unwrap();
assert_eq!(c.get_key("classify.parallel"), Some("8".to_string()));
c.set_key("classify.labels", "invoice,receipt,ticket")
.unwrap();
assert_eq!(
c.get_key("classify.labels"),
Some("invoice,receipt,ticket".to_string())
);
c.set_key("classify.cache", "true").unwrap();
assert_eq!(c.get_key("classify.cache"), Some("true".to_string()));
c.unset_key("classify.parallel").unwrap();
assert_eq!(c.get_key("classify.parallel"), None);
}
#[test]
fn config_set_rejects_unknown_key_and_bad_value() {
let mut c = Config::default();
assert!(c.set_key("classify.nope", "x").is_err());
assert!(c.set_key("classify.parallel", "notanumber").is_err());
assert!(c.set_key("classify.cache", "maybe").is_err());
}
}