feat: validate configuration
This commit is contained in:
parent
ee7d7971be
commit
48c670f455
33 changed files with 1076 additions and 165 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
mod action;
|
||||
mod client;
|
||||
mod config;
|
||||
mod validation;
|
||||
|
||||
pub use action::BitmagnetAction;
|
||||
pub use config::BitmagnetConfig;
|
||||
|
|
|
|||
48
src/actions/bitmagnet/validation.rs
Normal file
48
src/actions/bitmagnet/validation.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
pub mod action;
|
||||
pub mod client;
|
||||
pub mod config;
|
||||
pub mod validation;
|
||||
|
||||
pub use action::TransmissionAction;
|
||||
pub use config::TransmissionConfig;
|
||||
|
|
|
|||
33
src/actions/transmission/validation.rs
Normal file
33
src/actions/transmission/validation.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
}
|
||||
}
|
||||
28
src/config/fixtures/test_config.toml
Normal file
28
src/config/fixtures/test_config.toml
Normal 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
238
src/config/lib.rs
Normal 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
6
src/config/mod.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
mod lib;
|
||||
pub(crate) mod types;
|
||||
mod validation;
|
||||
|
||||
pub use lib::*;
|
||||
pub use validation::Validate;
|
||||
|
|
@ -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
241
src/config/types.rs
Normal 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
47
src/config/validation.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
22
src/db.rs
22
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::<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
47
src/errors.rs
Normal 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);
|
||||
23
src/main.rs
23
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<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)?;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
pub mod config;
|
||||
pub mod notification;
|
||||
pub mod validation;
|
||||
|
||||
pub use config::NtfyConfig;
|
||||
pub use notification::NtfyNotification;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
88
src/notifications/ntfy/validation.rs
Normal file
88
src/notifications/ntfy/validation.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue