elmo 0.0.1

Library for easy creation of persistent notifications
Documentation
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(())
}

/// Check if the log-file already exists and create a new one if required
///
/// # Parameters
///
/// - `path`: Os-specific path as string from the argument parser
///
/// # Returns
///
/// - Path as a PathBuf object
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)
}

/// Create a thread-safe logger, logging to a specific file or STDOUT.
///
/// # Parameters
///
/// - `log_file`: String reference of the full path to the log file or None (to log to STDOUT)
///
/// # Returns
///
/// - owned Logger object logging to `log_file` or to STDOUT
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!(),
        ))
    }
}

/// Get the location to the SQlite3 database file.
///
/// If the `ELMO_DATABASE_URL` environment variable is given it takes precedence over
/// the default fallback location at `~/.elmo/events.db`.
///
/// # Returns
/// - `path`: String representation of the Os-specific path to the database file
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");
        // Create the directory and the file if they don't exist
        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;
    }
    // TODO catch the error and send it to the logger
    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);
        }
    }
    // Create the database tables if they do not exist already
    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") {
        // The ID has already been checked by the validator in the argument parser
        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(())
}