//! Configuration loading and management for mail2couch //! //! This module handles loading configuration from JSON files with automatic //! file discovery, matching the behavior of the Go implementation. use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; use thiserror::Error; #[derive(Error, Debug)] pub enum ConfigError { #[error("IO error: {0}")] Io(#[from] std::io::Error), #[error("JSON parsing error: {0}")] Json(#[from] serde_json::Error), #[error("Configuration file not found. Searched: {paths:?}")] NotFound { paths: Vec }, #[error("Invalid mode '{mode}' for mail source '{name}': must be 'sync' or 'archive'")] InvalidMode { mode: String, name: String }, } /// Main configuration structure #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { #[serde(rename = "couchDb")] pub couch_db: CouchDbConfig, #[serde(rename = "mailSources")] pub mail_sources: Vec, } /// CouchDB connection configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CouchDbConfig { pub url: String, pub user: String, pub password: String, } /// Mail source configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MailSource { pub name: String, pub enabled: bool, pub protocol: String, pub host: String, pub port: u16, pub user: String, pub password: String, #[serde(default = "default_mode")] pub mode: String, #[serde(rename = "folderFilter", default)] pub folder_filter: FolderFilter, #[serde(rename = "messageFilter", default)] pub message_filter: MessageFilter, } /// Folder filtering configuration #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct FolderFilter { #[serde(default)] pub include: Vec, #[serde(default)] pub exclude: Vec, } /// Message filtering configuration #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct MessageFilter { #[serde(skip_serializing_if = "Option::is_none")] pub since: Option, #[serde(rename = "subjectKeywords", default)] pub subject_keywords: Vec, #[serde(rename = "senderKeywords", default)] pub sender_keywords: Vec, #[serde(rename = "recipientKeywords", default)] pub recipient_keywords: Vec, } impl MessageFilter { /// Check if this filter has any keyword-based filters that can use IMAP SEARCH pub fn has_keyword_filters(&self) -> bool { !self.subject_keywords.is_empty() || !self.sender_keywords.is_empty() // Note: recipient_keywords not included as IMAP SEARCH doesn't have a TO field search // that works reliably across all IMAP servers } } fn default_mode() -> String { "archive".to_string() } impl MailSource { /// Returns true if the mail source is in sync mode pub fn is_sync_mode(&self) -> bool { self.mode == "sync" } /// Returns true if the mail source is in archive mode pub fn is_archive_mode(&self) -> bool { self.mode == "archive" || self.mode.is_empty() } } /// Command line arguments #[derive(Debug, Clone)] pub struct CommandLineArgs { pub config_path: Option, pub max_messages: Option, pub dry_run: bool, pub generate_bash_completion: bool, pub help: bool, } impl Config { /// Load configuration from a file path pub fn load_from_path(path: &str) -> Result { let content = fs::read_to_string(path)?; let mut config: Config = serde_json::from_str(&content)?; // Validate and set defaults for mail sources for source in &mut config.mail_sources { if source.mode.is_empty() { source.mode = "archive".to_string(); } if source.mode != "sync" && source.mode != "archive" { return Err(ConfigError::InvalidMode { mode: source.mode.clone(), name: source.name.clone(), }); } } Ok(config) } /// Find configuration file in standard locations /// Searches in the same order as the Go implementation: /// 1. Path specified by command line argument /// 2. ./config.json (current directory) /// 3. ./config/config.json (config subdirectory) /// 4. ~/.config/mail2couch/config.json (user config directory) /// 5. ~/.mail2couch.json (user home directory) pub fn find_config_file(args: &CommandLineArgs) -> Result { // If a specific path was provided, check it first if let Some(path) = &args.config_path { let path_buf = PathBuf::from(path); if path_buf.exists() { return Ok(path_buf); } return Err(ConfigError::NotFound { paths: vec![path_buf], }); } // List of candidate locations let mut candidates = vec![ PathBuf::from("config.json"), PathBuf::from("config/config.json"), ]; // Add user directory paths if let Some(home_dir) = dirs::home_dir() { candidates.push(home_dir.join(".config").join("mail2couch").join("config.json")); candidates.push(home_dir.join(".mail2couch.json")); } // Try each candidate for candidate in &candidates { if candidate.exists() { return Ok(candidate.clone()); } } Err(ConfigError::NotFound { paths: candidates, }) } /// Load configuration with automatic file discovery pub fn load_with_discovery(args: &CommandLineArgs) -> Result<(Self, PathBuf), ConfigError> { let config_path = Self::find_config_file(args)?; let config = Self::load_from_path(config_path.to_str().unwrap())?; Ok((config, config_path)) } } #[cfg(test)] mod tests { use super::*; use std::fs; use tempfile::tempdir; #[test] fn test_config_parsing() { let config_json = r#" { "couchDb": { "url": "http://localhost:5984", "user": "admin", "password": "password" }, "mailSources": [ { "name": "Test Account", "enabled": true, "protocol": "imap", "host": "imap.example.com", "port": 993, "user": "test@example.com", "password": "testpass", "mode": "archive", "folderFilter": { "include": ["INBOX", "Sent"], "exclude": ["Trash"] }, "messageFilter": { "since": "2024-01-01", "subjectKeywords": ["urgent"] } } ] } "#; let config: Config = serde_json::from_str(config_json).unwrap(); assert_eq!(config.couch_db.url, "http://localhost:5984"); assert_eq!(config.mail_sources.len(), 1); let source = &config.mail_sources[0]; assert_eq!(source.name, "Test Account"); assert_eq!(source.mode, "archive"); assert!(source.is_archive_mode()); assert!(!source.is_sync_mode()); } #[test] fn test_default_mode() { let config_json = r#" { "couchDb": { "url": "http://localhost:5984", "user": "admin", "password": "password" }, "mailSources": [ { "name": "Test Account", "enabled": true, "protocol": "imap", "host": "imap.example.com", "port": 993, "user": "test@example.com", "password": "testpass" } ] } "#; let config: Config = serde_json::from_str(config_json).unwrap(); let source = &config.mail_sources[0]; assert_eq!(source.mode, "archive"); } #[test] fn test_config_file_discovery() { let temp_dir = tempdir().unwrap(); let config_path = temp_dir.path().join("config.json"); let config_content = r#" { "couchDb": {"url": "http://localhost:5984", "user": "admin", "password": "password"}, "mailSources": [] } "#; fs::write(&config_path, config_content).unwrap(); // Change to temp directory for relative path test let original_dir = std::env::current_dir().unwrap(); std::env::set_current_dir(&temp_dir).unwrap(); let args = CommandLineArgs { config_path: None, max_messages: None, dry_run: false, generate_bash_completion: false, help: false, }; let found_path = Config::find_config_file(&args).unwrap(); assert_eq!(found_path, PathBuf::from("config.json")); // Restore original directory std::env::set_current_dir(original_dir).unwrap(); } }