Merge branch 'validation' into 'main'

feat: validate configuration

See merge request kernald/reddit-magnet!34
This commit is contained in:
Marc Plano-Lesay 2025-05-22 17:54:10 +10:00
commit 8b3baad1f8
33 changed files with 1076 additions and 165 deletions

107
Cargo.lock generated
View file

@ -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]]

View file

@ -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"

View file

@ -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;

View file

@ -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 {

View file

@ -1,6 +1,7 @@
mod action;
mod client;
mod config;
mod validation;
pub use action::BitmagnetAction;
pub use config::BitmagnetConfig;

View file

@ -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<BitmagnetConfig> {
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(())
}
}

View file

@ -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;

View file

@ -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(),
})
}

View file

@ -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 {

View file

@ -1,6 +1,7 @@
pub mod action;
pub mod client;
pub mod config;
pub mod validation;
pub use action::TransmissionAction;
pub use config::TransmissionConfig;

View file

@ -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<TransmissionConfig> {
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(())
}
}

View file

@ -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<MultiMap<String, RedditPost>> {
pub async fn fetch_posts(
&self,
post_count: u32,
) -> Result<MultiMap<RedditUsername, RedditPost>> {
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<String, RedditPost>,
user_posts: &MultiMap<RedditUsername, RedditPost>,
) -> Result<usize> {
self.post_processor
.process_sources(user_posts, &self.config.sources)

View file

@ -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<String>,
pub imdb_id: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
}
/// Main application configuration
#[derive(Debug, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub bitmagnet: Option<BitmagnetConfig>,
#[serde(default)]
pub transmission: Option<TransmissionConfig>,
#[serde(default)]
pub ntfy: Option<NtfyConfig>,
#[serde(default)]
pub sources: HashMap<String, SourceConfig>,
}
/// Loads the configuration from the specified file or default location
pub fn load_config(args: &Args) -> Result<Config> {
let mut conf_extractor = Figment::new();
let config_file_path: Option<PathBuf> = 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<PathBuf> {
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")),
}
}

View file

@ -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"]

238
src/config/lib.rs Normal file
View file

@ -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<TitleFilter>,
pub imdb_id: Option<ImdbId>,
#[serde(default)]
pub tags: Vec<String>,
}
/// Main application configuration
#[derive(Debug, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub bitmagnet: Option<BitmagnetConfig>,
#[serde(default)]
pub transmission: Option<TransmissionConfig>,
#[serde(default)]
pub ntfy: Option<NtfyConfig>,
#[serde(default)]
pub sources: BTreeMap<String, SourceConfig>,
}
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<T: Validate>(&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<T: Validate + Enableable>(
&self,
component: &Option<T>,
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<Config> {
let mut conf_extractor = Figment::new();
let config_file_path: Option<PathBuf> = 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<PathBuf> {
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<Config> {
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(())
}
}

6
src/config/mod.rs Normal file
View file

@ -0,0 +1,6 @@
mod lib;
pub(crate) mod types;
mod validation;
pub use lib::*;
pub use validation::Validate;

View file

@ -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",
],
},
},
}

241
src/config/types.rs Normal file
View file

@ -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."));
}
}

47
src/config/validation.rs Normal file
View file

@ -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<SourceConfig> {
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(())
}
}

View file

@ -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::<Vec<NewMagnet>>();
@ -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(),

47
src/errors.rs Normal file
View file

@ -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<E: std::fmt::Display>(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);

View file

@ -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<magnet::Magnet>,
pub subreddit: String,
pub timestamp: DateTime<Utc>,
pub imdb_id: Option<String>,
pub imdb_id: Option<ImdbId>,
pub tags: Vec<String>,
}
#[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)?;

View file

@ -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<String>,
pub username: Option<Username>,
/// The password for authentication (optional)
#[serde(default)]
pub password: Option<String>,
pub password: Option<Password>,
/// The topic to publish notifications to
pub topic: String,
pub topic: Topic,
}
impl Enableable for NtfyConfig {

View file

@ -1,5 +1,6 @@
pub mod config;
pub mod notification;
pub mod validation;
pub use config::NtfyConfig;
pub use notification::NtfyNotification;

View file

@ -17,11 +17,14 @@ pub struct NtfyNotification {
impl NtfyNotification {
/// Create a new NtfyNotification instance
pub fn new(config: NtfyConfig) -> Result<Self> {
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,

View file

@ -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<NtfyConfig> {
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(())
}
}

View file

@ -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<Vec<RedditPost>> {
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))

View file

@ -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);

View file

@ -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<Vec<String>> {
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::<T>()
.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::<T>(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();

View file

@ -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);

View file

@ -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<String, crate::reddit_client::RedditPost>,
source_configs: &std::collections::HashMap<String, crate::config::SourceConfig>,
user_posts: &MultiMap<RedditUsername, RedditPost>,
source_configs: &BTreeMap<String, SourceConfig>,
) -> Result<usize> {
let mut total_new_links = 0;
@ -100,12 +105,12 @@ fn filter_post(title: &str, title_filter: Option<Regex>) -> 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");

View file

@ -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<String>,
usernames: HashSet<RedditUsername>,
post_count: u32,
) -> Result<MultiMap<String, RedditPost>> {
) -> Result<MultiMap<RedditUsername, RedditPost>> {
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<Vec<RedditPost>> {
let mut attempts = 0;

View file

@ -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
}