cli-diary 0.8.0

A CLI based diary tool for adding entries on the fly.
Documentation
#![doc = include_str!("../../README.md")]
#![warn(clippy::all, clippy::nursery)]
extern crate confy;

#[macro_use]
extern crate serde_derive;
extern crate serde;

pub mod config;
pub mod errors;
pub mod ops;
pub mod utils;

pub type CliResult = Result<(), errors::CliError>;

use std::{
    fs::{File, OpenOptions},
    io,
    path::{Path, PathBuf},
    str::FromStr,
};

use chrono::prelude::*;
use enum_dispatch::enum_dispatch;

use crate::{
    config::Config,
    errors::DiaryError,
    utils::{date, file_system},
};

fn title_elements(date: DateTime<Local>) -> (String, String, String) {
    let start_title = date.format("%A %-e").to_string();
    let date_superscript = date::date_superscript(date.day()).to_owned();
    let end_title = date.format("%B %Y").to_string();

    (start_title, date_superscript, end_title)
}

#[enum_dispatch]
pub trait EntryContent {
    fn extension(&self) -> &'static str;

    fn title(&self, date: &DateTime<Local>) -> String;

    fn tag(&self, tag_name: String) -> String;
}

pub struct MarkdownDiary {}

impl EntryContent for MarkdownDiary {
    fn extension(&self) -> &'static str {
        "md"
    }

    fn title(&self, date: &DateTime<Local>) -> String {
        let (start_title, date_superscript, end_title) = title_elements(*date);

        format!(
            "# {}<sup>{}</sup> {}\n\n",
            start_title, date_superscript, end_title
        )
    }

    fn tag(&self, tag_name: String) -> String {
        format!("## {}\n\n", tag_name)
    }
}
pub struct RstDiary {}

impl EntryContent for RstDiary {
    fn extension(&self) -> &'static str {
        "rst"
    }

    fn title(&self, date: &DateTime<Local>) -> String {
        let (start_title, date_superscript, end_title) = title_elements(*date);

        let first_line = format!(
            "{}\\ :sup:`{}` {}",
            start_title, date_superscript, end_title
        );
        let first_line_len = first_line.chars().count();

        let second_line = format!("{:=<1$}", "", first_line_len);

        format!("{}\n{}\n\n", first_line, second_line)
    }

    fn tag(&self, tag_name: String) -> String {
        let first_line_len = tag_name.chars().count();

        let second_line = format!("{:^<1$}", "", first_line_len);

        format!("{}\n{}\n\n", tag_name, second_line)
    }
}

#[enum_dispatch(EntryContent)]
#[non_exhaustive]
pub enum EntryFileType {
    MarkdownDiary,
    RstDiary,
}

impl FromStr for EntryFileType {
    type Err = DiaryError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "md" => Ok(MarkdownDiary {}.into()),
            "rst" => Ok(RstDiary {}.into()),
            _ => Err(DiaryError::BadFileType),
        }
    }
}

pub fn process_file_type(potential_file_type: Option<&str>) -> Result<Option<&str>, DiaryError> {
    potential_file_type.map_or_else(
        || Ok(None),
        |file_type| match EntryFileType::from_str(file_type) {
            Err(_) => Err(DiaryError::BadFileType),
            Ok(_) => Ok(Some(file_type)),
        },
    )
}

pub struct Diary {
    prefix: String,
    diary_path: PathBuf,
    file_type: EntryFileType,
}

impl Diary {
    pub fn new(prefix: &str, diary_path: &Path, file_type: &str) -> Result<Box<Self>, DiaryError> {
        if !diary_path.exists() {
            return Err(DiaryError::UnInitialised { source: None });
        }
        let entry_file_type = EntryFileType::from_str(file_type)?;
        Ok(Box::new(Self {
            prefix: prefix.to_owned(),
            diary_path: diary_path.to_path_buf(),
            file_type: entry_file_type,
        }))
    }

    pub fn from_config(cfg: &Config) -> Result<Box<Self>, DiaryError> {
        Self::new(cfg.prefix(), cfg.diary_path(), cfg.file_type())
    }

    pub const fn prefix(&self) -> &String {
        &self.prefix
    }
    pub const fn diary_path(&self) -> &PathBuf {
        &self.diary_path
    }
    pub const fn file_type(&self) -> &EntryFileType {
        &self.file_type
    }
    pub fn file_name(&self, date: &DateTime<Local>) -> PathBuf {
        let entry_suffix = date.format("%Y-%m-%d").to_string();
        let file_name = format!(
            "{}_{}.{}",
            self.prefix,
            entry_suffix,
            self.file_type.extension()
        );
        PathBuf::from(file_name)
    }
    pub fn get_entry_path(&self, date: &DateTime<Local>) -> PathBuf {
        let mut entry_path = file_system::month_folder(self.diary_path(), date);
        let entry_name = self.file_name(date);
        entry_path.push(entry_name);
        entry_path
    }
    pub fn get_entry_file(&self, date: &DateTime<Local>) -> io::Result<File> {
        let entry_path = self.get_entry_path(date);
        return OpenOptions::new().append(true).open(entry_path);
    }
}

#[cfg(test)]
mod tests {
    use std::{path::PathBuf, str::FromStr};

    use chrono::prelude::*;

    use super::{process_file_type, Diary, EntryContent, EntryFileType, MarkdownDiary, RstDiary};
    use crate::config::Config;

    #[test]
    fn get_extension() {
        let entry_file = EntryFileType::from_str("rst").unwrap();

        assert_eq!(entry_file.extension(), "rst");

        let entry_file = EntryFileType::from_str("md").unwrap();

        assert_eq!(entry_file.extension(), "md")
    }

    #[test]
    #[should_panic(expected = "value: BadFileType")]
    fn unsupported_file_extension() {
        process_file_type(Some("xyz")).unwrap();
    }

    #[test]
    fn rst_title() {
        let entry_file = RstDiary {};
        let entry_date = Local.with_ymd_and_hms(2021, 11, 6, 0, 0, 0).unwrap();

        let actual_header = entry_file.title(&entry_date);

        let expected_header =
            "Saturday 6\\ :sup:`th` November 2021\n===================================\n\n";

        assert_eq!(actual_header, expected_header)
    }

    #[test]
    fn rst_tag() {
        let entry_file = RstDiary {};

        let actual_tag = entry_file.tag("Meeting".to_string());

        let expected_tag = "Meeting\n^^^^^^^\n\n";

        assert_eq!(actual_tag, expected_tag)
    }

    #[test]
    fn md_title() {
        let entry_file = MarkdownDiary {};
        let entry_date = Local.with_ymd_and_hms(2021, 11, 6, 0, 0, 0).unwrap();

        let actual_header = entry_file.title(&entry_date);

        let expected_header = "# Saturday 6<sup>th</sup> November 2021\n\n";

        assert_eq!(actual_header, expected_header)
    }

    #[test]
    fn markdown_tag() {
        let entry_file = MarkdownDiary {};

        let actual_tag = entry_file.tag("Meeting".to_string());

        let expected_tag = "## Meeting\n\n";

        assert_eq!(actual_tag, expected_tag)
    }

    #[test]
    fn diary_file_from_config() {
        let cfg = Config::builder()
            .file_type("md")
            .diary_path("/".into())
            .build();

        let diary_file = Diary::from_config(&cfg).unwrap();

        assert_eq!(diary_file.file_type().extension(), "md");
        assert_eq!(diary_file.diary_path(), &PathBuf::from("/"));
        assert_eq!(diary_file.prefix(), "diary")
    }
}