use crate::actions::bitmagnet::BitmagnetConfig; use crate::actions::transmission::TransmissionConfig; use crate::app::Enableable; use crate::args::Args; use crate::config::types::{ImdbId, RedditUsername, TitleFilter}; use crate::config::validation::Validate; use crate::errors::{self}; use crate::notifications::ntfy::NtfyConfig; use color_eyre::eyre::Result; use directories::ProjectDirs; use figment::providers::Env; use figment::{ providers::{Format, Toml}, Figment, }; use figment_file_provider_adapter::FileAdapter; use log::debug; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; /// Configuration for a Reddit source #[derive(Debug, Serialize, Deserialize)] pub struct SourceConfig { pub username: RedditUsername, pub title_filter: Option, pub imdb_id: Option, #[serde(default)] pub tags: Vec, } /// Main application configuration #[derive(Debug, Serialize, Deserialize)] pub struct Config { #[serde(default)] pub bitmagnet: Option, #[serde(default)] pub transmission: Option, #[serde(default)] pub ntfy: Option, #[serde(default)] pub sources: BTreeMap, } impl Config { /// Validates the entire configuration pub fn validate(&self) -> Result<()> { if self.sources.is_empty() { return Err(errors::config_error( "No sources found in configuration", "Please add at least one source to your configuration file", )); } for (name, source) in &self.sources { self.validate_component(source, &format!("Invalid source configuration: {}", name))?; } self.validate_optional_component(&self.bitmagnet, "Invalid Bitmagnet configuration")?; self.validate_optional_component(&self.transmission, "Invalid Transmission configuration")?; self.validate_optional_component(&self.ntfy, "Invalid Ntfy configuration")?; let has_enabled_action = (self.bitmagnet.as_ref().is_some_and(|c| c.is_enabled())) || (self.transmission.as_ref().is_some_and(|c| c.is_enabled())); if !has_enabled_action { return Err(errors::config_error( "No actions are enabled", "Please enable at least one action in your configuration", )); } Ok(()) } /// Helper method to validate a component fn validate_component(&self, component: &T, error_context: &str) -> Result<()> { component .validate() .map_err(|e| errors::config_error(e, error_context)) } /// Helper method to validate an optional component fn validate_optional_component( &self, component: &Option, error_context: &str, ) -> Result<()> { if let Some(component) = component { component .validate_if_enabled() .map_err(|e| errors::config_error(e, error_context))?; } Ok(()) } } /// Loads the configuration from the specified file or default location pub fn load_config(args: &Args) -> Result { let mut conf_extractor = Figment::new(); let config_file_path: Option = match &args.config { Some(path) => Some(Path::new(path).to_path_buf()), None => ProjectDirs::from("fr", "enoent", "reddit-magnet") .map(|p| p.config_dir().join("config.toml")), }; match config_file_path { Some(path) => { if path.exists() { debug!("Reading configuration from {:?}", path); conf_extractor = conf_extractor.merge(FileAdapter::wrap(Toml::file_exact(path))); } else { debug!("Configuration file doesn't exist at {:?}", path); } } None => { debug!("No configuration file specified, using default configuration"); } } let conf: Config = conf_extractor .merge(FileAdapter::wrap(Env::prefixed("REDDIT_MAGNET_"))) .extract() .map_err(|e| { errors::config_error( e, "Invalid configuration or insufficient command line arguments", ) })?; // Validate the configuration conf.validate()?; Ok(conf) } /// Gets the database path from the command line arguments or default location pub fn get_db_path(args: &Args) -> Result { match &args.db { Some(path) => Ok(PathBuf::from(path)), None => ProjectDirs::from("fr", "enoent", "reddit-magnet") .map(|p| p.data_dir().join("reddit-magnet.db")) .ok_or_else(|| { errors::config_error("Could not determine data directory", "Database path") }), } } #[cfg(test)] mod tests { use super::*; use crate::config::types::Host; use insta::assert_debug_snapshot; use std::path::Path; /// Helper function to create a valid configuration for testing fn create_valid_config() -> Result { let mut sources = BTreeMap::new(); sources.insert( "test".to_string(), SourceConfig { username: RedditUsername::try_new("user123")?, title_filter: None, imdb_id: None, tags: vec![], }, ); Ok(Config { bitmagnet: Some(BitmagnetConfig { enable: true, host: Host::try_new("http://localhost:8080")?, }), transmission: None, ntfy: None, sources, }) } #[test] fn test_valid_config() -> Result<()> { let config = create_valid_config()?; assert!(config.validate().is_ok()); Ok(()) } #[test] fn test_config_with_no_sources() -> Result<()> { let mut invalid_config = create_valid_config()?; invalid_config.sources = BTreeMap::new(); assert!(invalid_config.validate().is_err()); Ok(()) } #[test] fn test_config_with_no_enabled_actions() -> Result<()> { let mut invalid_config = create_valid_config()?; if let Some(bitmagnet) = &mut invalid_config.bitmagnet { bitmagnet.enable = false; } assert!(invalid_config.validate().is_err()); Ok(()) } #[test] fn test_invalid_source_username() -> Result<()> { match RedditUsername::try_new("u") { Ok(_) => panic!("Expected error for short username"), Err(e) => { assert!(e.to_string().contains("RedditUsername is too short. The value length must be more than 3 character(s).")); } } Ok(()) } #[test] fn test_config_parsing_snapshot() -> Result<()> { let fixture_path = Path::new("src/config/fixtures/test_config.toml"); let config_content = std::fs::read_to_string(fixture_path)?; let config: Config = Figment::new() .merge(Toml::string(&config_content)) .extract()?; assert_debug_snapshot!("config_parsing", config); Ok(()) } }