use std::fmt::{self, Display};
use std::fs::File;
use std::io;
use std::io::prelude::*;
use std::num::NonZeroU32;
use std::path::Path;
use std::result;
use std::time::Duration;
use crate::buf_reader;
#[doc(inline)]
use crate::date::{DateTime, Time};
#[doc(inline)]
use crate::entry::{Entry, EntryError, EntryKind};
#[doc(inline)]
use crate::error::Error;
#[doc(inline)]
use crate::error::PathError;
#[doc(inline)]
use crate::file;
const TWELVE_HOURS: u64 = 12 * 3600;
#[derive(Debug, Eq, PartialEq)]
pub enum Problem {
FileAccess,
BlankLine(usize),
InvalidTimeStamp(usize),
MissingTask(usize),
InvalidMarker(usize),
EventsOrder(usize),
EventLength(usize)
}
impl Display for Problem {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
let (msg, lineno) = match self {
Self::FileAccess => return write!(f, "Error: Unable to open file"),
Self::BlankLine(n) => ("Error: Blank entry line", n),
Self::InvalidTimeStamp(n) => ("Error: Time stamp is invalid or missing", n),
Self::MissingTask(n) => ("Error: Task missing from entry line", n),
Self::InvalidMarker(n) => ("Error: Unrecognized marker character", n),
Self::EventsOrder(n) => ("Error: Entries out of order", n),
Self::EventLength(n) => ("Warn: Very long interval, possibly missing stop", n)
};
write!(f, "Line {lineno}: {msg}")
}
}
impl Problem {
fn from_error(err: EntryError, lineno: usize) -> Self {
match err {
EntryError::BlankLine => Self::BlankLine(lineno),
EntryError::InvalidTimeStamp => Self::InvalidTimeStamp(lineno),
EntryError::MissingTask => Self::MissingTask(lineno),
EntryError::InvalidMarker => Self::InvalidMarker(lineno)
}
}
}
#[derive(Debug)]
pub struct Logfile(String);
impl Logfile {
pub fn new(file: &str) -> result::Result<Self, PathError> {
file::canonical_filename(file, file::FileKind::LogFile).map(Self)
}
pub fn open(&self) -> result::Result<File, PathError> {
File::open(&self.0).map_err(|e| PathError::FileAccess(self.0.clone(), e.to_string()))
}
pub fn clone_file(&self) -> String { self.0.clone() }
pub fn exists(&self) -> bool { Path::new(&self.0).exists() }
pub fn add_line(&self, entry: &str) -> result::Result<(), PathError> {
let file = file::append_open(&self.0)?;
let mut stream = io::BufWriter::new(file);
writeln!(&mut stream, "{entry}")
.map_err(|e| PathError::FileWrite(self.clone_file(), e.to_string()))?;
stream
.flush()
.map_err(|e| PathError::FileWrite(self.clone_file(), e.to_string()))?;
Ok(())
}
pub fn add_task(&self, task: &str) -> result::Result<(), PathError> {
self.add_entry(&Entry::new(task, DateTime::now()))
}
pub fn add_entry(&self, entry: &Entry) -> result::Result<(), PathError> {
let line = format!("{entry}");
self.add_line(&line)
}
pub fn add_comment(&self, comment: &str) -> result::Result<(), PathError> {
let file = file::append_open(&self.0)?;
let mut stream = io::BufWriter::new(file);
writeln!(&mut stream, "# {comment}")
.map_err(|e| PathError::FileWrite(self.clone_file(), e.to_string()))?;
stream
.flush()
.map_err(|e| PathError::FileWrite(self.clone_file(), e.to_string()))?;
Ok(())
}
pub fn add_event(&self, line: &str) -> result::Result<(), PathError> {
self.add_entry(&Entry::new_marked(line, DateTime::now(), EntryKind::Event))
}
pub fn discard_line(&self) -> result::Result<(), PathError> {
let mut file = file::rw_open(&self.0)?;
file::pop_last_line(&mut file);
Ok(())
}
fn change_last_entry<F>(&self, func: F) -> result::Result<(), Error>
where
F: FnOnce(Entry) -> result::Result<Entry, Error>
{
let mut file = file::rw_open(&self.0)?;
if let Some(line) = file::pop_last_line(&mut file) {
let old_entry = Entry::from_line(&line)?;
if old_entry.is_stop() { return Err(Error::InvalidStopEdit); }
if old_entry.is_ignore() { return Err(Error::InvalidIgnoreEdit); }
match func(old_entry) {
Ok(entry) => self.add_entry(&entry)?,
Err(e) => {
self.add_line(&line)?;
return Err(e);
}
}
}
Ok(())
}
pub fn reset_last_entry(&self) -> result::Result<(), Error> {
self.change_last_entry(|entry| Ok(entry.change_date_time(DateTime::now())))
}
pub fn ignore_last_entry(&self) -> result::Result<(), Error> {
self.change_last_entry(|entry| Ok(entry.ignore()))
}
pub fn rewrite_last_entry(&self, task: &str) -> result::Result<(), Error> {
self.change_last_entry(|entry| Ok(entry.change_text(task)))
}
pub fn retime_last_entry(&self, time: Time) -> result::Result<(), Error> {
self.change_last_entry(|entry| {
let dt = DateTime::new_from_date_time(entry.date(), time);
Ok(entry.change_date_time(dt))
})
}
pub fn rewind_last_entry(&self, minutes: NonZeroU32) -> result::Result<(), Error> {
let dur = Duration::from_secs(u64::from(minutes.get()) * 60);
self.change_last_entry(|entry| {
let dt = (entry.date_time() - dur)?;
Ok(entry.change_date_time(dt))
})
}
pub fn raw_last_line(&self) -> Option<String> {
if self.exists() {
let file = File::open(&self.0).ok()?;
io::BufReader::new(file).lines().map_while(result::Result::ok).last()
}
else {
None
}
}
pub fn last_line(&self) -> Option<String> {
if self.exists() {
let file = File::open(&self.0).ok()?;
io::BufReader::new(file)
.lines()
.map_while(Result::ok)
.filter(|ln| !ln.starts_with('#') && EntryKind::from_entry_line(ln).is_start())
.last()
}
else {
None
}
}
pub fn last_entry(&self) -> result::Result<Entry, Error> {
Entry::from_line(&self.last_line().unwrap_or_default()).map_err(Into::into)
}
pub fn problems(&self) -> Vec<Problem> {
if !self.exists() { return Vec::new(); }
let Ok(file) = self.open() else {
return vec![Problem::FileAccess];
};
let mut problems: Vec<Problem> = Vec::new();
let mut iter = buf_reader(file);
let Some(line) = iter.next() else { return problems; };
let mut prev = match Entry::from_line(&line) {
Ok(ev) => ev,
Err(e) => {
problems.push(Problem::from_error(e, 1));
return problems;
}
};
let twelve_hour_dur = Duration::from_secs(TWELVE_HOURS);
for (line, lineno) in iter.zip(2..) {
match Entry::from_line(&line) {
Ok(ev) => {
if prev.date_time() > ev.date_time() {
problems.push(Problem::EventsOrder(lineno));
}
else if !prev.is_stop() && !prev.is_event() {
let diff = (ev.date_time() - prev.date_time()).unwrap_or_default();
if diff > twelve_hour_dur {
problems.push(Problem::EventLength(lineno));
}
}
prev = ev;
}
Err(e) => problems.push(Problem::from_error(e, lineno))
}
}
problems
}
}
#[cfg(test)]
mod tests {
use std::fs::{canonicalize, OpenOptions};
use assert2::{assert, let_assert};
use nzliteral::nzliteral;
use regex::Regex;
use rstest::rstest;
use tempfile::TempDir;
use super::*;
use crate::date::Date;
fn make_timelog(lines: &[String]) -> (TempDir, String) {
let_assert!(Ok(tmpdir) = TempDir::new());
let mut path = tmpdir.path().to_path_buf();
path.push("timelog.txt");
let_assert!(Some(filename) = path.to_str());
let_assert!(Ok(file) = OpenOptions::new()
.create(true)
.append(true)
.open(filename));
let mut stream = io::BufWriter::new(file);
lines
.iter()
.for_each(|line| writeln!(&mut stream, "{line}").expect("Hardcoded value"));
let_assert!(Ok(_) = stream.flush());
(tmpdir, filename.to_string())
}
fn touch_timelog() -> (TempDir, String) { make_timelog(&[String::new()]) }
#[test]
fn test_new() {
let_assert!(Ok(logfile) = Logfile::new("./foo.txt"));
let expected = canonicalize(".")
.map(|mut pb| {
pb.push("foo.txt");
pb.to_str().expect("Hardcoded value").to_string()
})
.unwrap_or("".to_string());
assert!(logfile.clone_file() == expected);
}
#[test]
fn test_new_empty_name() {
let_assert!(Err(err) = Logfile::new(""));
assert!(err == PathError::FilenameMissing);
}
#[test]
fn test_new_bad_path() {
let_assert!(Err(err) = Logfile::new("./xyzzy/foo.txt"));
assert!(err == PathError::InvalidPath(
"./xyzzy/foo.txt".to_string(),
"No such file or directory (os error 2)".to_string()
));
}
#[test]
fn test_exists_false() {
let_assert!(Ok(logfile) = Logfile::new("./foo.txt"));
assert!(!logfile.exists());
}
#[test]
fn test_exists_true() {
let (_tmpdir, filename) = touch_timelog();
let_assert!(Ok(logfile) = Logfile::new(&filename));
assert!(logfile.exists());
}
#[test]
fn test_add_line() {
let (_tmpdir, filename) = touch_timelog();
let_assert!(Ok(logfile) = Logfile::new(&filename));
let_assert!(Ok(_) = logfile.add_line("2021-11-18 18:00:00 +project @task"));
let_assert!(Some(line) = logfile.last_line());
assert!(line == String::from("2021-11-18 18:00:00 +project @task"));
}
#[test]
fn test_add_task() {
let (_tmpdir, filename) = touch_timelog();
let_assert!(Ok(logfile) = Logfile::new(&filename));
let_assert!(Ok(_) = logfile.add_task("+project @task"));
let_assert!(Some(line) = logfile.last_line());
assert!(line.ends_with("+project @task"));
}
#[test]
fn test_add_entry() {
let (_tmpdir, filename) = touch_timelog();
let_assert!(Ok(logfile) = Logfile::new(&filename));
let_assert!(Ok(datetime) = "2021-11-18 18:00:00".parse::<DateTime>());
let entry = Entry::new("+project @task", datetime);
let_assert!(Ok(_) = logfile.add_entry(&entry));
let_assert!(Some(line) = logfile.last_line());
assert!(line == String::from("2021-11-18 18:00:00 +project @task"));
}
#[test]
fn test_add_comment() {
let (_tmpdir, filename) = touch_timelog();
let_assert!(Ok(logfile) = Logfile::new(&filename));
let_assert!(Ok(_) = logfile.add_comment("This is a test"));
let_assert!(Some(line) = logfile.raw_last_line());
assert!(line == String::from("# This is a test"));
}
#[test]
fn test_add_event() {
let (_tmpdir, filename) = touch_timelog();
let_assert!(Ok(logfile) = Logfile::new(&filename));
#[rustfmt::skip]
let_assert!(Ok(expect) = Regex::new(r"\A\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\^something happened"));
let_assert!(Ok(_) = logfile.add_event("something happened"));
let_assert!(Some(last_line) = logfile.raw_last_line());
assert!(expect.is_match(&last_line));
}
#[test]
fn test_discard_line() {
let (_tmpdir, filename) = make_timelog(&[
"2021-11-18 17:01:01 +foo".to_string(),
"2021-11-18 17:04:02 +bar".to_string(),
"2021-11-18 17:08:04 +baz".to_string(),
]);
let_assert!(Ok(logfile) = Logfile::new(&filename));
assert!(logfile.discard_line().is_ok());
let_assert!(Some(line) = logfile.last_line());
assert!(line == "2021-11-18 17:04:02 +bar".to_string());
}
#[test]
fn test_reset_last_entry() {
let (_tmpdir, filename) = make_timelog(&[
"2021-11-18 17:01:01 +foo".to_string(),
"2021-11-18 17:04:02 +bar".to_string(),
"2021-11-18 17:08:04 +baz".to_string(),
]);
let_assert!(Ok(logfile) = Logfile::new(&filename));
assert!(logfile.reset_last_entry().is_ok());
let_assert!(Ok(entry) = logfile.last_entry());
assert!(entry.entry_text() == "+baz");
assert!(entry.date() == Date::today());
}
#[test]
fn test_ignore_last_entry() {
let (_tmpdir, filename) = make_timelog(&vec![
"2021-11-18 17:01:01 +foo".to_string(),
"2021-11-18 17:04:02 +bar".to_string(),
"2021-11-18 17:08:04 +baz".to_string(),
]);
let_assert!(Ok(expect) = Entry::from_line("2021-11-18 17:04:02 +bar"));
let logfile = Logfile::new(&filename).expect("Hardcoded value");
let_assert!(Ok(_) = logfile.ignore_last_entry());
let_assert!(Ok(last) = logfile.last_entry());
assert!(last == expect);
}
#[test]
fn test_rewrite_last_entry() {
let (_tmpdir, filename) = make_timelog(&[
"2021-11-18 17:01:01 +foo".to_string(),
"2021-11-18 17:04:02 +bar".to_string(),
"2021-11-18 17:08:04 +baz".to_string(),
]);
let_assert!(Ok(expect) = Entry::from_line("2021-11-18 17:08:04 +foobar @Frond"));
let_assert!(Ok(logfile) = Logfile::new(&filename));
assert!(logfile.rewrite_last_entry("+foobar @Frond").is_ok());
let_assert!(Ok(last) = logfile.last_entry());
assert!(last == expect);
}
#[test]
fn test_retime_last_entry() {
let (_tmpdir, filename) = make_timelog(&[
"2021-11-18 17:01:01 +foo".to_string(),
"2021-11-18 17:04:02 +bar".to_string(),
"2021-11-18 17:08:04 +baz".to_string(),
]);
let_assert!(Ok(expect) = Entry::from_line("2021-11-18 16:01:00 +baz"));
let_assert!(Ok(logfile) = Logfile::new(&filename));
let_assert!(Some(time) = Time::from_hms_opt(16, 1, 0));
assert!(logfile.retime_last_entry(time).is_ok());
let_assert!(Ok(last) = logfile.last_entry());
assert!(last == expect);
}
#[test]
fn test_rewind_last_entry() {
let (_tmpdir, filename) = make_timelog(&[
"2021-11-18 17:01:01 +foo".to_string(),
"2021-11-18 17:04:02 +bar".to_string(),
"2021-11-18 17:08:04 +baz".to_string(),
]);
let_assert!(Ok(expect) = Entry::from_line("2021-11-18 16:57:04 +baz"));
let_assert!(Ok(logfile) = Logfile::new(&filename));
let minutes = nzliteral!(11);
assert!(logfile.rewind_last_entry(minutes).is_ok());
let_assert!(Ok(last) = logfile.last_entry());
assert!(last == expect);
}
#[test]
fn test_last_line_missing() {
let (_tmpdir, filename) = touch_timelog();
let_assert!(Ok(logfile) = Logfile::new(&filename));
let_assert!(Some(last) = logfile.last_line());
assert!(last == String::new());
}
#[test]
fn test_last_line_empty() {
let (_tmpdir, filename) = make_timelog(&[]);
let_assert!(Ok(logfile) = Logfile::new(&filename));
assert!(logfile.last_line().is_none());
}
#[test]
fn test_last_line_lines() {
let (_tmpdir, filename) = make_timelog(&[
"2021-11-18 17:01:01 +foo".to_string(),
"2021-11-18 17:04:02 +bar".to_string(),
"2021-11-18 17:08:04 +baz".to_string(),
]);
let_assert!(Ok(logfile) = Logfile::new(&filename));
let_assert!(Some(last) = logfile.last_line());
assert!(last == "2021-11-18 17:08:04 +baz".to_string());
}
#[test]
fn test_last_entry() {
let (_tmpdir, filename) = make_timelog(&[]);
let_assert!(Ok(logfile) = Logfile::new(&filename));
let_assert!(Err(err) = logfile.last_entry());
assert!(err == Error::from(EntryError::BlankLine));
}
#[test]
fn test_last_entry_lines() {
let (_tmpdir, filename) = make_timelog(&[
"2021-11-18 17:01:01 +foo".to_string(),
"2021-11-18 17:04:02 +bar".to_string(),
"2021-11-18 17:08:04 +baz".to_string(),
]);
let_assert!(Ok(logfile) = Logfile::new(&filename));
let_assert!(Ok(expected) = Entry::from_line("2021-11-18 17:08:04 +baz"));
let_assert!(Ok(last) = logfile.last_entry());
assert!(last == expected);
}
#[test]
fn test_problems_all_good() {
let (_tmpdir, filename) = make_timelog(&[
"2021-11-18 17:01:01 +foo".to_string(),
"2021-11-18 17:04:02 +bar".to_string(),
"2021-11-18 17:08:04 +baz".to_string(),
"2021-11-18 17:08:04 stop".to_string(),
]);
let_assert!(Ok(logfile) = Logfile::new(&filename));
assert!(logfile.problems().is_empty());
}
#[test]
fn test_problems_all_good_with_comments() {
let (_tmpdir, filename) = make_timelog(&[
"# Start of file".to_string(),
"2021-11-18 17:01:01 +foo".to_string(),
"2021-11-18 17:04:02 +bar".to_string(),
"# Middle of file".to_string(),
"2021-11-18 17:08:04 +baz".to_string(),
"2021-11-18 17:08:04 stop".to_string(),
"# End of file".to_string(),
]);
let_assert!(Ok(logfile) = Logfile::new(&filename));
assert!(logfile.problems().is_empty());
}
#[test]
fn test_problems_blank_line() {
let (_tmpdir, filename) = make_timelog(&[
"2021-11-18 17:01:01 +foo".to_string(),
"".to_string(),
"2021-11-18 17:04:02 +bar".to_string(),
"2021-11-18 17:08:04 +baz".to_string(),
]);
let_assert!(Ok(logfile) = Logfile::new(&filename));
assert!(logfile.problems() == vec![Problem::BlankLine(2)]);
}
#[test]
fn test_problems_bad_date() {
let (_tmpdir, filename) = make_timelog(&[
"2021-1-18 17:01:01 +foo".to_string(),
"2021-11-18 17:04:02 +bar".to_string(),
"2021-11-18 17:08:04 +baz".to_string(),
"2021-11-18 17:08:04 stop".to_string(),
]);
let_assert!(Ok(logfile) = Logfile::new(&filename));
assert!(logfile.problems() == vec![Problem::InvalidTimeStamp(1)]);
}
#[test]
fn test_problems_bad_time() {
let (_tmpdir, filename) = make_timelog(&[
"2021-11-18 7:01:01 +foo".to_string(),
"2021-11-18 17:04:02 +bar".to_string(),
"2021-11-18 17:08:04 +baz".to_string(),
"2021-11-18 17:08:04 stop".to_string(),
]);
let_assert!(Ok(logfile) = Logfile::new(&filename));
assert!(logfile.problems() == vec![Problem::InvalidTimeStamp(1)]);
}
#[test]
fn test_problems_missing_timestamp() {
let (_tmpdir, filename) = make_timelog(&[
"+foo".to_string(),
"2021-11-18 17:04:02 +bar".to_string(),
"2021-11-18 17:08:04 +baz".to_string(),
"2021-11-18 17:08:04 stop".to_string(),
]);
let_assert!(Ok(logfile) = Logfile::new(&filename));
assert!(logfile.problems() == vec![Problem::InvalidTimeStamp(1)]);
}
#[test]
fn test_problems_no_task() {
let (_tmpdir, filename) = make_timelog(&[
"2021-11-18 17:01:01 +foo".to_string(),
"2021-11-18 17:04:02 +bar".to_string(),
"2021-11-18 17:08:04 ".to_string(),
"2021-11-18 17:08:04 stop".to_string(),
]);
let_assert!(Ok(logfile) = Logfile::new(&filename));
assert!(logfile.problems() == vec![Problem::MissingTask(3)]);
}
#[test]
fn test_problems_unknown_marker() {
let (_tmpdir, filename) = make_timelog(&[
"2021-11-18 17:01:01 +foo".to_string(),
"2021-11-18 17:04:02*+bar".to_string(),
"2021-11-18 17:08:04 +baz".to_string(),
"2021-11-18 17:08:04 stop".to_string(),
]);
let_assert!(Ok(logfile) = Logfile::new(&filename));
assert!(logfile.problems() == vec![Problem::InvalidMarker(2)]);
}
#[test]
fn test_problems_entry_unordered() {
let (_tmpdir, filename) = make_timelog(&[
"2021-11-18 17:01:01 +foo".to_string(),
"2021-11-18 17:08:04 +baz".to_string(),
"2021-11-18 17:04:02 +bar".to_string(),
"2021-11-18 17:08:04 stop".to_string(),
]);
let_assert!(Ok(logfile) = Logfile::new(&filename));
assert!(logfile.problems() == vec![Problem::EventsOrder(3)]);
}
#[rstest]
#[case(Problem::BlankLine(2), "Line 2: Error: Blank entry line")]
#[case(Problem::InvalidTimeStamp(3), "Line 3: Error: Time stamp is invalid or missing")]
#[case(Problem::MissingTask(5), "Line 5: Error: Task missing from entry line")]
#[case(Problem::InvalidMarker(5), "Line 5: Error: Unrecognized marker character")]
#[case(Problem::EventsOrder(9), "Line 9: Error: Entries out of order")]
#[case(Problem::EventLength(13), "Line 13: Warn: Very long interval, possibly missing stop")]
fn test_problem_fmt(#[case]prob: Problem, #[case]display: &str) {
assert!(prob.to_string() == String::from(display));
}
#[test]
fn test_problems_open_ended() {
let (_tmpdir, filename) = make_timelog(&[
"2021-11-18 17:01:01 +foo".to_string(),
"2021-11-18 17:04:02 +bar".to_string(),
"2021-11-19 17:08:04 +baz".to_string(),
"2021-11-19 17:08:04 stop".to_string(),
]);
let_assert!(Ok(logfile) = Logfile::new(&filename));
assert!(logfile.problems() == vec![Problem::EventLength(3)]);
}
}