commit - 6397d1ece16453ad239561e06deebb0a352cd85a
commit + fac6f83825df33e7e88b106983ce5889f9d0c4e3
blob - 136c1bfa9246f2b06b5a9244755309d61db0db17
blob + 4b3d013017785bc22b3946c921230182cd48c4b2
--- Cargo.lock
+++ Cargo.lock
]
[[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"
]
[[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"
name = "rss-email"
version = "0.4.2"
dependencies = [
- "anyhow",
"atom_syndication",
"chrono",
"clap",
"rss",
"serde",
"simple_logger",
+ "snafu",
"sqlx",
"tokio",
"toml",
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"
[[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
[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"
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
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 {
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<Self> {
+ pub fn build_app() -> Result<Self, Error> {
use std::fs::File;
let args = Self::parse();
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() {
"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() {
"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)
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
-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,
}
impl Config {
- pub fn new(config_path: impl AsRef<Path>) -> anyhow::Result<Self> {
+ pub fn new(config_path: impl AsRef<Path>) -> Result<Self, Error> {
+ 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)
}
///
/// 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<Self> {
- let cli = crate::cli::Cli::build_app()?;
+ pub fn new() -> Result<Self, Error> {
+ 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
+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<Sqlite>, post: &Post) -> anyhow::Result<()> {
+pub async fn insert_item(mut conn: PoolConnection<Sqlite>, post: &Post) -> Result<(), Error> {
sqlx::query!(
"insert or ignore into posts (guid, title, url, pub_date, content) values (?, ?, ?, ?, ?)",
post.guid,
post.content
)
.execute(&mut conn)
- .await?;
+ .await
+ .context(GeneralDatabaseSnafu)?;
Ok(())
}
blob - cdc2c1750a457cdc08f93f652ead8270c1ac3500
blob + 29d0bf7e8ec045d8ee2c556bc47934a9a7741e4f
--- src/feed.rs
+++ src/feed.rs
use atom_syndication;
use rss;
+use snafu::{prelude::*, Snafu};
-use crate::anyhow::Context;
use crate::models::Post;
+#[derive(Debug, Snafu)]
+pub enum Error {
+ #[snafu(display("reqwest got error fetching feed \"{url}\": {source}"))]
+ ReceivingFeed { url: String, source: reqwest::Error },
+ #[snafu(display("RSS feed is formatted incorrectly: {url}"))]
+ InvalidRSS { url: String },
+ #[snafu(display("Atom feed is formatted incorrectly: {url}"))]
+ InvalidAtom { url: String },
+ #[snafu(display("Feed \"{url}\" is neither an RSS nor an Atom feed"))]
+ InvalidFeed { url: String },
+}
+
/// Fetch a new feed, RSS or Atom.
///
/// # Errors
///
/// An error occurs if the feed cannot be fetched or is invalid.
-pub async fn fetch_new<S>(url: S) -> anyhow::Result<Vec<Post>>
+pub async fn fetch_new<S>(url: S) -> Result<Vec<Post>, Error>
where
S: AsRef<str> + 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),
}
}
/// # Errors
///
/// An error occurs if the given bytes are an invalid RSS feed.
-pub fn fetch_new_rss(bytes: &[u8]) -> anyhow::Result<Vec<Post>> {
- let channel = rss::Channel::read_from(bytes).context("Unable to read from RSS feed")?;
+pub fn fetch_new_rss(bytes: &[u8]) -> Result<Vec<Post>, rss::Error> {
+ let channel = rss::Channel::read_from(bytes)?;
Ok(channel
.items
/// # Errors
///
/// An error occurs if the given bytes are an invalid Atom feed.
-pub fn fetch_new_atom(bytes: &[u8]) -> anyhow::Result<Vec<Post>> {
- let feed = atom_syndication::Feed::read_from(bytes).context("Unable to read from atom feed")?;
+pub fn fetch_new_atom(bytes: &[u8]) -> Result<Vec<Post>, atom_syndication::Error> {
+ let feed = atom_syndication::Feed::read_from(bytes)?;
Ok(feed
.entries
blob - 1f217a66fd933723bd85b74832ff8e3ce3a3d71c
blob + 51fbeb368498d43e6cd239897feffa5f323775e3
--- src/mail.rs
+++ src/mail.rs
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,
from: &'a str,
to: &'a str,
mailer: &AsyncSmtpTransport<Tokio1Executor>,
- ) -> 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(())
}
password: String,
server: &str,
port: u16,
-) -> anyhow::Result<AsyncSmtpTransport<Tokio1Executor>> {
+) -> Result<AsyncSmtpTransport<Tokio1Executor>, Error> {
let creds = Credentials::new(user, password);
- let mailer = AsyncSmtpTransport::<Tokio1Executor>::relay(server)?
+ let mailer = AsyncSmtpTransport::<Tokio1Executor>::relay(server)
+ .context(SMTPSnafu)?
.credentials(creds)
.port(port)
.build();
blob - 05e73279c485fb1800f2dd273817995e1ea9e2a3
blob + 6f90934474817e779ff2f68d5db85b57206aec68
--- src/main.rs
+++ src/main.rs
clippy::pedantic,
clippy::nursery,
clippy::unwrap_used,
- clippy::expect_used,
clippy::cargo,
clippy::style,
clippy::complexity,
#[macro_use]
extern crate log;
-#[macro_use]
-extern crate anyhow;
pub mod cli;
pub mod config;
config::AppConfig,
mail::{get_mailer, Mail},
};
-use anyhow::Context;
use lettre::{AsyncSmtpTransport, Tokio1Executor};
+use snafu::{prelude::*, Snafu};
use std::{
fs::File,
io::{BufRead, BufReader},
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<String> = 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<String> =
+ 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");
"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();
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<String>, pool: &sqlx::Pool<Sqlite>) -> anyhow::Result<()> {
+async fn fetch_feeds(urls: Vec<String>, pool: &sqlx::Pool<Sqlite>) -> 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?;
}
}
to: &'a str,
post: models::Post,
dry_run: bool,
-) -> anyhow::Result<()>
+) -> Result<(), Error>
where
E: sqlx::Executor<'a, Database = Sqlite>,
{
} 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
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,
}
impl TryFrom<rss::Item> for Post {
- type Error = anyhow::Error;
+ type Error = MissingGUID;
- fn try_from(item: rss::Item) -> anyhow::Result<Self> {
+ fn try_from(item: rss::Item) -> Result<Self, Self::Error> {
let time = item.pub_date().map(|date| {
DateTime::parse_from_rfc2822(date)
.unwrap_or_else(|_| DateTime::default())
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);
}
}
+// `TryFrom` is implemented for consistency, not because it can actually fail at the moment
impl TryFrom<atom_syndication::Entry> for Post {
- type Error = anyhow::Error;
+ // NOTE: NOT USED
+ type Error = MissingGUID;
fn try_from(value: atom_syndication::Entry) -> Result<Self, Self::Error> {
let guid = value.id.clone();