1#![doc = include_str!("../../README.md")]
2#![warn(clippy::all, clippy::nursery)]
3extern crate confy;
4
5#[macro_use]
6extern crate serde_derive;
7extern crate serde;
8
9pub mod config;
10pub mod errors;
11pub mod ops;
12pub mod utils;
13
14pub type CliResult = Result<(), errors::CliError>;
15
16use std::{
17    fs::{File, OpenOptions},
18    io,
19    path::{Path, PathBuf},
20    str::FromStr,
21};
22
23use chrono::prelude::*;
24use enum_dispatch::enum_dispatch;
25
26use crate::{
27    config::Config,
28    errors::DiaryError,
29    utils::{date, file_system},
30};
31
32fn title_elements(date: DateTime<Local>) -> (String, String, String) {
33    let start_title = date.format("%A %-e").to_string();
34    let date_superscript = date::date_superscript(date.day()).to_owned();
35    let end_title = date.format("%B %Y").to_string();
36
37    (start_title, date_superscript, end_title)
38}
39
40#[enum_dispatch]
41pub trait EntryContent {
42    fn extension(&self) -> &'static str;
43
44    fn title(&self, date: &DateTime<Local>) -> String;
45
46    fn tag(&self, tag_name: String) -> String;
47}
48
49pub struct MarkdownDiary {}
50
51impl EntryContent for MarkdownDiary {
52    fn extension(&self) -> &'static str {
53        "md"
54    }
55
56    fn title(&self, date: &DateTime<Local>) -> String {
57        let (start_title, date_superscript, end_title) = title_elements(*date);
58
59        format!(
60            "# {}<sup>{}</sup> {}\n\n",
61            start_title, date_superscript, end_title
62        )
63    }
64
65    fn tag(&self, tag_name: String) -> String {
66        format!("## {}\n\n", tag_name)
67    }
68}
69pub struct RstDiary {}
70
71impl EntryContent for RstDiary {
72    fn extension(&self) -> &'static str {
73        "rst"
74    }
75
76    fn title(&self, date: &DateTime<Local>) -> String {
77        let (start_title, date_superscript, end_title) = title_elements(*date);
78
79        let first_line = format!(
80            "{}\\ :sup:`{}` {}",
81            start_title, date_superscript, end_title
82        );
83        let first_line_len = first_line.chars().count();
84
85        let second_line = format!("{:=<1$}", "", first_line_len);
86
87        format!("{}\n{}\n\n", first_line, second_line)
88    }
89
90    fn tag(&self, tag_name: String) -> String {
91        let first_line_len = tag_name.chars().count();
92
93        let second_line = format!("{:^<1$}", "", first_line_len);
94
95        format!("{}\n{}\n\n", tag_name, second_line)
96    }
97}
98
99#[enum_dispatch(EntryContent)]
100#[non_exhaustive]
101pub enum EntryFileType {
102    MarkdownDiary,
103    RstDiary,
104}
105
106impl FromStr for EntryFileType {
107    type Err = DiaryError;
108
109    fn from_str(s: &str) -> Result<Self, Self::Err> {
110        match s {
111            "md" => Ok(MarkdownDiary {}.into()),
112            "rst" => Ok(RstDiary {}.into()),
113            _ => Err(DiaryError::BadFileType),
114        }
115    }
116}
117
118pub fn process_file_type(potential_file_type: Option<&str>) -> Result<Option<&str>, DiaryError> {
119    potential_file_type.map_or_else(
120        || Ok(None),
121        |file_type| match EntryFileType::from_str(file_type) {
122            Err(_) => Err(DiaryError::BadFileType),
123            Ok(_) => Ok(Some(file_type)),
124        },
125    )
126}
127
128pub struct Diary {
129    prefix: String,
130    diary_path: PathBuf,
131    file_type: EntryFileType,
132}
133
134impl Diary {
135    pub fn new(prefix: &str, diary_path: &Path, file_type: &str) -> Result<Box<Self>, DiaryError> {
136        if !diary_path.exists() {
137            return Err(DiaryError::UnInitialised { source: None });
138        }
139        let entry_file_type = EntryFileType::from_str(file_type)?;
140        Ok(Box::new(Self {
141            prefix: prefix.to_owned(),
142            diary_path: diary_path.to_path_buf(),
143            file_type: entry_file_type,
144        }))
145    }
146
147    pub fn from_config(cfg: &Config) -> Result<Box<Self>, DiaryError> {
148        Self::new(cfg.prefix(), cfg.diary_path(), cfg.file_type())
149    }
150
151    pub const fn prefix(&self) -> &String {
152        &self.prefix
153    }
154    pub const fn diary_path(&self) -> &PathBuf {
155        &self.diary_path
156    }
157    pub const fn file_type(&self) -> &EntryFileType {
158        &self.file_type
159    }
160    pub fn file_name(&self, date: &DateTime<Local>) -> PathBuf {
161        let entry_suffix = date.format("%Y-%m-%d").to_string();
162        let file_name = format!(
163            "{}_{}.{}",
164            self.prefix,
165            entry_suffix,
166            self.file_type.extension()
167        );
168        PathBuf::from(file_name)
169    }
170    pub fn get_entry_path(&self, date: &DateTime<Local>) -> PathBuf {
171        let mut entry_path = file_system::month_folder(self.diary_path(), date);
172        let entry_name = self.file_name(date);
173        entry_path.push(entry_name);
174        entry_path
175    }
176    pub fn get_entry_file(&self, date: &DateTime<Local>) -> io::Result<File> {
177        let entry_path = self.get_entry_path(date);
178        return OpenOptions::new().append(true).open(entry_path);
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use std::{path::PathBuf, str::FromStr};
185
186    use chrono::prelude::*;
187
188    use super::{process_file_type, Diary, EntryContent, EntryFileType, MarkdownDiary, RstDiary};
189    use crate::config::Config;
190
191    #[test]
192    fn get_extension() {
193        let entry_file = EntryFileType::from_str("rst").unwrap();
194
195        assert_eq!(entry_file.extension(), "rst");
196
197        let entry_file = EntryFileType::from_str("md").unwrap();
198
199        assert_eq!(entry_file.extension(), "md")
200    }
201
202    #[test]
203    #[should_panic(expected = "value: BadFileType")]
204    fn unsupported_file_extension() {
205        process_file_type(Some("xyz")).unwrap();
206    }
207
208    #[test]
209    fn rst_title() {
210        let entry_file = RstDiary {};
211        let entry_date = Local.with_ymd_and_hms(2021, 11, 6, 0, 0, 0).unwrap();
212
213        let actual_header = entry_file.title(&entry_date);
214
215        let expected_header =
216            "Saturday 6\\ :sup:`th` November 2021\n===================================\n\n";
217
218        assert_eq!(actual_header, expected_header)
219    }
220
221    #[test]
222    fn rst_tag() {
223        let entry_file = RstDiary {};
224
225        let actual_tag = entry_file.tag("Meeting".to_string());
226
227        let expected_tag = "Meeting\n^^^^^^^\n\n";
228
229        assert_eq!(actual_tag, expected_tag)
230    }
231
232    #[test]
233    fn md_title() {
234        let entry_file = MarkdownDiary {};
235        let entry_date = Local.with_ymd_and_hms(2021, 11, 6, 0, 0, 0).unwrap();
236
237        let actual_header = entry_file.title(&entry_date);
238
239        let expected_header = "# Saturday 6<sup>th</sup> November 2021\n\n";
240
241        assert_eq!(actual_header, expected_header)
242    }
243
244    #[test]
245    fn markdown_tag() {
246        let entry_file = MarkdownDiary {};
247
248        let actual_tag = entry_file.tag("Meeting".to_string());
249
250        let expected_tag = "## Meeting\n\n";
251
252        assert_eq!(actual_tag, expected_tag)
253    }
254
255    #[test]
256    fn diary_file_from_config() {
257        let cfg = Config::builder()
258            .file_type("md")
259            .diary_path("/".into())
260            .build();
261
262        let diary_file = Diary::from_config(&cfg).unwrap();
263
264        assert_eq!(diary_file.file_type().extension(), "md");
265        assert_eq!(diary_file.diary_path(), &PathBuf::from("/"));
266        assert_eq!(diary_file.prefix(), "diary")
267    }
268}