diff --git a/Cargo.lock b/Cargo.lock index d78f27f..7a1656f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -312,6 +312,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -530,7 +539,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1107,6 +1116,17 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" +[[package]] +name = "insta" +version = "1.43.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "154934ea70c58054b556dd430b99a98c2a7ff5309ac9891597e339b5c28f4371" +dependencies = [ + "console", + "once_cell", + "similar", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1121,7 +1141,7 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1146,6 +1166,27 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kinded" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce4bdbb2f423660b19f0e9f7115182214732d8dd5f840cd0a3aee3e22562f34c" +dependencies = [ + "kinded_macros", +] + +[[package]] +name = "kinded_macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13b4ddc5dcb32f45dac3d6f606da2a52fdb9964a18427e63cd5ef6c0d13288d" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1324,6 +1365,31 @@ dependencies = [ "autocfg", ] +[[package]] +name = "nutype" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3340cb6773b0794ecb3f62ff66631d580f57151d9415c10ee8a27a357aeb998b" +dependencies = [ + "nutype_macros", +] + +[[package]] +name = "nutype_macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35c955e27d02868fe90b9c2dc901661fd7ed67ec382782bdc67c6aa8d2e957a9" +dependencies = [ + "cfg-if", + "kinded", + "proc-macro2", + "quote", + "regex", + "rustc_version", + "syn", + "urlencoding", +] + [[package]] name = "object" version = "0.32.2" @@ -1540,7 +1606,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1602,10 +1668,12 @@ dependencies = [ "directories", "figment", "figment_file_provider_adapter", + "insta", "log", "magnet-url", "multimap", "ntfy", + "nutype", "pretty_env_logger", "regex", "reqwest 0.12.15", @@ -1787,6 +1855,15 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.0.5" @@ -1797,7 +1874,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1896,6 +1973,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" + [[package]] name = "serde" version = "1.0.219" @@ -1975,6 +2058,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "slab" version = "0.4.9" @@ -2107,7 +2196,7 @@ dependencies = [ "getrandom 0.3.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2443,6 +2532,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + [[package]] name = "unicode-width" version = "0.2.0" @@ -2639,7 +2734,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 454bc95..f145a3b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,3 +34,5 @@ reqwest = "0.12.15" magnet-url = "2.0.0" urlencoding = "2.1.3" ntfy = "0.7.0" +nutype = { version = "0.6.1", features = ["serde", "regex"] } +insta = "1.43.1" diff --git a/flake.nix b/flake.nix index 5310c46..1247dc7 100644 --- a/flake.nix +++ b/flake.nix @@ -28,7 +28,6 @@ rustPlatform.rustcSrc ] ++ lib.optionals stdenv.isDarwin [ libiconv - darwin.apple_sdk.frameworks.SystemConfiguration ]; }; in @@ -101,6 +100,7 @@ buildInputs = with pkgs; [ cargo cargo-edit + cargo-insta cargo-machete cargo-release cargo-sort @@ -114,7 +114,6 @@ sqlite ] ++ lib.optionals stdenv.isDarwin [ libiconv - darwin.apple_sdk.frameworks.SystemConfiguration ] ++ self.checks.${system}.pre-commit-check.enabledPackages; RUST_BACKTRACE = 1; diff --git a/src/actions/bitmagnet/config.rs b/src/actions/bitmagnet/config.rs index 66a6659..3e00e64 100644 --- a/src/actions/bitmagnet/config.rs +++ b/src/actions/bitmagnet/config.rs @@ -1,11 +1,12 @@ use crate::app::Enableable; +use crate::config::types::Host; use serde::{Deserialize, Serialize}; /// Configuration for the Bitmagnet action #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BitmagnetConfig { pub enable: bool, - pub host: String, + pub host: Host, } impl Enableable for BitmagnetConfig { diff --git a/src/actions/bitmagnet/mod.rs b/src/actions/bitmagnet/mod.rs index f586b91..f15c1fd 100644 --- a/src/actions/bitmagnet/mod.rs +++ b/src/actions/bitmagnet/mod.rs @@ -1,6 +1,7 @@ mod action; mod client; mod config; +mod validation; pub use action::BitmagnetAction; pub use config::BitmagnetConfig; diff --git a/src/actions/bitmagnet/validation.rs b/src/actions/bitmagnet/validation.rs new file mode 100644 index 0000000..bead367 --- /dev/null +++ b/src/actions/bitmagnet/validation.rs @@ -0,0 +1,48 @@ +use crate::actions::bitmagnet::BitmagnetConfig; +use crate::config::Validate; +use crate::errors::{self}; +use color_eyre::eyre::Result; +use url::Url; + +impl Validate for BitmagnetConfig { + fn validate(&self) -> Result<()> { + Url::parse(&self.host.to_string()) + .map_err(|e| errors::config_error(e, &format!("Invalid URL format: {}", self.host)))?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::types::Host; + + fn create_valid_config() -> Result { + Ok(BitmagnetConfig { + enable: true, + host: Host::try_new("http://localhost:8080")?, + }) + } + + #[test] + fn test_valid_config() -> Result<()> { + let config = create_valid_config()?; + + assert!(config.validate().is_ok()); + + Ok(()) + } + + #[test] + fn test_invalid_url() -> Result<()> { + let config = BitmagnetConfig { + enable: true, + host: Host::try_new("not a url")?, + }; + + assert!(config.validate().is_err()); + + Ok(()) + } +} diff --git a/src/actions/factory.rs b/src/actions/factory.rs index 4301b70..9bd230e 100644 --- a/src/actions/factory.rs +++ b/src/actions/factory.rs @@ -35,7 +35,6 @@ where mod tests { use super::*; use crate::actions::action::ProcessedMagnets; - use crate::db::Database; use crate::models::Magnet; use crate::services::database::DatabaseService; use async_trait::async_trait; diff --git a/src/actions/transmission/client.rs b/src/actions/transmission/client.rs index 8466286..bced173 100644 --- a/src/actions/transmission/client.rs +++ b/src/actions/transmission/client.rs @@ -23,15 +23,15 @@ impl TransmissionClient { let url = Url::parse(&url_str).wrap_err_with(|| format!("Invalid URL: {}", url_str))?; let auth = BasicAuth { - user: config.username.clone(), - password: config.password.clone(), + user: config.username.to_string(), + password: config.password.to_string(), }; let client = TransClient::with_auth(url, auth); Ok(TransmissionClient { client, - download_dir: config.download_dir.clone(), + download_dir: config.download_dir.to_string(), }) } diff --git a/src/actions/transmission/config.rs b/src/actions/transmission/config.rs index bfdfd85..10214c6 100644 --- a/src/actions/transmission/config.rs +++ b/src/actions/transmission/config.rs @@ -1,15 +1,16 @@ use crate::app::Enableable; +use crate::config::types::{AbsoluteDownloadDir, Host, Password, Port, Username}; use serde::{Deserialize, Serialize}; /// Configuration for the Transmission action #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TransmissionConfig { pub enable: bool, - pub host: String, - pub username: String, - pub password: String, - pub port: u16, - pub download_dir: String, + pub host: Host, + pub username: Username, + pub password: Password, + pub port: Port, + pub download_dir: AbsoluteDownloadDir, } impl Enableable for TransmissionConfig { diff --git a/src/actions/transmission/mod.rs b/src/actions/transmission/mod.rs index 106ed1f..c660c4b 100644 --- a/src/actions/transmission/mod.rs +++ b/src/actions/transmission/mod.rs @@ -1,6 +1,7 @@ pub mod action; pub mod client; pub mod config; +pub mod validation; pub use action::TransmissionAction; pub use config::TransmissionConfig; diff --git a/src/actions/transmission/validation.rs b/src/actions/transmission/validation.rs new file mode 100644 index 0000000..77ac44b --- /dev/null +++ b/src/actions/transmission/validation.rs @@ -0,0 +1,33 @@ +use crate::actions::transmission::TransmissionConfig; +use crate::config::Validate; +use color_eyre::eyre::Result; + +impl Validate for TransmissionConfig { + fn validate(&self) -> Result<()> { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::types::{AbsoluteDownloadDir, Host, Password, Port, Username}; + + fn create_valid_config() -> Result { + Ok(TransmissionConfig { + enable: true, + host: Host::try_new("localhost")?, + username: Username::try_new("user")?, + password: Password::try_new("pass")?, + port: Port::try_new(9091)?, + download_dir: AbsoluteDownloadDir::try_new("/downloads")?, + }) + } + + #[test] + fn test_valid_config() -> Result<()> { + let config = create_valid_config()?; + assert!(config.validate().is_ok()); + Ok(()) + } +} diff --git a/src/app.rs b/src/app.rs index e5d97c9..e433b5e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,5 @@ use crate::actions::action::ProcessedMagnets; +use crate::config::types::RedditUsername; use crate::config::Config; use crate::db::Database; use crate::reddit_client::RedditPost; @@ -56,7 +57,10 @@ impl App { } /// Fetch posts from Reddit - pub async fn fetch_posts(&self, post_count: u32) -> Result> { + pub async fn fetch_posts( + &self, + post_count: u32, + ) -> Result> { let mut unique_usernames = HashSet::new(); for source_config in self.config.sources.values() { unique_usernames.insert(source_config.username.clone()); @@ -73,7 +77,7 @@ impl App { /// Process sources and extract magnet links pub async fn process_sources( &mut self, - user_posts: &MultiMap, + user_posts: &MultiMap, ) -> Result { self.post_processor .process_sources(user_posts, &self.config.sources) diff --git a/src/config.rs b/src/config.rs deleted file mode 100644 index fdcd861..0000000 --- a/src/config.rs +++ /dev/null @@ -1,86 +0,0 @@ -use crate::actions::bitmagnet::BitmagnetConfig; -use crate::actions::transmission::TransmissionConfig; -use crate::args::Args; -use crate::notifications::ntfy::NtfyConfig; -use color_eyre::eyre::{eyre, Result, WrapErr}; -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::HashMap; -use std::path::{Path, PathBuf}; - -/// Configuration for a Reddit source -#[derive(Debug, Serialize, Deserialize)] -pub struct SourceConfig { - pub username: String, - 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: HashMap, -} - -/// 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() - .wrap_err_with(|| "Invalid configuration or insufficient command line arguments")?; - - if conf.sources.is_empty() { - return Err(eyre!("No sources found in configuration. Please add at least one source to your configuration file.")); - } - - 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(|| eyre!("Could not determine data directory")), - } -} diff --git a/src/config/fixtures/test_config.toml b/src/config/fixtures/test_config.toml new file mode 100644 index 0000000..d25268d --- /dev/null +++ b/src/config/fixtures/test_config.toml @@ -0,0 +1,28 @@ +[bitmagnet] +enable = true +host = "http://localhost:8080" + +[transmission] +enable = false +host = "localhost" +port = 9091 +username = "admin" +password = "password" +download_dir = "/downloads" + +[ntfy] +enable = true +topic = "reddit-magnet" +host = "https://ntfy.sh" +priority = "default" + +[sources.movies] +username = "movie_poster" +title_filter = "\\[1080p\\]" +tags = ["movies", "hd"] + +[sources.tv_shows] +username = "tv_shows_fan" +title_filter = "S\\d{2}E\\d{2}" +imdb_id = "tt1234567" +tags = ["tv", "series"] \ No newline at end of file diff --git a/src/config/lib.rs b/src/config/lib.rs new file mode 100644 index 0000000..8e88ec8 --- /dev/null +++ b/src/config/lib.rs @@ -0,0 +1,238 @@ +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(()) + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..28b403a --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,6 @@ +mod lib; +pub(crate) mod types; +mod validation; + +pub use lib::*; +pub use validation::Validate; diff --git a/src/config/snapshots/reddit_magnet__config__lib__tests__config_parsing.snap b/src/config/snapshots/reddit_magnet__config__lib__tests__config_parsing.snap new file mode 100644 index 0000000..a715746 --- /dev/null +++ b/src/config/snapshots/reddit_magnet__config__lib__tests__config_parsing.snap @@ -0,0 +1,84 @@ +--- +source: src/config/lib.rs +assertion_line: 247 +expression: config +--- +Config { + bitmagnet: Some( + BitmagnetConfig { + enable: true, + host: Host( + "http://localhost:8080", + ), + }, + ), + transmission: Some( + TransmissionConfig { + enable: false, + host: Host( + "localhost", + ), + username: Username( + "admin", + ), + password: Password( + "password", + ), + port: Port( + 9091, + ), + download_dir: AbsoluteDownloadDir( + "/downloads", + ), + }, + ), + ntfy: Some( + NtfyConfig { + enable: true, + host: Host( + "https://ntfy.sh", + ), + username: None, + password: None, + topic: Topic( + "reddit-magnet", + ), + }, + ), + sources: { + "movies": SourceConfig { + username: RedditUsername( + "movie_poster", + ), + title_filter: Some( + TitleFilter( + "\\[1080p\\]", + ), + ), + imdb_id: None, + tags: [ + "movies", + "hd", + ], + }, + "tv_shows": SourceConfig { + username: RedditUsername( + "tv_shows_fan", + ), + title_filter: Some( + TitleFilter( + "S\\d{2}E\\d{2}", + ), + ), + imdb_id: Some( + ImdbId( + "tt1234567", + ), + ), + tags: [ + "tv", + "series", + ], + }, + }, +} diff --git a/src/config/types.rs b/src/config/types.rs new file mode 100644 index 0000000..d2dc7fe --- /dev/null +++ b/src/config/types.rs @@ -0,0 +1,241 @@ +use nutype::nutype; + +#[nutype( + sanitize(trim), + validate(len_char_min = 3, len_char_max = 20), + derive( + Debug, + Deref, + Display, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + Clone + ) +)] +pub struct RedditUsername(String); + +#[nutype( + sanitize(trim), + validate(regex = r"^tt\d+$"), + derive( + Debug, + Deref, + Display, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + Clone + ) +)] +pub struct ImdbId(String); + +#[nutype( + sanitize(trim), + validate(not_empty), + derive( + Debug, + Deref, + Display, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + Clone + ) +)] +pub struct Host(String); + +#[nutype( + validate(greater_or_equal = 1, less_or_equal = 65535), + derive( + Debug, + Deref, + Display, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + Clone + ) +)] +pub struct Port(u16); + +#[nutype( + sanitize(trim), + validate(not_empty), + derive( + Debug, + Deref, + Display, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + Clone + ) +)] +pub struct Username(String); + +#[nutype( + validate(not_empty), + derive( + Debug, + Deref, + Display, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + Clone + ) +)] +pub struct Password(String); + +#[nutype( + sanitize(trim), + validate(not_empty, predicate = is_absolute_path), + derive( + Debug, + Deref, + Display, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + Clone + ) +)] +pub struct AbsoluteDownloadDir(String); + +#[nutype( + sanitize(trim), + validate(regex = "^[a-zA-Z0-9_-]+$"), + derive( + Debug, + Deref, + Display, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + Clone + ) +)] +pub struct Topic(String); + +#[nutype( + sanitize(trim), + validate(not_empty, predicate = is_valid_regex), + derive( + Debug, + Deref, + Display, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + Clone + ) +)] +pub struct TitleFilter(String); + +fn is_valid_regex(s: &str) -> bool { + use regex::Regex; + Regex::new(s).is_ok() +} + +fn is_absolute_path(s: &str) -> bool { + use std::path::Path; + Path::new(s).is_absolute() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_empty_host() { + let result = Host::try_new(""); + + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Host is empty")); + } + + #[test] + fn test_empty_username() { + let result = Username::try_new(""); + + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Username is empty")); + } + + #[test] + fn test_empty_download_dir() { + let result = AbsoluteDownloadDir::try_new(""); + + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("AbsoluteDownloadDir is empty")); + } + + #[test] + fn test_relative_download_dir() { + let result = AbsoluteDownloadDir::try_new("downloads"); + + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("AbsoluteDownloadDir failed the predicate test")); + } + + #[test] + fn test_empty_topic() { + let result = Topic::try_new(""); + + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Topic violated the regular expression")); + } + + #[test] + fn test_invalid_topic_format() { + let result = Topic::try_new("invalid@topic"); + + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Topic violated the regular expression")); + } + + #[test] + fn test_invalid_regex() { + let result = TitleFilter::try_new(r"*invalid["); + + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("TitleFilter failed the predicate test.")); + } +} diff --git a/src/config/validation.rs b/src/config/validation.rs new file mode 100644 index 0000000..0ffd8ed --- /dev/null +++ b/src/config/validation.rs @@ -0,0 +1,47 @@ +use crate::app::Enableable; +use crate::config::SourceConfig; +use color_eyre::eyre::Result; + +/// Trait for configuration validation +pub trait Validate { + fn validate(&self) -> Result<()>; + + /// Validates the configuration if it's enabled, otherwise returns Ok(()) + fn validate_if_enabled(&self) -> Result<()> + where + Self: Enableable, + { + if !self.is_enabled() { + return Ok(()); + } + self.validate() + } +} + +impl Validate for SourceConfig { + fn validate(&self) -> Result<()> { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::types::{ImdbId, RedditUsername, TitleFilter}; + + fn create_valid_config() -> Result { + Ok(SourceConfig { + username: RedditUsername::try_new("user123")?, + title_filter: Some(TitleFilter::try_new(r".*\.mkv$")?), + imdb_id: Some(ImdbId::try_new("tt1234567")?), + tags: vec!["tag1".to_string(), "tag2".to_string()], + }) + } + + #[test] + fn test_valid_config() -> Result<()> { + let config = create_valid_config()?; + assert!(config.validate().is_ok()); + Ok(()) + } +} diff --git a/src/db.rs b/src/db.rs index f21c800..d448a39 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,6 +1,7 @@ use crate::models::{Magnet, NewBitmagnetProcessed, NewMagnet, NewTag, NewTransmissionProcessed}; use crate::schema::{bitmagnet_processed, magnet_tags, magnets, tags, transmission_processed}; use crate::PostInfo; +use chrono::Utc; use color_eyre::eyre::{eyre, Result, WrapErr}; use diesel::prelude::*; use diesel::sqlite::SqliteConnection; @@ -28,7 +29,7 @@ impl ProcessedTable for BitmagnetProcessedTable { } fn mark_processed(conn: &mut SqliteConnection, magnet_id: i32) -> Result<()> { - let now = chrono::Utc::now().naive_utc(); + let now = Utc::now().naive_utc(); let new_processed = NewBitmagnetProcessed { magnet_id, processed_at: &now, @@ -52,7 +53,7 @@ impl ProcessedTable for TransmissionProcessedTable { } fn mark_processed(conn: &mut SqliteConnection, magnet_id: i32) -> Result<()> { - let now = chrono::Utc::now().naive_utc(); + let now = Utc::now().naive_utc(); let new_processed = NewTransmissionProcessed { magnet_id, processed_at: &now, @@ -125,7 +126,7 @@ impl Database { subreddit: post.subreddit.as_str(), link: m, published_at: &published_at, - imdb_id: post.imdb_id.as_deref(), + imdb_id: post.imdb_id.as_deref().map(|s| s.as_str()), }) .collect::>(); @@ -222,6 +223,7 @@ impl Database { #[cfg(test)] mod tests { use super::*; + use crate::config::types::{ImdbId, RedditUsername}; use crate::PostInfo; use chrono::Utc; use tempfile::tempdir; @@ -246,7 +248,7 @@ mod tests { let post_info = PostInfo { title: "Test Title".to_string(), - submitter: "test_user".to_string(), + submitter: RedditUsername::try_new("test_user").unwrap(), subreddit: "test_subreddit".to_string(), magnet_links: vec![ "magnet:?xt=urn:btih:test1".to_string(), @@ -286,7 +288,7 @@ mod tests { // Create initial post with magnet links let post_info = PostInfo { title: "Test Title".to_string(), - submitter: "test_user".to_string(), + submitter: RedditUsername::try_new("test_user").unwrap(), subreddit: "test_subreddit".to_string(), magnet_links: vec![ "magnet:?xt=urn:btih:test1".to_string(), @@ -305,14 +307,14 @@ mod tests { // Create a second post with some duplicate and some new links let post_info2 = PostInfo { title: "Test Title 2".to_string(), - submitter: "test_user2".to_string(), + submitter: RedditUsername::try_new("test_user2").unwrap(), subreddit: "test_subreddit2".to_string(), magnet_links: vec![ "magnet:?xt=urn:btih:test1".to_string(), // Duplicate "magnet:?xt=urn:btih:test3".to_string(), // New ], timestamp: Utc::now(), - imdb_id: Some("tt1234567".to_string()), + imdb_id: Some(ImdbId::try_new("tt1234567").unwrap()), tags: vec!["test".to_string()], }; @@ -328,7 +330,7 @@ mod tests { // Try inserting only duplicates let post_info3 = PostInfo { title: "Test Title 3".to_string(), - submitter: "test_user3".to_string(), + submitter: RedditUsername::try_new("test_user3").unwrap(), subreddit: "test_subreddit3".to_string(), magnet_links: vec![ "magnet:?xt=urn:btih:test1".to_string(), // Duplicate @@ -359,7 +361,7 @@ mod tests { // Create a post with tags let post_info = PostInfo { title: "Test Title with Tags".to_string(), - submitter: "test_user".to_string(), + submitter: RedditUsername::try_new("test_user").unwrap(), subreddit: "test_subreddit".to_string(), magnet_links: vec![ "magnet:?xt=urn:btih:test_tag1".to_string(), @@ -390,7 +392,7 @@ mod tests { // Test adding a new magnet with some duplicate and some new tags let post_info2 = PostInfo { title: "Test Title with More Tags".to_string(), - submitter: "test_user".to_string(), + submitter: RedditUsername::try_new("test_user").unwrap(), subreddit: "test_subreddit".to_string(), magnet_links: vec!["magnet:?xt=urn:btih:test_tag3".to_string()], timestamp: Utc::now(), diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 0000000..872083f --- /dev/null +++ b/src/errors.rs @@ -0,0 +1,47 @@ +use color_eyre::section::Section; +use std::fmt; + +#[derive(Debug, Clone, Copy)] +#[allow(dead_code)] +pub enum ErrorCategory { + Database, + Config, + Reddit, + Action, + Notification, + IO, + Unknown, +} + +impl fmt::Display for ErrorCategory { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ErrorCategory::Database => write!(f, "Database error"), + ErrorCategory::Config => write!(f, "Configuration error"), + ErrorCategory::Reddit => write!(f, "Reddit API error"), + ErrorCategory::Action => write!(f, "Action error"), + ErrorCategory::Notification => write!(f, "Notification error"), + ErrorCategory::IO => write!(f, "IO error"), + ErrorCategory::Unknown => write!(f, "Unknown error"), + } + } +} + +#[macro_export] +macro_rules! define_error_fn { + ($fn_name:ident, $category:expr) => { + /// Helper function to create an error with context + #[allow(dead_code)] + pub fn $fn_name(error: E, context: &str) -> color_eyre::eyre::Report { + color_eyre::eyre::eyre!("{}: {}", context, error).with_section(|| $category.to_string()) + } + }; +} + +// Define all error helper functions using the macro +define_error_fn!(db_error, ErrorCategory::Database); +define_error_fn!(config_error, ErrorCategory::Config); +define_error_fn!(reddit_error, ErrorCategory::Reddit); +define_error_fn!(action_error, ErrorCategory::Action); +define_error_fn!(notification_error, ErrorCategory::Notification); +define_error_fn!(io_error, ErrorCategory::IO); diff --git a/src/main.rs b/src/main.rs index ec85f86..2acb1f0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,11 @@ use crate::app::App; use crate::args::Args; +use crate::config::types::{ImdbId, RedditUsername}; use crate::config::{get_db_path, load_config}; use crate::db::Database; use chrono::{DateTime, Utc}; use clap::Parser; -use color_eyre::eyre::{Result, WrapErr}; +use color_eyre::eyre::Result; use std::fs::create_dir_all; mod actions; @@ -12,6 +13,7 @@ mod app; mod args; mod config; mod db; +mod errors; mod magnet; mod models; mod notifications; @@ -24,18 +26,18 @@ mod services; #[derive(Debug)] pub struct PostInfo { pub title: String, - pub submitter: String, + pub submitter: RedditUsername, pub magnet_links: Vec, pub subreddit: String, pub timestamp: DateTime, - pub imdb_id: Option, + pub imdb_id: Option, pub tags: Vec, } #[tokio::main] async fn main() -> Result<()> { // Initialize error handling - color_eyre::install()?; + color_eyre::install().map_err(|e| errors::db_error(e, "Failed to install error handler"))?; // Parse command-line arguments let args = Args::parse(); @@ -50,12 +52,17 @@ async fn main() -> Result<()> { // Create parent directory if it doesn't exist if let Some(parent) = db_path.parent() { - create_dir_all(parent) - .wrap_err_with(|| format!("Failed to create directory: {:?}", parent))?; + create_dir_all(parent).map_err(|e| { + errors::io_error(e, &format!("Failed to create directory: {:?}", parent)) + })?; } - let db = Database::new(&db_path) - .wrap_err_with(|| format!("Failed to initialize database at {:?}", db_path))?; + let db = Database::new(&db_path).map_err(|e| { + errors::db_error( + e, + &format!("Failed to initialize database at {:?}", db_path), + ) + })?; // Load configuration let conf = load_config(&args)?; diff --git a/src/notifications/ntfy/config.rs b/src/notifications/ntfy/config.rs index b2bd6f5..65a68ba 100644 --- a/src/notifications/ntfy/config.rs +++ b/src/notifications/ntfy/config.rs @@ -1,4 +1,5 @@ use crate::app::Enableable; +use crate::config::types::{Host, Password, Topic, Username}; use serde::{Deserialize, Serialize}; /// Configuration for the ntfy notification service @@ -9,18 +10,18 @@ pub struct NtfyConfig { pub enable: bool, /// The host URL of the ntfy server - pub host: String, + pub host: Host, /// The username for authentication (optional) #[serde(default)] - pub username: Option, + pub username: Option, /// The password for authentication (optional) #[serde(default)] - pub password: Option, + pub password: Option, /// The topic to publish notifications to - pub topic: String, + pub topic: Topic, } impl Enableable for NtfyConfig { diff --git a/src/notifications/ntfy/mod.rs b/src/notifications/ntfy/mod.rs index 03ca59c..6fef632 100644 --- a/src/notifications/ntfy/mod.rs +++ b/src/notifications/ntfy/mod.rs @@ -1,5 +1,6 @@ pub mod config; pub mod notification; +pub mod validation; pub use config::NtfyConfig; pub use notification::NtfyNotification; diff --git a/src/notifications/ntfy/notification.rs b/src/notifications/ntfy/notification.rs index 99800e5..e8614a0 100644 --- a/src/notifications/ntfy/notification.rs +++ b/src/notifications/ntfy/notification.rs @@ -17,11 +17,14 @@ pub struct NtfyNotification { impl NtfyNotification { /// Create a new NtfyNotification instance pub fn new(config: NtfyConfig) -> Result { - let mut builder = dispatcher::builder(config.host.clone()); + let mut builder = dispatcher::builder(config.host.as_str()); // Add authentication if provided if let (Some(username), Some(password)) = (&config.username, &config.password) { - builder = builder.credentials(Auth::credentials(username, password)) + builder = builder.credentials(Auth::credentials( + username.to_string(), + password.to_string(), + )) } let client = builder.build_async()?; @@ -57,7 +60,7 @@ impl Notification for NtfyNotification { Priority::Default }; - let payload = Payload::new(&self.config.topic) + let payload = Payload::new(self.config.topic.to_string()) .message(report::generate_report( action_results, total_new_links, diff --git a/src/notifications/ntfy/validation.rs b/src/notifications/ntfy/validation.rs new file mode 100644 index 0000000..00f6d8f --- /dev/null +++ b/src/notifications/ntfy/validation.rs @@ -0,0 +1,88 @@ +use crate::config::Validate; +use crate::errors::{self}; +use crate::notifications::ntfy::NtfyConfig; +use color_eyre::eyre::Result; +use url::Url; + +impl Validate for NtfyConfig { + fn validate(&self) -> Result<()> { + // Validate URL format + Url::parse(&self.host.to_string()) + .map_err(|e| errors::config_error(e, &format!("Invalid URL format: {}", self.host)))?; + + match (self.username.as_ref(), self.password.as_ref()) { + (Some(_), None) => { + return Err(errors::config_error( + "Password must be provided when username is set", + "NtfyConfig", + )) + } + (None, Some(_)) => { + return Err(errors::config_error( + "Username must be provided when password is set", + "NtfyConfig", + )) + } + _ => {} + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::types::{Host, Password, Topic, Username}; + + fn create_valid_config() -> Result { + Ok(NtfyConfig { + enable: true, + host: Host::try_new("https://ntfy.sh")?, + username: None, + password: None, + topic: Topic::try_new("my-topic")?, + }) + } + + #[test] + fn test_valid_config() -> Result<()> { + let config = create_valid_config()?; + + assert!(config.validate().is_ok()); + + Ok(()) + } + + #[test] + fn test_invalid_url() -> Result<()> { + let mut invalid_config = create_valid_config()?; + invalid_config.host = Host::try_new("not a url")?; + + assert!(invalid_config.validate().is_err()); + + Ok(()) + } + + #[test] + fn test_username_without_password() -> Result<()> { + let mut invalid_config = create_valid_config()?; + invalid_config.username = Some(Username::try_new("user")?); + invalid_config.password = None; + + assert!(invalid_config.validate().is_err()); + + Ok(()) + } + + #[test] + fn test_password_without_username() -> Result<()> { + let mut invalid_config = create_valid_config()?; + invalid_config.username = None; + invalid_config.password = Some(Password::try_new("pass")?); + + assert!(invalid_config.validate().is_err()); + + Ok(()) + } +} diff --git a/src/reddit_client.rs b/src/reddit_client.rs index 72f222a..821cdc2 100644 --- a/src/reddit_client.rs +++ b/src/reddit_client.rs @@ -1,3 +1,4 @@ +use crate::config::types::RedditUsername; use chrono::{DateTime, TimeZone, Utc}; use color_eyre::eyre::{Result, WrapErr}; use roux::util::FeedOption; @@ -29,10 +30,10 @@ impl RedditClient { /// * `post_count` - The maximum number of posts to fetch pub async fn fetch_user_submissions( &self, - username: &str, + username: &RedditUsername, post_count: u32, ) -> Result> { - let user = User::new(username); + let user = User::new(username.as_str()); let feed_option = FeedOption::new().limit(post_count); let submissions = user .submitted(Some(feed_option)) diff --git a/src/services/action.rs b/src/services/action.rs index a613372..7ecd300 100644 --- a/src/services/action.rs +++ b/src/services/action.rs @@ -81,7 +81,7 @@ mod tests { use async_trait::async_trait; use chrono::NaiveDateTime; use color_eyre::eyre::{eyre, Result}; - use std::collections::HashMap; + use std::collections::BTreeMap; use std::sync::atomic::{AtomicBool, Ordering}; use tempfile::tempdir; @@ -198,7 +198,7 @@ mod tests { bitmagnet: None, transmission: None, ntfy: None, - sources: HashMap::new(), + sources: BTreeMap::new(), }; let result = action_service.init_actions(&config); diff --git a/src/services/database.rs b/src/services/database.rs index 871a8d1..dc75d95 100644 --- a/src/services/database.rs +++ b/src/services/database.rs @@ -1,7 +1,8 @@ use crate::db::{Database, ProcessedTable}; +use crate::errors::{self}; use crate::models; use crate::PostInfo; -use color_eyre::eyre::{Result, WrapErr}; +use color_eyre::eyre::Result; use log::debug; /// Service for database operations @@ -31,9 +32,12 @@ impl DatabaseService { post_info.title ); - self.db - .store_magnets(post_info) - .wrap_err_with(|| format!("Failed to store magnets for post: {}", post_info.title)) + self.db.store_magnets(post_info).map_err(|e| { + errors::db_error( + e, + &format!("Failed to store magnets for post: {}", post_info.title), + ) + }) } /// Get all magnet links from the database @@ -47,7 +51,7 @@ impl DatabaseService { self.db .get_all_magnets() - .wrap_err("Failed to retrieve all magnet links from database") + .map_err(|e| errors::db_error(e, "Failed to retrieve all magnet links from database")) } /// Get tags for a magnet link @@ -62,9 +66,12 @@ impl DatabaseService { pub fn get_tags_for_magnet(&mut self, magnet_id: i32) -> Result> { debug!("Retrieving tags for magnet ID: {}", magnet_id); - self.db - .get_tags_for_magnet(magnet_id) - .wrap_err_with(|| format!("Failed to retrieve tags for magnet ID: {}", magnet_id)) + self.db.get_tags_for_magnet(magnet_id).map_err(|e| { + errors::db_error( + e, + &format!("Failed to retrieve tags for magnet ID: {}", magnet_id), + ) + }) } /// Get unprocessed magnet links for a specific action @@ -77,7 +84,7 @@ impl DatabaseService { self.db .get_unprocessed_magnets_for_table::() - .wrap_err("Failed to retrieve unprocessed magnet links") + .map_err(|e| errors::db_error(e, "Failed to retrieve unprocessed magnet links")) } /// Mark a magnet link as processed for a specific action @@ -90,37 +97,45 @@ impl DatabaseService { self.db .mark_magnet_processed_for_table::(magnet_id) - .wrap_err_with(|| format!("Failed to mark magnet ID {} as processed", magnet_id)) + .map_err(|e| { + errors::db_error( + e, + &format!("Failed to mark magnet ID {} as processed", magnet_id), + ) + }) } } #[cfg(test)] mod tests { use super::*; + use crate::config::types::{ImdbId, RedditUsername}; use chrono::Utc; use tempfile::tempdir; fn create_test_post_info() -> PostInfo { PostInfo { title: "Test Post".to_string(), - submitter: "test_user".to_string(), + submitter: RedditUsername::try_new("test_user").unwrap(), subreddit: "test_subreddit".to_string(), magnet_links: vec![ "magnet:?xt=urn:btih:test1".to_string(), "magnet:?xt=urn:btih:test2".to_string(), ], timestamp: Utc::now(), - imdb_id: Some("tt1234567".to_string()), + imdb_id: Some(ImdbId::try_new("tt1234567").unwrap()), tags: vec!["tag1".to_string(), "tag2".to_string()], } } #[test] fn test_store_and_retrieve_magnets() -> Result<()> { - let temp_dir = tempdir()?; + let temp_dir = + tempdir().map_err(|e| errors::io_error(e, "Failed to create temp directory"))?; let db_path = temp_dir.path().join("test.db"); - let db = Database::new(&db_path)?; + let db = Database::new(&db_path) + .map_err(|e| errors::db_error(e, "Failed to create test database"))?; let mut service = DatabaseService::new(db); let post_info = create_test_post_info(); diff --git a/src/services/notification.rs b/src/services/notification.rs index a63a232..bbb9264 100644 --- a/src/services/notification.rs +++ b/src/services/notification.rs @@ -68,6 +68,7 @@ mod tests { use async_trait::async_trait; use chrono::NaiveDateTime; use color_eyre::eyre::{eyre, Result}; + use std::collections::BTreeMap; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -172,7 +173,7 @@ mod tests { bitmagnet: None, transmission: None, ntfy: None, - sources: HashMap::new(), + sources: BTreeMap::new(), }; let result = notification_service.init_notifications(&config); diff --git a/src/services/post_processor.rs b/src/services/post_processor.rs index 348a4da..be8b722 100644 --- a/src/services/post_processor.rs +++ b/src/services/post_processor.rs @@ -3,7 +3,12 @@ use color_eyre::eyre::{Result, WrapErr}; use log::{info, warn}; use regex::Regex; +use crate::config::types::RedditUsername; +use crate::config::SourceConfig; +use crate::reddit_client::RedditPost; +use multimap::MultiMap; use std::cell::RefCell; +use std::collections::BTreeMap; use std::rc::Rc; /// Service for processing posts and extracting magnet links @@ -29,8 +34,8 @@ impl PostProcessorService { /// The number of new magnet links stored pub fn process_sources( &mut self, - user_posts: &multimap::MultiMap, - source_configs: &std::collections::HashMap, + user_posts: &MultiMap, + source_configs: &BTreeMap, ) -> Result { let mut total_new_links = 0; @@ -100,12 +105,12 @@ fn filter_post(title: &str, title_filter: Option) -> bool { #[cfg(test)] mod tests { use super::*; + use crate::config::types::ImdbId; use crate::config::SourceConfig; use crate::db::Database; use crate::reddit_client::RedditPost; use chrono::Utc; use multimap::MultiMap; - use std::collections::HashMap; use tempfile::tempdir; #[test] @@ -119,7 +124,7 @@ mod tests { let mut service = PostProcessorService::new(db_service); let mut user_posts = MultiMap::new(); - let mut source_configs = HashMap::new(); + let mut source_configs = BTreeMap::new(); let post = RedditPost { title: "Test Post".to_string(), @@ -127,17 +132,17 @@ mod tests { subreddit: "test_subreddit".to_string(), created: Utc::now(), }; - user_posts.insert("test_user".to_string(), post); + user_posts.insert(RedditUsername::try_new("test_user")?, post); let source_config = SourceConfig { - username: "test_user".to_string(), + username: RedditUsername::try_new("test_user")?, title_filter: None, - imdb_id: Some("tt1234567".to_string()), + imdb_id: Some(ImdbId::try_new("tt1234567")?), tags: vec!["tag1".to_string(), "tag2".to_string()], }; source_configs.insert("test_source".to_string(), source_config); - let count = service.process_sources(&mut user_posts, &mut source_configs)?; + let count = service.process_sources(&user_posts, &source_configs)?; assert_eq!(count, 2, "Should have stored 2 new magnet links"); diff --git a/src/services/reddit.rs b/src/services/reddit.rs index ec238c0..68a06ee 100644 --- a/src/services/reddit.rs +++ b/src/services/reddit.rs @@ -1,3 +1,4 @@ +use crate::config::types::RedditUsername; use crate::reddit_client::{RedditClient, RedditPost}; use color_eyre::eyre::{Result, WrapErr}; use log::{debug, error, info, warn}; @@ -56,9 +57,9 @@ impl RedditService { /// A MultiMap mapping usernames to their posts pub async fn fetch_posts_from_users( &self, - usernames: HashSet, + usernames: HashSet, post_count: u32, - ) -> Result> { + ) -> Result> { let mut user_posts = MultiMap::new(); for username in usernames { @@ -93,7 +94,7 @@ impl RedditService { /// A vector of RedditPost objects async fn fetch_user_posts_with_retry( &self, - username: &str, + username: &RedditUsername, post_count: u32, ) -> Result> { let mut attempts = 0; diff --git a/src/services/report.rs b/src/services/report.rs index 2b5b7c0..a63d413 100644 --- a/src/services/report.rs +++ b/src/services/report.rs @@ -27,13 +27,10 @@ impl ReportService { #[cfg(test)] mod tests { use super::*; - use crate::actions::action::ProcessedMagnets; - use crate::models::Magnet; - use std::collections::HashMap; #[test] fn test_new_report_service() { - let report_service = ReportService::new(); + let _report_service = ReportService::new(); // Just verify that we can create the service // The actual report generation is tested in the report module }