mail2couch/rust/src/config.rs

298 lines
9.2 KiB
Rust
Raw Normal View History

//! 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<PathBuf> },
#[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<MailSource>,
}
/// 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<String>,
#[serde(default)]
pub exclude: Vec<String>,
}
/// Message filtering configuration
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MessageFilter {
#[serde(skip_serializing_if = "Option::is_none")]
pub since: Option<String>,
#[serde(rename = "subjectKeywords", default)]
pub subject_keywords: Vec<String>,
#[serde(rename = "senderKeywords", default)]
pub sender_keywords: Vec<String>,
#[serde(rename = "recipientKeywords", default)]
pub recipient_keywords: Vec<String>,
}
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<String>,
pub max_messages: Option<u32>,
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<Self, ConfigError> {
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<PathBuf, ConfigError> {
// 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();
}
}