commit fac6f83825df33e7e88b106983ce5889f9d0c4e3 from: witcher date: Sat Feb 25 13:43:42 2023 UTC refactor: Introduce SNAFU for better errors With SNAFU, a context as well as a message can be given. It is similar to thiserror. Start using this to give meaningful errors without relying on anyhow. commit - 6397d1ece16453ad239561e06deebb0a352cd85a commit + fac6f83825df33e7e88b106983ce5889f9d0c4e3 blob - 136c1bfa9246f2b06b5a9244755309d61db0db17 blob + 4b3d013017785bc22b3946c921230182cd48c4b2 --- Cargo.lock +++ Cargo.lock @@ -23,12 +23,6 @@ dependencies = [ ] [[package]] -name = "anyhow" -version = "1.0.61" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "508b352bb5c066aac251f6daf6b36eccd03e8a88e8081cd44959ea277a3af9a8" - -[[package]] name = "async-trait" version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -359,6 +353,12 @@ dependencies = [ ] [[package]] +name = "doc-comment" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" + +[[package]] name = "dotenvy" version = "0.15.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1139,7 +1139,6 @@ dependencies = [ name = "rss-email" version = "0.4.2" dependencies = [ - "anyhow", "atom_syndication", "chrono", "clap", @@ -1150,6 +1149,7 @@ dependencies = [ "rss", "serde", "simple_logger", + "snafu", "sqlx", "tokio", "toml", @@ -1290,6 +1290,28 @@ source = "registry+https://github.com/rust-lang/crates checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0" [[package]] +name = "snafu" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb0656e7e3ffb70f6c39b3c2a86332bb74aa3c679da781642590f3c1118c5045" +dependencies = [ + "doc-comment", + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "475b3bbe5245c26f2d8a6f62d67c1f30eb9fffeccee721c45d162c3ebbdf81b2" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] name = "socket2" version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1459,18 +1481,18 @@ checksum = "b1141d4d61095b28419e22cb0bbf02755f5e54e052 [[package]] name = "thiserror" -version = "1.0.32" +version = "1.0.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5f6586b7f764adc0231f4c79be7b920e766bb2f3e51b3661cdb263828f19994" +checksum = "6a9cd18aa97d5c45c6603caea1da6628790b37f7a34b6ca89522331c5180fed0" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.32" +version = "1.0.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12bafc5b54507e0149cdf1b145a5d80ab80a90bcd9275df43d4fff68460f6c21" +checksum = "1fb327af4685e4d03fa8cbcf1716380da910eeb2bb8be417e7f9fd3fb164f36f" dependencies = [ "proc-macro2", "quote", blob - 6217251e560d2a295835dcd110a091c2be2b505a blob + a6d7de3594d6fdeac55280d1386b7aa77a4698ab --- Cargo.toml +++ Cargo.toml @@ -16,7 +16,6 @@ strip = "symbols" [dependencies] rss = "2.0" -anyhow = "1.0" reqwest = {version = "0.11", default-features = false, features = ["rustls-tls"]} clap = { version = "3", features = ["derive"] } chrono = "0.4" @@ -31,3 +30,4 @@ tokio = { version = "1.21.2", default-features = false sqlx = { version = "0.6.2", features = ["runtime-tokio-rustls", "migrate", "sqlite", "offline"] } atom_syndication = "0.11.0" simple_logger = "4.0.0" +snafu = "0.7.4" blob - 2066caf7a569852f8bf6e3708166090170fbb650 blob + 104d5f46d08cb482905330ea1282eebb8cfdcee6 --- src/cli.rs +++ src/cli.rs @@ -2,6 +2,19 @@ use clap::Parser; use log::debug; use std::path::PathBuf; +use snafu::{prelude::*, Snafu}; + +#[derive(Debug, Snafu)] +pub enum Error { + #[snafu(display("Io Error for \"{path}\": {source}"))] + Io { + path: String, + source: std::io::Error, + }, + #[snafu(display("Logger error: {source}"))] + Logger { source: log::SetLoggerError }, +} + #[derive(Parser)] #[clap(author, version, about)] pub struct Cli { @@ -45,7 +58,7 @@ pub struct Cli { impl Cli { /// Parse the clap `Cli` struct with the command line arguments and create the database if it does not exist yet. - pub fn build_app() -> anyhow::Result { + pub fn build_app() -> Result { use std::fs::File; let args = Self::parse(); @@ -57,7 +70,7 @@ impl Cli { 2 => log::Level::Debug, _ => log::Level::Trace, }; - simple_logger::init_with_level(verbosity)?; + simple_logger::init_with_level(verbosity).context(LoggerSnafu)?; let data_dir = data_dir(); if !PathBuf::from(&data_dir).exists() { @@ -65,7 +78,9 @@ impl Cli { "No data directory exists, creating a new one: {:?}", &data_dir ); - std::fs::create_dir(&data_dir)?; + std::fs::create_dir(&data_dir).with_context(|_| IoSnafu { + path: data_dir.display().to_string(), + })?; } if File::open(&args.database_path).is_err() { @@ -73,7 +88,9 @@ impl Cli { "No database file exists, creating a new one: {:?}", &args.database_path ); - File::create(&args.database_path)?; + File::create(&args.database_path).with_context(|_| IoSnafu { + path: args.database_path.clone(), + })?; } Ok(args) @@ -82,7 +99,8 @@ impl Cli { fn project_dirs() -> directories::ProjectDirs { #[allow(clippy::unwrap_used)] - directories::ProjectDirs::from("", "", "rss-email").unwrap() + directories::ProjectDirs::from("", "", "rss-email") + .expect("A valid home directory path from the OS") } /// Returns the base directory where all the configuration files are stored. blob - 284b9035bb825496a7056c5689c715ad1b88ac96 blob + 40cbfa995122f513006038b8b4c7d727aa91fbd6 --- src/config.rs +++ src/config.rs @@ -1,8 +1,24 @@ -use anyhow::Context; use serde::Deserialize; +use snafu::{prelude::*, Snafu}; use std::fs::File; use std::{io::Read, path::Path}; +#[derive(Debug, Snafu)] +pub enum Error { + #[snafu(display("Io Error for \"{path}\": {source}"))] + Io { + path: String, + source: std::io::Error, + }, + Toml { + source: toml::de::Error, + }, + #[snafu(display("Cli error: {source}"))] + Cli { + source: crate::cli::Error, + }, +} + #[derive(Debug, Deserialize)] pub struct Config { pub(crate) mail: Mail, @@ -24,12 +40,18 @@ pub struct SmtpConfig { } impl Config { - pub fn new(config_path: impl AsRef) -> anyhow::Result { + pub fn new(config_path: impl AsRef) -> Result { + let config_path = config_path.as_ref(); let mut string = String::new(); - File::open(config_path.as_ref()) - .with_context(|| format!("File {:?} does not exist", &config_path.as_ref()))? - .read_to_string(&mut string)?; - let config: Self = toml::de::from_str(&string)?; + File::open(config_path) + .with_context(|_| IoSnafu { + path: config_path.display().to_string(), + })? + .read_to_string(&mut string) + .with_context(|_| IoSnafu { + path: config_path.display().to_string(), + })?; + let config: Self = toml::de::from_str(&string).context(TomlSnafu)?; Ok(config) } @@ -59,8 +81,8 @@ impl AppConfig { /// /// Internally, this will parse the command line arguments with [`clap`] and read the /// configuration file *if* all of the required options have *not* been given. - pub fn new() -> anyhow::Result { - let cli = crate::cli::Cli::build_app()?; + pub fn new() -> Result { + let cli = crate::cli::Cli::build_app().context(CliSnafu)?; let mut s = Self { database_path: cli.database_path, urls_path: cli.urls_path, blob - c1797f8dd887d33f4370e8cdfef1f3a55b19d7c5 blob + e51b0580ebdb438787ae9c3c43c394c317fae02a --- src/db.rs +++ src/db.rs @@ -1,14 +1,16 @@ +use snafu::prelude::*; use sqlx::pool::PoolConnection; use sqlx::Sqlite; use crate::models::Post; +use crate::{Error, GeneralDatabaseSnafu}; /// Insert a new post or update an old one with the same GUID. /// /// # Errors /// /// An error occurs if the statement cannot be executed. -pub async fn insert_item(mut conn: PoolConnection, post: &Post) -> anyhow::Result<()> { +pub async fn insert_item(mut conn: PoolConnection, post: &Post) -> Result<(), Error> { sqlx::query!( "insert or ignore into posts (guid, title, url, pub_date, content) values (?, ?, ?, ?, ?)", post.guid, @@ -18,7 +20,8 @@ pub async fn insert_item(mut conn: PoolConnection(url: S) -> anyhow::Result> +pub async fn fetch_new(url: S) -> Result, Error> where S: AsRef + Send, { debug!("Fetching feed for {}", url.as_ref()); let url = url.as_ref(); - let content = reqwest::get(url).await?.bytes().await?; + let content = reqwest::get(url) + .await + .with_context(|_| ReceivingFeedSnafu { + url: url.to_string(), + })? + .bytes() + .await + .with_context(|_| ReceivingFeedSnafu { + url: url.to_string(), + })?; match fetch_new_rss(&content[..]) { - Err(_) => { - fetch_new_atom(&content[..]).with_context(|| format!("Failed fetching feed for {url}")) + Err(rss::Error::InvalidStartTag) => match fetch_new_atom(&content[..]) { + Err(atom_syndication::Error::InvalidStartTag) => InvalidFeedSnafu { + url: url.to_string(), + } + .fail(), + Err(_) => InvalidAtomSnafu { + url: url.to_string(), + } + .fail(), + Ok(v) => Ok(v), + }, + Err(_) => InvalidRSSSnafu { + url: url.to_string(), } - p => p, + .fail(), + Ok(v) => Ok(v), } } @@ -29,8 +62,8 @@ where /// # Errors /// /// An error occurs if the given bytes are an invalid RSS feed. -pub fn fetch_new_rss(bytes: &[u8]) -> anyhow::Result> { - let channel = rss::Channel::read_from(bytes).context("Unable to read from RSS feed")?; +pub fn fetch_new_rss(bytes: &[u8]) -> Result, rss::Error> { + let channel = rss::Channel::read_from(bytes)?; Ok(channel .items @@ -50,8 +83,8 @@ pub fn fetch_new_rss(bytes: &[u8]) -> anyhow::Result anyhow::Result> { - let feed = atom_syndication::Feed::read_from(bytes).context("Unable to read from atom feed")?; +pub fn fetch_new_atom(bytes: &[u8]) -> Result, atom_syndication::Error> { + let feed = atom_syndication::Feed::read_from(bytes)?; Ok(feed .entries blob - 1f217a66fd933723bd85b74832ff8e3ce3a3d71c blob + 51fbeb368498d43e6cd239897feffa5f323775e3 --- src/mail.rs +++ src/mail.rs @@ -3,7 +3,20 @@ use lettre::{ transport::smtp::{authentication::Credentials, AsyncSmtpTransport}, AsyncTransport, Tokio1Executor, }; +use snafu::{prelude::*, Snafu}; +#[derive(Debug, Snafu)] +pub enum Error { + #[snafu(display("General Mail error: {source}"))] + GeneralMail { source: lettre::error::Error }, + #[snafu(display("SMTP Mail error: {source}"))] + SMTP { + source: lettre::transport::smtp::Error, + }, + #[snafu(display("Unable to parse {value}"))] + Parse { value: String }, +} + pub struct Mail { subject: String, body: String, @@ -36,15 +49,23 @@ impl Mail { from: &'a str, to: &'a str, mailer: &AsyncSmtpTransport, - ) -> anyhow::Result<()> { + ) -> Result<(), Error> { trace!("Sending to {}: {}", to, &self.subject); + let from = from.parse().map_err(|_| Error::Parse { + value: from.to_string(), + })?; + let to = to.parse().map_err(|_| Error::Parse { + value: to.to_string(), + })?; + let email = Message::builder() - .from(from.parse()?) - .to(to.parse()?) + .from(from) + .to(to) .subject(self.subject) - .body(self.body)?; + .body(self.body) + .context(GeneralMailSnafu)?; - mailer.send(email).await?; + mailer.send(email).await.context(SMTPSnafu)?; Ok(()) } @@ -62,10 +83,11 @@ pub fn get_mailer( password: String, server: &str, port: u16, -) -> anyhow::Result> { +) -> Result, Error> { let creds = Credentials::new(user, password); - let mailer = AsyncSmtpTransport::::relay(server)? + let mailer = AsyncSmtpTransport::::relay(server) + .context(SMTPSnafu)? .credentials(creds) .port(port) .build(); blob - 05e73279c485fb1800f2dd273817995e1ea9e2a3 blob + 6f90934474817e779ff2f68d5db85b57206aec68 --- src/main.rs +++ src/main.rs @@ -2,7 +2,6 @@ clippy::pedantic, clippy::nursery, clippy::unwrap_used, - clippy::expect_used, clippy::cargo, clippy::style, clippy::complexity, @@ -16,8 +15,6 @@ #[macro_use] extern crate log; -#[macro_use] -extern crate anyhow; pub mod cli; pub mod config; @@ -30,8 +27,8 @@ use crate::{ config::AppConfig, mail::{get_mailer, Mail}, }; -use anyhow::Context; use lettre::{AsyncSmtpTransport, Tokio1Executor}; +use snafu::{prelude::*, Snafu}; use std::{ fs::File, io::{BufRead, BufReader}, @@ -41,26 +38,56 @@ use std::{ use sqlx::{sqlite::SqlitePoolOptions, Sqlite}; use tokio::task::JoinSet; +#[derive(Debug, Snafu)] +pub enum Error { + #[snafu(display("Io Error at \"{path}\": {source}"))] + Io { + path: String, + source: std::io::Error, + }, + #[snafu(display("General database error: {source}"))] + GeneralDatabaseError { source: sqlx::Error }, + #[snafu(display("Migration error: {source}"))] + MigrationError { source: sqlx::migrate::MigrateError }, + #[snafu(display("Task in JoinSet failed to execute to completion: {source}"))] + JoinSet { source: tokio::task::JoinError }, + #[snafu(display("Mail error: {source}"))] + Mail { source: crate::mail::Error }, + #[snafu(display("Config error: {source}"))] + Config { source: crate::config::Error }, +} + #[tokio::main] -async fn main() -> anyhow::Result<()> { - let config = Arc::new(AppConfig::new()?); - let urls: Vec = BufReader::new( - File::open(config.urls_path.as_str()) - .with_context(|| format!("File {:?} does not exist", &config.urls_path))?, - ) - .lines() - .filter_map(Result::ok) - .filter(|l| !l.starts_with('#')) - .collect(); +async fn main() { + if let Err(e) = app_main().await { + eprintln!("{e}"); + std::process::exit(1); + } +} +async fn app_main() -> Result<(), Error> { + let config = Arc::new(AppConfig::new().context(ConfigSnafu)?); + let urls: Vec = + BufReader::new(File::open(config.urls_path.as_str()).context(IoSnafu { + path: &config.urls_path, + })?) + .lines() + .filter_map(Result::ok) + .filter(|l| !l.starts_with('#')) + .collect(); + let db_path = &config.database_path; debug!("Establishing connection to database at {:?}", db_path); let pool = SqlitePoolOptions::new() .max_connections(5) .connect(db_path.as_str()) - .await?; + .await + .context(GeneralDatabaseSnafu)?; - sqlx::migrate!("./migrations").run(&pool).await?; + sqlx::migrate!("./migrations") + .run(&pool) + .await + .context(MigrationSnafu)?; if config.no_fetch { info!("Not fetching any feeds as \"--no-fetch\" has been passed"); @@ -73,18 +100,20 @@ async fn main() -> anyhow::Result<()> { "select * from posts where sent != true order by pub_date desc" ) .fetch_all(&pool) - .await?; + .await + .context(GeneralDatabaseSnafu)?; let mailer = get_mailer( config.smtp_user.clone(), config.smtp_password.clone(), &config.smtp_server, config.smtp_port, - )?; + ) + .context(MailSnafu)?; let mut handles = JoinSet::new(); for result in results { - let mut conn = pool.acquire().await?; + let mut conn = pool.acquire().await.context(GeneralDatabaseSnafu)?; let mailer = mailer.clone(); let config = config.clone(); @@ -103,37 +132,32 @@ async fn main() -> anyhow::Result<()> { while let Some(handle) = handles.join_next().await { // TODO: retry sending mail instead? user should specify number of retries in config - if let Err(e) = handle? { - log::error!("An error occured while sending an email: {}", e); + if let Err(e) = handle.context(JoinSetSnafu)? { + log::error!("An error occured while sending an email: {e}"); } } Ok(()) } -async fn fetch_feeds(urls: Vec, pool: &sqlx::Pool) -> anyhow::Result<()> { +async fn fetch_feeds(urls: Vec, pool: &sqlx::Pool) -> Result<(), Error> { let mut set = JoinSet::new(); for u in urls { set.spawn(async move { feed::fetch_new(u).await }); } while let Some(new) = set.join_next().await { - let posts = match new? { + let posts = match new.context(JoinSetSnafu)? { Ok(p) => p, Err(e) => { - log::error!("Error while fetching feed: {}", e); + log::error!("Error while fetching feed: {e}"); continue; } }; for i in posts { - let conn = pool.acquire().await?; - db::insert_item(conn, &i).await.with_context(|| { - format!( - "Unable to insert item from {:?} with GUID {:?}", - i.url, i.guid - ) - })?; + let conn = pool.acquire().await.context(GeneralDatabaseSnafu)?; + db::insert_item(conn, &i).await?; } } @@ -147,7 +171,7 @@ async fn send_post<'a, E>( to: &'a str, post: models::Post, dry_run: bool, -) -> anyhow::Result<()> +) -> Result<(), Error> where E: sqlx::Executor<'a, Database = Sqlite>, { @@ -156,12 +180,14 @@ where } else { Mail::new(post.title, post.content, post.url) .send_email(from, to, &mailer) - .await?; + .await + .context(MailSnafu)?; } sqlx::query!("update posts set sent = true where guid = ?", post.guid) .execute(conn) - .await?; + .await + .context(GeneralDatabaseSnafu)?; Ok(()) } blob - 6b9fc15c737a4c11fd765fb3267a921c74ad404e blob + 9e2af03f15e99e78f6d321acff448143c9c69437 --- src/models.rs +++ src/models.rs @@ -1,5 +1,12 @@ use chrono::DateTime; +use snafu::Snafu; +#[derive(Debug, Snafu)] +#[snafu(display("Missing GUID on item: {item:?}"))] +pub struct MissingGUID { + item: rss::Item, +} + #[derive(Debug)] pub struct Post { pub guid: String, @@ -11,9 +18,9 @@ pub struct Post { } impl TryFrom for Post { - type Error = anyhow::Error; + type Error = MissingGUID; - fn try_from(item: rss::Item) -> anyhow::Result { + fn try_from(item: rss::Item) -> Result { let time = item.pub_date().map(|date| { DateTime::parse_from_rfc2822(date) .unwrap_or_else(|_| DateTime::default()) @@ -22,7 +29,7 @@ impl TryFrom for Post { let guid = item .guid() - .ok_or_else(|| anyhow!("No guid found"))? + .ok_or_else(|| MissingGUID { item: item.clone() })? .value() .to_string(); let title = item.title().map(String::from); @@ -44,8 +51,10 @@ impl TryFrom for Post { } } +// `TryFrom` is implemented for consistency, not because it can actually fail at the moment impl TryFrom for Post { - type Error = anyhow::Error; + // NOTE: NOT USED + type Error = MissingGUID; fn try_from(value: atom_syndication::Entry) -> Result { let guid = value.id.clone();