use std::fmt::{self, Debug, Display};
use once_cell::sync::Lazy;
use regex::Regex;
const STOP_CMD: &str = "stop";
static TIMESTAMP_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"\A(\d{4}[-/](?:0[1-9]|1[0-2])[-/](?:0[1-9]|[12][0-9]|3[01]) (?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-6][0-9])").expect("Date time Regex failed.")
});
static LAX_LINE_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"\A(\d{4}[-/](?:0[1-9]|1[0-2])[-/](?:0[1-9]|[12][0-9]|3[01]) (?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-6][0-9])(.)(.+)").expect("Entry line Regex failed.")
});
pub static PROJECT_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\+(\S+)").expect("Entry project regex failed."));
static TASKNAME_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"@(\S+)").expect("Task name Regex failed."));
static STOP_LINE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"\A(\d{4}[-/](?:0[1-9]|1[0-2])[-/](?:0[1-9]|[12][0-9]|3[01]) (?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-6][0-9]) stop").expect("Stop line Regex failed")
});
static EVENT_LINE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"\A(\d{4}[-/](?:0[1-9]|1[0-2])[-/](?:0[1-9]|[12][0-9]|3[01]) (?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-6][0-9])\^.+").expect("Event line Regex failed")
});
pub static YEAR_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^(\d\d\d\d)").expect("Date regex failed"));
pub static MARKER_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d(.)").expect("Marker regex failed"));
#[doc(inline)]
use crate::date::{Date, DateTime};
pub mod error;
pub mod kind;
pub use error::EntryError;
pub use kind::EntryKind;
#[derive(Debug, Clone, Eq, PartialEq)]
#[must_use]
pub struct Entry {
time: DateTime,
project: Option<String>,
text: String,
kind: EntryKind
}
impl Entry {
pub fn task_breakdown(entry_text: &str) -> (Option<String>, Option<String>) {
if entry_text.is_empty() {
return (None, None);
}
let task = PROJECT_RE.replace(entry_text, "").trim().to_string();
if let Some(caps) = TASKNAME_RE.captures(&task) {
if let Some(tname) = caps.get(1) {
let detail = TASKNAME_RE.replace(&task, "").trim().to_string();
let tname = tname.as_str().to_string();
return (Some(tname), (!detail.is_empty()).then_some(detail));
}
}
(None, (!task.is_empty()).then_some(task))
}
pub fn is_stop_line(line: &str) -> bool { STOP_LINE.is_match(line) }
pub fn is_event_line(line: &str) -> bool { EVENT_LINE.is_match(line) }
pub fn datetime_from_line(line: &str) -> Option<&str> {
if line.is_empty() || Self::is_comment_line(line) {
return None;
}
if let Some(caps) = LAX_LINE_RE.captures(line) {
return caps.get(1).map(|s| s.as_str());
}
None
}
pub fn date_from_line(line: &str) -> Option<&str> {
Self::datetime_from_line(line).and_then(|s| s.split_whitespace().next())
}
pub fn extract_year(line: &str) -> Option<i32> {
if Self::is_comment_line(line) {
return None;
}
YEAR_RE
.captures(line)
.and_then(|cap| cap[0].parse::<i32>().ok())
}
pub fn is_comment_line(line: &str) -> bool { line.starts_with('#') }
}
impl Entry {
pub fn new(entry_text: &str, time: DateTime) -> Self {
Self::new_marked(entry_text, time, EntryKind::Start)
}
pub fn new_marked(entry_text: &str, time: DateTime, kind: EntryKind) -> Self {
let kind = if kind == EntryKind::Start && entry_text == STOP_CMD {
EntryKind::Stop
}
else {
kind
};
let oproject = PROJECT_RE.captures(entry_text)
.and_then(|caps| caps.get(1).map(|m| String::from(m.as_str())));
Self { time, project: oproject, text: entry_text.into(), kind }
}
pub fn new_stop(time: DateTime) -> Self { Self::new_marked(STOP_CMD, time, EntryKind::Stop) }
pub fn from_line(line: &str) -> Result<Self, EntryError> {
if line.is_empty() {
return Err(EntryError::BlankLine);
}
match LAX_LINE_RE.captures(line) {
Some(caps) => {
let Some(stamp) = caps.get(1) else { return Err(EntryError::InvalidTimeStamp); };
let Ok(time) = stamp.as_str().parse::<DateTime>() else {
return Err(EntryError::InvalidTimeStamp);
};
let kind = EntryKind::try_new(caps.get(2).and_then(|m| m.as_str().chars().next()))?;
Ok(Entry::new_marked(
caps.get(3).map_or("", |m| m.as_str()),
time,
kind
))
}
None => {
if TIMESTAMP_RE.is_match(line) {
Err(EntryError::MissingTask)
}
else {
Err(EntryError::InvalidTimeStamp)
}
}
}
}
}
impl Entry {
pub fn project(&self) -> Option<&str> { self.project.as_deref() }
pub fn entry_text(&self) -> &str { &self.text }
pub fn task(&self) -> Option<String> { Self::task_breakdown(&self.text).0 }
pub fn detail(&self) -> Option<String> { Self::task_breakdown(&self.text).1 }
pub fn task_and_detail(&self) -> (Option<String>, Option<String>) {
Self::task_breakdown(&self.text)
}
pub fn epoch(&self) -> i64 { self.time.timestamp() }
pub fn date(&self) -> Date { self.time.date() }
pub fn date_time(&self) -> DateTime { self.time }
#[rustfmt::skip]
pub fn timestamp(&self) -> String {
format!("{} {:02}:{:02}", self.time.date(), self.time.hour(), self.time.minute())
}
pub fn stamp(&self) -> String { self.date().to_string() }
pub fn is_start(&self) -> bool { self.kind == EntryKind::Start }
pub fn is_stop(&self) -> bool { self.kind == EntryKind::Stop }
pub fn is_ignore(&self) -> bool { self.kind == EntryKind::Ignored }
pub fn ignore(&self) -> Self { Self { kind: EntryKind::Ignored, ..self.clone() } }
pub fn is_event(&self) -> bool { self.kind == EntryKind::Event }
}
impl Entry {
pub fn change_date_time(&self, date_time: DateTime) -> Self {
let mut entry = self.clone();
entry.time = date_time;
entry
}
pub fn change_text(&self, task: &str) -> Self {
if self.is_stop() { return self.clone(); }
Self::new_marked(task, self.time, self.kind)
}
pub fn to_day_end(&self) -> Self { Self { time: self.date().day_end(), ..self.clone() } }
}
impl Display for Entry {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mark: char = self.kind.into();
write!(f, "{}{mark}{}", self.time, self.text)
}
}
impl PartialOrd for Entry {
#[rustfmt::skip]
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.time.cmp(&other.time)
.then_with(|| self.text.cmp(&other.text)))
}
}
#[cfg(test)]
mod tests {
use assert2::{assert, let_assert};
use rstest::rstest;
use super::*;
const CANONICAL_LINE: &str = "2013-06-05 10:00:02 +proj1 @do something";
const IGNORED_LINE: &str = "2013-06-05 10:00:02!+proj1 @do something";
const EVENT_LINE: &str = "2013-06-05 10:00:02^+proj1 @do something";
const STOP_LINE: &str = "2013-06-05 10:00:02 stop";
fn reference_time() -> i64 { DateTime::new((2013, 6, 5), (10, 0, 2)).expect("Hardcoded value").timestamp() }
#[test]
fn from_line_error_if_empty() {
assert!(Err(EntryError::BlankLine) == Entry::from_line(""));
}
#[rstest]
#[case("# Random comment", "simple comment")]
#[case("#2013-06-05 10:00:02 +test @Commented", "commented entry")]
fn is_comment_found(#[case]input: &str, #[case]msg: &str) {
assert!(Entry::is_comment_line(input), "{msg}");
}
#[rstest]
#[case("", "empty line")]
#[case("2013-06-05 10:00:02 +test @Commented", "entry line")]
fn is_comment_not_found(#[case]input: &str, #[case]msg: &str) {
assert!(!Entry::is_comment_line(input), "{msg}");
}
#[rstest]
#[case("", "empty line")]
#[case("# Random comment", "simple comment")]
#[case("#2013-06-05 10:00:02 +test @Commented", "commented entry")]
fn test_datetime_not_found(#[case]input: &str, #[case]msg: &str) {
assert!(None == Entry::datetime_from_line(input), "{msg}");
}
#[rstest]
#[case(CANONICAL_LINE, "2013-06-05 10:00:02", "entry line")]
#[case(IGNORED_LINE, "2013-06-05 10:00:02", "ignored line")]
#[case(EVENT_LINE, "2013-06-05 10:00:02", "event line")]
#[case(STOP_LINE, "2013-06-05 10:00:02", "stop line")]
fn test_datetime_from_line(#[case]input: &str, #[case]expected: &str, #[case]msg: &str) {
let_assert!(Some(dt) = Entry::datetime_from_line(input));
assert!(dt == expected, "{msg}");
}
#[rstest]
#[case("", "empty line")]
#[case("# Random comment", "simple comment")]
#[case("#2013-06-05 10:00:02 +test @Commented", "entry line")]
fn test_date_not_found(#[case]input: &str, #[case]msg: &str) {
assert!(None == Entry::date_from_line(input), "{msg}");
}
#[rstest]
#[case(CANONICAL_LINE, "2013-06-05", "entry line")]
#[case(IGNORED_LINE, "2013-06-05", "ignored line")]
#[case(EVENT_LINE, "2013-06-05", "event line")]
#[case(STOP_LINE, "2013-06-05", "stop line")]
fn test_date_from_line(#[case]input: &str, #[case]expected: &str, #[case]msg: &str) {
let_assert!(Some(dt) = Entry::date_from_line(input));
assert!(dt == expected, "{msg}");
}
#[test]
fn from_line_error_if_not_entry() {
assert!(Err(EntryError::InvalidTimeStamp) == Entry::from_line("This is not an entry"));
}
#[test]
fn from_line_canonical_entry() {
let_assert!(Ok(entry) = Entry::from_line(CANONICAL_LINE));
assert!(entry.stamp() == String::from("2013-06-05"));
assert!(entry.project() == Some("proj1"));
assert!(entry.entry_text() == "+proj1 @do something");
assert!(entry.task() == Some(String::from("do")));
assert!(entry.detail() == Some(String::from("something")));
assert!(entry.task_and_detail() == (Some(String::from("do")), Some(String::from("something"))));
assert!(entry.epoch() == reference_time());
assert!(entry.to_string().as_str() == CANONICAL_LINE);
assert!(!entry.is_stop());
assert!(!entry.is_ignore());
assert!(!entry.is_event());
}
#[test]
fn new_canonical_entry() {
let_assert!(Ok(canonical_time) = "2013-06-05 10:00:02".parse::<DateTime>());
let entry = Entry::new("+proj1 @do something", canonical_time);
assert!(entry.stamp() == String::from("2013-06-05"));
assert!(entry.project() == Some("proj1"));
assert!(entry.entry_text() == "+proj1 @do something");
assert!(entry.task() == Some(String::from("do")));
assert!(entry.detail() == Some(String::from("something")));
assert!(entry.task_and_detail() == (Some(String::from("do")), Some(String::from("something"))));
assert!(entry.epoch() == reference_time());
assert!(entry.to_string().as_str() == CANONICAL_LINE);
assert!(!entry.is_stop());
assert!(!entry.is_ignore());
assert!(!entry.is_event());
}
#[test]
fn from_line_no_task_entry() {
const LINE: &str = "2013-06-05 10:00:02 +proj1 do something";
let_assert!(Ok(entry) = Entry::from_line(LINE));
assert!(entry.stamp() == String::from("2013-06-05"));
assert!(entry.project() == Some("proj1"));
assert!(entry.entry_text() == "+proj1 do something");
assert!(entry.task() == None);
assert!(entry.detail() == Some(String::from("do something")));
assert!(entry.task_and_detail() == (None, Some(String::from("do something"))));
assert!(entry.epoch() == reference_time());
assert!(entry.to_string().as_str() == LINE);
assert!(!entry.is_stop());
assert!(!entry.is_ignore());
assert!(!entry.is_event());
}
#[test]
fn from_line_no_detail_entry() {
const LINE: &str = "2013-06-05 10:00:02 +proj1 @something";
let_assert!(Ok(entry) = Entry::from_line(LINE));
assert!(entry.stamp() == String::from("2013-06-05"));
assert!(entry.project() == Some("proj1"));
assert!(entry.entry_text() == "+proj1 @something");
assert!(entry.task() == Some(String::from("something")));
assert!(entry.detail() == None);
assert!(entry.task_and_detail() == (Some(String::from("something")), None));
assert!(entry.epoch() == reference_time());
assert!(entry.to_string().as_str() == LINE);
assert!(!entry.is_stop());
assert!(!entry.is_ignore());
assert!(!entry.is_event());
}
#[test]
fn from_line_no_entry_text() {
const LINE: &str = "2013-06-05 10:00:02 +proj1";
let_assert!(Ok(entry) = Entry::from_line(LINE));
assert!(entry.stamp() == String::from("2013-06-05"));
assert!(entry.project() == Some("proj1"));
assert!(entry.entry_text() == "+proj1");
assert!(entry.task() == None);
assert!(entry.detail() == None);
assert!(entry.task_and_detail() == (None, None));
assert!(entry.epoch() == reference_time());
assert!(entry.to_string().as_str() == LINE);
assert!(!entry.is_stop());
assert!(!entry.is_ignore());
assert!(!entry.is_event());
}
#[test]
fn from_line_stop_entry() {
let_assert!(Ok(entry) = Entry::from_line(STOP_LINE));
assert!(entry.stamp() == String::from("2013-06-05"));
assert!(entry.project() == None);
assert!(entry.entry_text() == "stop");
assert!(entry.task() == None);
assert!(entry.detail() == Some(String::from("stop")));
assert!(entry.task_and_detail() == (None, Some(String::from("stop"))));
assert!(entry.epoch() == reference_time());
assert!(entry.to_string().as_str() == STOP_LINE);
assert!(entry.is_stop());
assert!(!entry.is_ignore());
assert!(!entry.is_event());
}
#[test]
fn test_extract_year() {
let line = "2018-11-20 12:34:43 +test @Event";
assert!(Some(2018) == Entry::extract_year(line));
}
#[test]
fn test_extract_year_fail() {
let line = "xyzzy 2018-11-20 12:34:43 +test @Event";
assert!(Entry::extract_year(line) == None);
}
#[test]
fn new_stop_entry() {
let_assert!(Ok(canonical_time) = "2013-06-05 10:00:02".parse::<DateTime>());
let entry = Entry::new("stop", canonical_time);
assert!(entry.stamp() == String::from("2013-06-05"));
assert!(entry.project() == None);
assert!(entry.entry_text() == "stop");
assert!(entry.task() == None);
assert!(entry.detail() == Some(String::from("stop")));
assert!(entry.task_and_detail() == (None, Some(String::from("stop"))));
assert!(entry.epoch() == reference_time());
assert!(entry.to_string().as_str() == STOP_LINE);
assert!(entry.is_stop());
assert!(!entry.is_ignore());
assert!(!entry.is_event());
}
#[test]
fn from_line_ignored_entry() {
let_assert!(Ok(entry) = Entry::from_line(IGNORED_LINE));
assert!(entry.stamp() == String::from("2013-06-05"));
assert!(entry.project() == Some("proj1"));
assert!(entry.entry_text() == "+proj1 @do something");
assert!(entry.task() == Some(String::from("do")));
assert!(entry.detail() == Some(String::from("something")));
assert!(entry.task_and_detail() == (Some(String::from("do")), Some(String::from("something"))));
assert!(entry.epoch() == reference_time());
assert!(entry.to_string().as_str() == IGNORED_LINE);
assert!(!entry.is_stop());
assert!(entry.is_ignore());
assert!(!entry.is_event());
}
#[test]
fn new_ignored_entry() {
let_assert!(Ok(canonical_time) = "2013-06-05 10:00:02".parse::<DateTime>());
let entry = Entry::new_marked("+proj1 @do something", canonical_time, EntryKind::Ignored);
assert!(entry.stamp() == String::from("2013-06-05"));
assert!(entry.project() == Some("proj1"));
assert!(entry.entry_text() == "+proj1 @do something");
assert!(entry.task() == Some(String::from("do")));
assert!(entry.detail() == Some(String::from("something")));
assert!(entry.task_and_detail() == (Some(String::from("do")), Some(String::from("something"))));
assert!(entry.epoch() == reference_time());
assert!(entry.to_string().as_str() == IGNORED_LINE);
assert!(!entry.is_stop());
assert!(entry.is_ignore());
assert!(!entry.is_event());
}
#[test]
fn from_line_event_entry() {
let_assert!(Ok(entry) = Entry::from_line(EVENT_LINE));
assert!(entry.stamp() == String::from("2013-06-05"));
assert!(entry.project() == Some("proj1"));
assert!(entry.entry_text() == "+proj1 @do something");
assert!(entry.task() == Some(String::from("do")));
assert!(entry.detail() == Some(String::from("something")));
assert!(entry.task_and_detail() == (Some(String::from("do")), Some(String::from("something"))));
assert!(entry.epoch() == reference_time());
assert!(entry.to_string().as_str() == EVENT_LINE);
assert!(!entry.is_stop());
assert!(!entry.is_ignore());
assert!(entry.is_event());
}
#[test]
fn new_event_entry() {
let_assert!(Ok(canonical_time) = "2013-06-05 10:00:02".parse::<DateTime>());
let entry = Entry::new_marked("+proj1 @do something", canonical_time, EntryKind::Event);
assert!(entry.stamp() == String::from("2013-06-05"));
assert!(entry.project() == Some("proj1"));
assert!(entry.entry_text() == "+proj1 @do something");
assert!(entry.task() == Some(String::from("do")));
assert!(entry.detail() == Some(String::from("something")));
assert!(entry.task_and_detail() == (Some(String::from("do")), Some(String::from("something"))));
assert!(entry.epoch() == reference_time());
assert!(entry.to_string().as_str() == EVENT_LINE);
assert!(!entry.is_stop());
assert!(!entry.is_ignore());
assert!(entry.is_event());
}
#[test]
fn compare_entry() {
let_assert!(Ok(entry1) = Entry::from_line("2013-06-05 10:00:02 +proj1"));
let_assert!(Ok(entry2) = Entry::from_line("2013-06-05 11:00:02 +proj1"));
assert!(entry2 > entry1);
assert!(entry1 < entry2);
assert!(entry1 == entry1);
}
#[test]
fn test_change_date_time_start() {
let_assert!(Ok(entry) = Entry::from_line("2022-12-27 10:00:00 +proj1"));
let_assert!(Ok(dt) = DateTime::new((2022, 12, 27), (9, 50, 00)));
let new_entry = entry.change_date_time(dt);
assert!(new_entry != entry);
let_assert!(Ok(dt) = DateTime::new((2022, 12, 27), (9, 50, 00)));
assert!(new_entry.date_time() == dt);
assert!(new_entry.entry_text() == entry.entry_text());
assert!(new_entry.is_start());
}
#[test]
fn test_change_date_time_event() {
let_assert!(Ok(entry) = Entry::from_line("2022-12-27 10:00:00^+event"));
let_assert!(Ok(dt) = DateTime::new((2022, 12, 27), (9, 50, 00)));
let new_entry = entry.change_date_time(dt);
assert!(new_entry != entry);
let_assert!(Ok(dt) = DateTime::new((2022, 12, 27), (9, 50, 00)));
assert!(new_entry.date_time() == dt);
assert!(new_entry.entry_text() == entry.entry_text());
assert!(new_entry.is_event());
}
#[test]
fn test_change_date_time_ignored() {
let_assert!(Ok(entry) = Entry::from_line("2022-12-27 10:00:00!+proj1"));
let_assert!(Ok(dt) = DateTime::new((2022, 12, 27), (9, 50, 00)));
let new_entry = entry.change_date_time(dt);
assert!(new_entry != entry);
let_assert!(Ok(dt) = DateTime::new((2022, 12, 27), (9, 50, 00)));
assert!(new_entry.date_time() == dt);
assert!(new_entry.entry_text() == entry.entry_text());
assert!(new_entry.is_ignore());
}
#[test]
fn test_change_date_time_stop() {
let_assert!(Ok(entry) = Entry::from_line("2022-12-27 10:00:00 stop"));
let_assert!(Ok(dt) = DateTime::new((2022, 12, 27), (9, 50, 00)));
let new_entry = entry.change_date_time(dt);
assert!(new_entry != entry);
let_assert!(Ok(dt) = DateTime::new((2022, 12, 27), (9, 50, 00)));
assert!(new_entry.date_time() == dt);
assert!(new_entry.entry_text() == entry.entry_text());
assert!(new_entry.is_stop());
}
#[test]
fn test_change_text_start() {
let_assert!(Ok(entry) = Entry::from_line("2022-12-27 10:00:00 +proj1"));
let new_entry = entry.change_text("+proj2 @Changed");
assert!(new_entry != entry);
assert!(new_entry.date_time() == entry.date_time());
assert!(new_entry.entry_text() == "+proj2 @Changed");
assert!(new_entry.is_start());
}
#[test]
fn test_change_text_event() {
let_assert!(Ok(entry) = Entry::from_line("2022-12-27 10:00:00^+event"));
let new_entry = entry.change_text("+proj2 @Changed");
assert!(new_entry != entry);
assert!(new_entry.date_time() == entry.date_time());
assert!(new_entry.entry_text() == "+proj2 @Changed");
assert!(new_entry.is_event());
}
#[test]
fn test_change_text_ignored() {
let_assert!(Ok(entry) = Entry::from_line("2022-12-27 10:00:00!+proj1"));
let new_entry = entry.change_text("+proj2 @Changed");
assert!(new_entry != entry);
assert!(new_entry.date_time() == entry.date_time());
assert!(new_entry.entry_text() == "+proj2 @Changed");
assert!(new_entry.is_ignore());
}
#[test]
fn test_change_text_stop() {
let_assert!(Ok(entry) = Entry::from_line("2022-12-27 10:00:00 stop"));
let new_entry = entry.change_text("+proj2 @Changed");
assert!(new_entry == entry);
assert!(new_entry.date_time() == entry.date_time());
assert!(new_entry.entry_text() == "stop");
assert!(new_entry.is_stop());
}
}