extern crate dotenv;
extern crate slog;
extern crate slog_async;
extern crate slog_term;
use chrono::naive::NaiveDateTime;
use chrono::Local;
use clap::clap_app;
use color_eyre::eyre::Report;
use dotenv::dotenv;
use eyre::{eyre, Result};
use elmo_lib::{
check_events, delete_event, list_events, prepare_environment, set_event, EventTemplate,
};
use slog::{error, info, o, Drain};
use sqlx::sqlite::SqlitePool;
use std::env;
use std::fs::OpenOptions;
use std::path::PathBuf;
fn id_validator(arg: String) -> Result<(), String> {
if arg.chars().any(|c| !c.is_numeric()) {
return Err(String::from("ID must be an integer"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_id_validator_alphabetic_string() -> Result<(), String> {
assert_eq!(true, id_validator(String::from("abc")).is_err());
Ok(())
}
#[test]
fn test_id_validator_numeric_string() -> Result<(), String> {
assert_eq!(false, id_validator(String::from("123")).is_err());
Ok(())
}
#[test]
fn test_id_validator_with_special_signs() -> Result<(), String> {
assert_eq!(true, id_validator(String::from("12$")).is_err());
Ok(())
}
#[test]
fn test_id_validator_with_spaces() -> Result<(), String> {
assert_eq!(true, id_validator(String::from("123 4 5")).is_err());
Ok(())
}
#[test]
fn test_id_validator_negative_number() -> Result<(), String> {
assert_eq!(true, id_validator(String::from("-1")).is_err());
Ok(())
}
#[test]
fn test_id_validator_very_high_number() -> Result<(), String> {
assert_eq!(
false,
id_validator(String::from("123456789101112")).is_err()
);
Ok(())
}
}
fn argument_parser() -> clap::ArgMatches<'static> {
clap_app!(elmo =>
(version: "0.0")
(author: "Sebastian Fricke <sebastian.fricke@posteo.net>")
(about: "Extended notification service")
(@arg VERBOSE: -v --verbose "Print more information")
(@arg LOGFILE: -l --log +takes_value "Write logs to file instead of STDOUT")
(@subcommand check =>
(about: "Check for upcoming notification events")
)
(@subcommand set =>
(about: "Set a new notification event")
(@arg NAME: +required "Name of the event")
(@arg DESCRIPTION: "Description of the event")
(@arg ANNOUNCE: -a --announce
"Create a test notification to verify that the creation was successful")
(@arg SOUND: -s --sound +takes_value
"Location of the sound file")
(@arg TEST_SOUND: --test_sound requires_all(&["SOUND", "ANNOUNCE"])
"Playback the sound at the given location for scheduled events")
(@arg ICON: -i --icon +takes_value
"Name of the icon file without extension or path")
(@arg WAIT: -w --wait +takes_value conflicts_with[CRON DATE] required_if("ANNOUNCE", "true")
"Wait for a specified amount of time and notify")
(@arg DATE: -d --date +takes_value conflicts_with[CRON WAIT] required_if("ANNOUNCE", "true")
"Notify at the given datetime")
(@arg CRON: -c --cron +takes_value conflicts_with[DATE WAIT] required_if("ANNOUNCE", "true")
"Provide a cron-like string for a recurring reminder")
)
(@subcommand list =>
(about: "List upcoming notifications (lists all by default)")
(@arg EXPRESSION: -e --exp +takes_value
"List all upcoming notifications, that match the time expression")
(@arg CRON: -c --cron
"List only recurring notifications")
)
(@subcommand delete =>
(about: "Delete an existing event by using its ID, you can find the ID with the list command")
(@arg ID: +required {id_validator} "ID of the event to delete")
(@arg CRON: -c --cron
"Delete a recurring event instead of a regular event")
(@arg CONFIRM: -y --yes
"Skip the dialog to confirm your choice")
)
)
.get_matches()
}
fn setup() -> Result<(), Report> {
if std::env::var("RUST_LIB_BACKTRACE").is_err() {
std::env::set_var("RUST_LIB_BACKTRACE", "1")
}
color_eyre::install()?;
Ok(())
}
fn check_log_file(path: &str) -> Result<PathBuf> {
let log_file: PathBuf = PathBuf::from(path);
if !log_file.is_file() {
OpenOptions::new().write(true).create_new(true).open(path)?;
}
Ok(log_file)
}
fn create_logger(log_file: Option<PathBuf>) -> Result<slog::Logger> {
if log_file.is_some() {
let decorator = slog_term::PlainDecorator::new(
OpenOptions::new()
.write(true)
.truncate(true)
.open(log_file.unwrap())?,
);
let drain = slog_term::CompactFormat::new(decorator).build().fuse();
let drain = slog_async::Async::new(drain).build().fuse();
Ok(slog::Logger::root(drain, o!()))
} else {
let plain = slog_term::PlainSyncDecorator::new(std::io::stdout());
Ok(slog::Logger::root(
slog_term::FullFormat::new(plain).build().fuse(),
o!(),
))
}
}
fn get_database_url() -> Result<String> {
if let Ok(env_var) = env::var("ELMO_DATABASE_URL") {
Ok(env_var)
} else if let Some(mut db_path) = home::home_dir() {
db_path.push(".elmo/events.db");
std::fs::create_dir_all(db_path.parent().unwrap())?;
OpenOptions::new().write(true).create(true).open(&db_path)?;
match db_path.into_os_string().into_string() {
Ok(path) => Ok(path),
Err(err_path) => Err(eyre!("Invalid path string: {}", err_path.to_str().unwrap())),
}
} else {
Err(eyre!(
"Unable to get the home directory from the current user."
))
}
}
#[tokio::main]
async fn main() -> Result<(), Report> {
setup()?;
dotenv().ok();
let matches = argument_parser();
let log_file: Option<PathBuf>;
if matches.is_present("LOGFILE") {
let log_file_check = check_log_file(matches.value_of("LOGFILE").unwrap());
match log_file_check {
Ok(x) => log_file = Some(x),
Err(why) => panic!("Creating a new log file failed, [{}]", why),
};
} else {
log_file = None;
}
let log = create_logger(log_file)?;
let pool;
let db_url = get_database_url();
match db_url {
Ok(url) => {
pool = SqlitePool::connect(&url).await?;
if matches.is_present("VERBOSE") {
info!(
log,
"Use database at {} for storing and reading events", url
);
}
}
Err(err) => {
let error = eyre!("Unable to create a default database file, {}", err);
error!(log, "{}", error);
return Err(error);
}
}
prepare_environment(&pool).await.map_err(|err| {
error!(log, "Creation of tables failed {}", err);
err
})?;
let now: NaiveDateTime = Local::now().naive_local();
if matches.is_present("VERBOSE") {
info!(log, "Current datetime: {}", now);
}
if let Some(_sub_matches) = matches.subcommand_matches("check") {
check_events(&pool).await.map_err(|err| {
error!(log, "Checking events failed: {}", err);
err
})?;
} else if let Some(sub_matches) = matches.subcommand_matches("set") {
let new_event = EventTemplate {
name: sub_matches.value_of("NAME").unwrap().to_string(),
desc: match sub_matches.value_of("DESCRIPTION") {
Some(desc) => desc.to_string(),
None => String::from(""),
},
date_str: sub_matches.value_of("DATE").map(|x| x.to_string()),
wait_expr: sub_matches.value_of("WAIT").map(|x| x.to_string()),
cron_str: sub_matches.value_of("CRON").map(|x| x.to_string()),
icon_name: sub_matches.value_of("ICON").map(|x| x.to_string()),
sound_path: sub_matches.value_of("SOUND").map(|x| x.to_string()),
announce: sub_matches.is_present("ANNOUNCE"),
test_sound: sub_matches.is_present("TEST_SOUND"),
};
set_event(new_event, &pool).await.map_err(|err| {
error!(log, "Setting an event failed: {}", err);
err
})?;
} else if let Some(sub_matches) = matches.subcommand_matches("list") {
list_events(
&pool,
sub_matches.value_of("EXPRESSION"),
sub_matches.is_present("CRON"),
)
.await
.map_err(|err| {
error!(log, "Listing events failed: {}", err);
err
})?;
} else if let Some(sub_matches) = matches.subcommand_matches("delete") {
let id = sub_matches.value_of("ID").unwrap().parse::<i64>().unwrap();
delete_event(
&pool,
id,
sub_matches.is_present("CRON"),
sub_matches.is_present("CONFIRM"),
)
.await
.map_err(|err| {
error!(
log,
"Deletion of ID {} in the {} table failed, due to {}",
id,
if sub_matches.is_present("CRON") {
"recurring_events"
} else {
"events"
},
err
);
err
})?;
}
Ok(())
}