timelog/
logfile.rs

1//! Interface to the timelog file for the timelog application.
2//!
3//! # Examples
4//!
5//! ```rust
6//! use timelog::logfile::Logfile;
7//! # fn main() -> Result<(), timelog::Error> {
8//! let timelog = Logfile::new("./timelog.txt" )?;
9//!
10//! let task = timelog.last_line();
11//! println!("{:?}", task);
12//!
13//! timelog.add_task("+Project @Task More detail");
14//! #   std::fs::remove_file(timelog.clone_file()).expect("Oops");
15//! #   Ok(())
16//! # }
17//! ```
18
19use std::fmt::{self, Display};
20use std::fs::File;
21use std::io;
22use std::io::prelude::*;
23use std::num::NonZeroU32;
24use std::path::Path;
25use std::result;
26use std::time::Duration;
27
28
29use crate::buf_reader;
30#[doc(inline)]
31use crate::date::{DateTime, Time};
32#[doc(inline)]
33use crate::entry::{Entry, EntryError, EntryKind};
34#[doc(inline)]
35use crate::error::Error;
36#[doc(inline)]
37use crate::error::PathError;
38#[doc(inline)]
39use crate::file;
40
41const TWELVE_HOURS: u64 = 12 * 3600;
42
43/// Problems that can be found in a logfile
44///
45/// Problems contain the line number where the problem was discovered, except FileAccess.
46#[derive(Debug, Eq, PartialEq)]
47pub enum Problem {
48    FileAccess,
49    BlankLine(usize),
50    InvalidTimeStamp(usize),
51    MissingTask(usize),
52    InvalidMarker(usize),
53    EventsOrder(usize),
54    EventLength(usize)
55}
56
57impl Display for Problem {
58    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
59        let (msg, lineno) = match self {
60            Self::FileAccess => return write!(f, "Error: Unable to open file"),
61            Self::BlankLine(n) => ("Error: Blank entry line", n),
62            Self::InvalidTimeStamp(n) => ("Error: Time stamp is invalid or missing", n),
63            Self::MissingTask(n) => ("Error: Task missing from entry line", n),
64            Self::InvalidMarker(n) => ("Error: Unrecognized marker character", n),
65            Self::EventsOrder(n) => ("Error: Entries out of order", n),
66            Self::EventLength(n) => ("Warn: Very long interval, possibly missing stop", n)
67        };
68        write!(f, "Line {lineno}: {msg}")
69    }
70}
71
72impl Problem {
73    fn from_error(err: EntryError, lineno: usize) -> Self {
74        match err {
75            EntryError::BlankLine => Self::BlankLine(lineno),
76            EntryError::InvalidTimeStamp => Self::InvalidTimeStamp(lineno),
77            EntryError::MissingTask => Self::MissingTask(lineno),
78            EntryError::InvalidMarker => Self::InvalidMarker(lineno)
79        }
80    }
81}
82
83/// A [`Logfile`] type that wraps the timelog log file.
84#[derive(Debug)]
85pub struct Logfile(String);
86
87impl Logfile {
88    /// Creates a [`Logfile`] object wrapping the supplied file.
89    ///
90    /// # Errors
91    ///
92    /// - Return [`PathError::FilenameMissing`] if the `file` has no filename.
93    /// - Return [`PathError::InvalidPath`] if the path part of `file` is not a valid path.
94    /// - Return [`PathError::InvalidTimelogPath`] if stack path is invalid.
95    pub fn new(file: &str) -> result::Result<Self, PathError> {
96        file::canonical_filename(file, file::FileKind::LogFile).map(Self)
97    }
98
99    /// Open the log file, return a [`File`].
100    ///
101    /// # Errors
102    ///
103    /// - Return [`PathError::FileAccess`] if unable to open the file.
104    pub fn open(&self) -> result::Result<File, PathError> {
105        File::open(&self.0).map_err(|e| PathError::FileAccess(self.0.clone(), e.to_string()))
106    }
107
108    /// Clone the filename
109    pub fn clone_file(&self) -> String { self.0.clone() }
110
111    /// Return `true` if the timelog file exists
112    pub fn exists(&self) -> bool { Path::new(&self.0).exists() }
113
114    /// Append the supplied line (including time stamp) to the timelog file
115    ///
116    /// # Errors
117    ///
118    /// - Return [`PathError::FileAccess`] if the file cannot be opened or created.
119    /// - Return [`PathError::FileWrite`] if the function fails to append to the file.
120    pub fn add_line(&self, entry: &str) -> result::Result<(), PathError> {
121        let file = file::append_open(&self.0)?;
122        let mut stream = io::BufWriter::new(file);
123        writeln!(&mut stream, "{entry}")
124            .map_err(|e| PathError::FileWrite(self.clone_file(), e.to_string()))?;
125        stream
126            .flush()
127            .map_err(|e| PathError::FileWrite(self.clone_file(), e.to_string()))?;
128        Ok(())
129    }
130
131    /// Append the supplied task to the timelog file
132    ///
133    /// # Errors
134    ///
135    /// - Return [`PathError::FileAccess`] if the file cannot be opened or created.
136    /// - Return [`PathError::FileWrite`] if the function fails to append to the file.
137    pub fn add_task(&self, task: &str) -> result::Result<(), PathError> {
138        self.add_entry(&Entry::new(task, DateTime::now()))
139    }
140
141    /// Append the supplied [`Entry`] to the timelog file
142    ///
143    /// # Errors
144    ///
145    /// - Return [`PathError::FileAccess`] if the file cannot be opened or created.
146    /// - Return [`PathError::FileWrite`] if the function fails to append to the file.
147    pub fn add_entry(&self, entry: &Entry) -> result::Result<(), PathError> {
148        let line = format!("{entry}");
149        self.add_line(&line)
150    }
151
152    /// Add a comment line to the timelog file
153    ///
154    /// # Errors
155    ///
156    /// - Return [`PathError::FileAccess`] if the file cannot be opened or created.
157    /// - Return [`PathError::FileWrite`] if the function fails to append to the file.
158    pub fn add_comment(&self, comment: &str) -> result::Result<(), PathError> {
159        let file = file::append_open(&self.0)?;
160        let mut stream = io::BufWriter::new(file);
161        writeln!(&mut stream, "# {comment}")
162            .map_err(|e| PathError::FileWrite(self.clone_file(), e.to_string()))?;
163        stream
164            .flush()
165            .map_err(|e| PathError::FileWrite(self.clone_file(), e.to_string()))?;
166        Ok(())
167    }
168
169    /// Add a zero duration event to the timelog file
170    ///
171    /// # Errors
172    ///
173    /// - Return [`PathError::FileAccess`] if the file cannot be opened or created.
174    /// - Return [`PathError::FileWrite`] if the function fails to append to the file.
175    pub fn add_event(&self, line: &str) -> result::Result<(), PathError> {
176        self.add_entry(&Entry::new_marked(line, DateTime::now(), EntryKind::Event))
177    }
178
179    /// Remove the most recent task from the logfile.
180    ///
181    /// # Errors
182    ///
183    /// - Return [`PathError::FileAccess`] if the file cannot be opened or created.
184    /// - Return [`PathError::FileWrite`] if the function fails to append to the file.
185    pub fn discard_line(&self) -> result::Result<(), PathError> {
186        let mut file = file::rw_open(&self.0)?;
187        file::pop_last_line(&mut file);
188        Ok(())
189    }
190
191    // Wrapper method that extracts the latest entry, calls a function on it
192    // to produce a new entry and adds that one to replace the original.
193    ///
194    /// # Errors
195    ///
196    /// - Return [`PathError::FileAccess`] if the file cannot be opened or created.
197    /// - Return [`PathError::FileWrite`] if the function fails to append to the file.
198    fn change_last_entry<F>(&self, func: F) -> result::Result<(), Error>
199    where
200        F: FnOnce(Entry) -> result::Result<Entry, Error>
201    {
202        let mut file = file::rw_open(&self.0)?;
203        if let Some(line) = file::pop_last_line(&mut file) {
204            let old_entry = Entry::from_line(&line)?;
205            if old_entry.is_stop() { return Err(Error::InvalidStopEdit); }
206            if old_entry.is_ignore() { return Err(Error::InvalidIgnoreEdit); }
207            match func(old_entry) {
208                Ok(entry) => self.add_entry(&entry)?,
209                Err(e) => {
210                    self.add_line(&line)?;
211                    return Err(e);
212                }
213            }
214        }
215        Ok(())
216    }
217
218    /// Reset most recent entry to current date time.
219    ///
220    /// # Errors
221    ///
222    /// - Return [`PathError::FileAccess`] if the file cannot be opened or created.
223    /// - Return [`PathError::FileWrite`] if the function fails to append to the file.
224    pub fn reset_last_entry(&self) -> result::Result<(), Error> {
225        self.change_last_entry(|entry| Ok(entry.change_date_time(DateTime::now())))
226    }
227
228    /// Ignore the most recent entry to current date time.
229    ///
230    /// # Errors
231    ///
232    /// - Return [`PathError::FileAccess`] if the file cannot be opened or created.
233    /// - Return [`PathError::FileWrite`] if the function fails to append to the file.
234    pub fn ignore_last_entry(&self) -> result::Result<(), Error> {
235        self.change_last_entry(|entry| Ok(entry.ignore()))
236    }
237
238    /// Rewrite the text of the most recent entry.
239    ///
240    /// # Errors
241    ///
242    /// - Return [`PathError::FileAccess`] if the file cannot be opened or created.
243    /// - Return [`PathError::FileWrite`] if the function fails to append to the file.
244    pub fn rewrite_last_entry(&self, task: &str) -> result::Result<(), Error> {
245        self.change_last_entry(|entry| Ok(entry.change_text(task)))
246    }
247
248    /// Reset the time of the most recent entry.
249    ///
250    /// # Errors
251    ///
252    /// - Return [`PathError::FileAccess`] if the file cannot be opened or created.
253    /// - Return [`PathError::FileWrite`] if the function fails to append to the file.
254    /// - Return [`Error::InvalidWasArgument`] if supplied time is not parsable.
255    pub fn retime_last_entry(&self, time: Time) -> result::Result<(), Error> {
256        self.change_last_entry(|entry| {
257            let dt = DateTime::new_from_date_time(entry.date(), time);
258            Ok(entry.change_date_time(dt))
259        })
260    }
261
262    /// Shift the time of the most recent entry back the specified number of minutes.
263    ///
264    /// # Errors
265    ///
266    /// - Return [`PathError::FileAccess`] if the file cannot be opened or created.
267    /// - Return [`PathError::FileWrite`] if the function fails to append to the file.
268    /// - Return [`Error::InvalidWasArgument`] if supplied time is not parsable.
269    pub fn rewind_last_entry(&self, minutes: NonZeroU32) -> result::Result<(), Error> {
270        let dur = Duration::from_secs(u64::from(minutes.get()) * 60);
271        self.change_last_entry(|entry| {
272            let dt = (entry.date_time() - dur)?;
273            Ok(entry.change_date_time(dt))
274        })
275    }
276
277    /// Return the unfiltered last line of the timelog file or `None` if we can't.
278    pub fn raw_last_line(&self) -> Option<String> {
279        if self.exists() {
280            let file = File::open(&self.0).ok()?;
281            io::BufReader::new(file).lines().map_while(result::Result::ok).last()
282        }
283        else {
284            None
285        }
286    }
287
288    /// Return the last line of the timelog file or `None` if we can't.
289    pub fn last_line(&self) -> Option<String> {
290        if self.exists() {
291            let file = File::open(&self.0).ok()?;
292            io::BufReader::new(file)
293                .lines()
294                .map_while(Result::ok)
295                .filter(|ln| !ln.starts_with('#') && EntryKind::from_entry_line(ln).is_start())
296                .last()
297        }
298        else {
299            None
300        }
301    }
302
303    /// Return the last line of the timelog file as an [`Entry`].
304    ///
305    /// # Errors
306    ///
307    /// - Return [`Error::InvalidEntryLine`] if the line is not correctly formatted.
308    pub fn last_entry(&self) -> result::Result<Entry, Error> {
309        Entry::from_line(&self.last_line().unwrap_or_default()).map_err(Into::into)
310    }
311
312    /// Return a Vec of problems found with the file.
313    ///
314    /// If the Vec is empty, there are no problems.
315    pub fn problems(&self) -> Vec<Problem> {
316        if !self.exists() { return Vec::new(); }
317        let Ok(file) = self.open() else {
318            return vec![Problem::FileAccess];
319        };
320
321        let mut problems: Vec<Problem> = Vec::new();
322        let mut iter = buf_reader(file);
323        let Some(line) = iter.next() else { return problems; };
324        let mut prev = match Entry::from_line(&line) {
325            Ok(ev) => ev,
326            Err(e) => {
327                problems.push(Problem::from_error(e, 1));
328                return problems;
329            }
330        };
331        let twelve_hour_dur = Duration::from_secs(TWELVE_HOURS);
332        for (line, lineno) in iter.zip(2..) {
333            match Entry::from_line(&line) {
334                Ok(ev) => {
335                    if prev.date_time() > ev.date_time() {
336                        problems.push(Problem::EventsOrder(lineno));
337                    }
338                    else if !prev.is_stop() && !prev.is_event() {
339                        let diff = (ev.date_time() - prev.date_time()).unwrap_or_default();
340                        if diff > twelve_hour_dur {
341                            problems.push(Problem::EventLength(lineno));
342                        }
343                    }
344                    prev = ev;
345                },
346                Err(e) => problems.push(Problem::from_error(e, lineno))
347            }
348        }
349
350        problems
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use std::fs::{canonicalize, OpenOptions};
357
358    use regex::Regex;
359    use spectral::prelude::*;
360    use tempfile::TempDir;
361
362    use super::*;
363    use crate::date::Date;
364    use crate::DateTime;
365
366    fn make_timelog(lines: &Vec<String>) -> (TempDir, String) {
367        let tmpdir = TempDir::new().expect("Cannot make tempfile");
368        let mut path = tmpdir.path().to_path_buf();
369        path.push("timelog.txt");
370        let filename = path.to_str().unwrap();
371        let file = OpenOptions::new()
372            .create(true)
373            .append(true)
374            .open(filename)
375            .unwrap();
376        let mut stream = io::BufWriter::new(file);
377        lines
378            .iter()
379            .for_each(|line| writeln!(&mut stream, "{line}").unwrap());
380        stream.flush().unwrap();
381        (tmpdir, filename.to_string())
382    }
383
384    fn touch_timelog() -> (TempDir, String) { make_timelog(&vec![String::new()]) }
385
386    // new
387
388    #[test]
389    fn test_new() {
390        let logfile = Logfile::new("./foo.txt").unwrap();
391        let expected = canonicalize(".")
392            .map(|mut pb| {
393                pb.push("foo.txt");
394                pb.to_str().unwrap().to_string()
395            })
396            .unwrap_or("".to_string());
397        assert_that!(logfile.clone_file()).is_equal_to(&expected);
398    }
399
400    #[test]
401    fn test_new_empty_name() {
402        assert_that!(Logfile::new(""))
403            .is_err()
404            .is_equal_to(&PathError::FilenameMissing);
405    }
406
407    #[test]
408    fn test_new_bad_path() {
409        assert_that!(Logfile::new("./xyzzy/foo.txt")).is_err_containing(PathError::InvalidPath(
410            "./xyzzy/foo.txt".to_string(),
411            "No such file or directory (os error 2)".to_string()
412        ));
413    }
414
415    // exists
416
417    #[test]
418    fn test_exists_false() {
419        let logfile = Logfile::new("./foo.txt").unwrap();
420        assert_that!(logfile.exists()).is_false();
421    }
422
423    #[test]
424    fn test_exists_true() {
425        let (_tmpdir, filename) = touch_timelog();
426        let logfile = Logfile::new(&filename).unwrap();
427        assert_that!(logfile.exists()).is_true();
428    }
429
430    // add
431
432    #[test]
433    fn test_add_line() {
434        let (_tmpdir, filename) = touch_timelog();
435        let logfile = Logfile::new(&filename).unwrap();
436        assert_that!(logfile.add_line("2021-11-18 18:00:00 +project @task")).is_ok();
437        assert_that!(logfile.last_line())
438            .contains_value(&String::from("2021-11-18 18:00:00 +project @task"));
439    }
440
441    #[test]
442    fn test_add_entry() {
443        let (_tmpdir, filename) = touch_timelog();
444        let logfile = Logfile::new(&filename).unwrap();
445        let entry = Entry::new(
446            "+project @task",
447            DateTime::try_from("2021-11-18 18:00:00").expect("Bad date")
448        );
449        assert_that!(logfile.add_entry(&entry)).is_ok();
450        assert_that!(logfile.last_line())
451            .contains_value(&String::from("2021-11-18 18:00:00 +project @task"));
452    }
453
454    #[test]
455    fn test_add_comment() {
456        let (_tmpdir, filename) = touch_timelog();
457        let logfile = Logfile::new(&filename).unwrap();
458        assert_that!(logfile.add_comment("This is a test")).is_ok();
459        assert_that!(logfile.raw_last_line()).contains_value(&String::from("# This is a test"));
460    }
461
462    #[test]
463    fn test_add_event() {
464        let (_tmpdir, filename) = touch_timelog();
465        let logfile = Logfile::new(&filename).unwrap();
466        #[rustfmt::skip]
467        let expect = Regex::new(
468            r"\A\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\^something happened"
469        ).expect("Regex failed");
470        assert_that!(logfile.add_event("something happened")).is_ok();
471        let last_line = logfile.raw_last_line();
472        assert_that!(last_line).is_some();
473        assert_that!(last_line.unwrap()).matches(|val| expect.is_match(&val));
474    }
475
476    // discard_line
477
478    #[test]
479    fn test_discard_line() {
480        let (_tmpdir, filename) = make_timelog(&vec![
481            "2021-11-18 17:01:01 +foo".to_string(),
482            "2021-11-18 17:04:02 +bar".to_string(),
483            "2021-11-18 17:08:04 +baz".to_string(),
484        ]);
485        let logfile = Logfile::new(&filename).unwrap();
486        assert_that!(logfile.discard_line()).is_ok();
487        assert_that!(logfile.last_line()).contains_value(
488            "2021-11-18 17:04:02 +bar".to_string(),
489        );
490    }
491
492    // change entry
493
494    #[test]
495    fn test_reset_last_entry() {
496        let (_tmpdir, filename) = make_timelog(&vec![
497            "2021-11-18 17:01:01 +foo".to_string(),
498            "2021-11-18 17:04:02 +bar".to_string(),
499            "2021-11-18 17:08:04 +baz".to_string(),
500        ]);
501        let logfile = Logfile::new(&filename).unwrap();
502        assert_that!(logfile.reset_last_entry()).is_ok();
503        let entry = logfile.last_entry().unwrap();
504        assert_that!(entry.entry_text()).is_equal_to("+baz");
505        assert_that!(entry.date()).is_equal_to(Date::today());
506    }
507
508    // TODO: Figure out why test fails, when parallel tests don't
509    // #[test]
510    // fn test_ignore_last_entry() {
511    //     let (_tmpdir, filename) = make_timelog(&vec![
512    //         "2021-11-18 17:01:01 +foo".to_string(),
513    //         "2021-11-18 17:04:02 +bar".to_string(),
514    //         "2021-11-18 17:08:04 +baz".to_string(),
515    //     ]);
516    //     let expect = Entry::from_line("2021-11-18 17:08:04 +baz").unwrap().ignore();
517    //     let logfile = Logfile::new(&filename).unwrap();
518    //     assert_that!(logfile.ignore_last_entry()).is_ok();
519    //     assert_that!(logfile.last_entry()).is_ok().is_equal_to(&expect);
520    // }
521
522    #[test]
523    fn test_rewrite_last_entry() {
524        let (_tmpdir, filename) = make_timelog(&vec![
525            "2021-11-18 17:01:01 +foo".to_string(),
526            "2021-11-18 17:04:02 +bar".to_string(),
527            "2021-11-18 17:08:04 +baz".to_string(),
528        ]);
529        let expect = Entry::from_line("2021-11-18 17:08:04 +foobar @Frond").unwrap();
530        let logfile = Logfile::new(&filename).unwrap();
531        assert_that!(logfile.rewrite_last_entry("+foobar @Frond")).is_ok();
532        assert_that!(logfile.last_entry()).is_ok().is_equal_to(&expect);
533    }
534
535    #[test]
536    fn test_retime_last_entry() {
537        let (_tmpdir, filename) = make_timelog(&vec![
538            "2021-11-18 17:01:01 +foo".to_string(),
539            "2021-11-18 17:04:02 +bar".to_string(),
540            "2021-11-18 17:08:04 +baz".to_string(),
541        ]);
542        let expect = Entry::from_line("2021-11-18 16:01:00 +baz").unwrap();
543        let logfile = Logfile::new(&filename).unwrap();
544        let time = Time::from_hms_opt(16, 1, 0).expect("");
545        assert_that!(logfile.retime_last_entry(time)).is_ok();
546        assert_that!(logfile.last_entry()).is_ok().is_equal_to(&expect);
547    }
548
549    #[test]
550    fn test_rewind_last_entry() {
551        let (_tmpdir, filename) = make_timelog(&vec![
552            "2021-11-18 17:01:01 +foo".to_string(),
553            "2021-11-18 17:04:02 +bar".to_string(),
554            "2021-11-18 17:08:04 +baz".to_string(),
555        ]);
556        let expect = Entry::from_line("2021-11-18 16:57:04 +baz").unwrap();
557        let logfile = Logfile::new(&filename).unwrap();
558        let minutes = NonZeroU32::new(11).expect("Hard-coded legal NonZero value");
559        assert_that!(logfile.rewind_last_entry(minutes)).is_ok();
560        assert_that!(logfile.last_entry()).is_ok().is_equal_to(&expect);
561    }
562
563    // last_line
564
565    #[test]
566    fn test_last_line_missing() {
567        let (_tmpdir, filename) = touch_timelog();
568        let logfile = Logfile::new(&filename).unwrap();
569        assert_that!(logfile.last_line()).contains_value(String::new());
570    }
571
572    #[test]
573    fn test_last_line_empty() {
574        let (_tmpdir, filename) = make_timelog(&vec![]);
575        let logfile = Logfile::new(&filename).unwrap();
576        assert_that!(logfile.last_line()).is_none();
577    }
578
579    #[test]
580    fn test_last_line_lines() {
581        let (_tmpdir, filename) = make_timelog(&vec![
582            "2021-11-18 17:01:01 +foo".to_string(),
583            "2021-11-18 17:04:02 +bar".to_string(),
584            "2021-11-18 17:08:04 +baz".to_string(),
585        ]);
586        let logfile = Logfile::new(&filename).unwrap();
587        assert_that!(logfile.last_line()).contains_value(&"2021-11-18 17:08:04 +baz".to_string());
588    }
589
590    #[test]
591    fn test_last_entry() {
592        let (_tmpdir, filename) = make_timelog(&vec![]);
593        let logfile = Logfile::new(&filename).unwrap();
594        assert_that!(logfile.last_entry())
595            .is_err()
596            .is_equal_to(&EntryError::BlankLine.into());
597    }
598
599    #[test]
600    fn test_last_entry_lines() {
601        let (_tmpdir, filename) = make_timelog(&vec![
602            "2021-11-18 17:01:01 +foo".to_string(),
603            "2021-11-18 17:04:02 +bar".to_string(),
604            "2021-11-18 17:08:04 +baz".to_string(),
605        ]);
606        let logfile = Logfile::new(&filename).unwrap();
607        let expected = Entry::from_line("2021-11-18 17:08:04 +baz").unwrap();
608        assert_that!(logfile.last_entry())
609            .is_ok()
610            .is_equal_to(&expected);
611    }
612
613    #[test]
614    fn test_problems_all_good() {
615        let (_tmpdir, filename) = make_timelog(&vec![
616            "2021-11-18 17:01:01 +foo".to_string(),
617            "2021-11-18 17:04:02 +bar".to_string(),
618            "2021-11-18 17:08:04 +baz".to_string(),
619            "2021-11-18 17:08:04 stop".to_string(),
620        ]);
621        let logfile = Logfile::new(&filename).unwrap();
622        assert_that!(logfile.problems()).is_empty();
623    }
624
625    #[test]
626    fn test_problems_all_good_with_comments() {
627        let (_tmpdir, filename) = make_timelog(&vec![
628            "# Start of file".to_string(),
629            "2021-11-18 17:01:01 +foo".to_string(),
630            "2021-11-18 17:04:02 +bar".to_string(),
631            "# Middle of file".to_string(),
632            "2021-11-18 17:08:04 +baz".to_string(),
633            "2021-11-18 17:08:04 stop".to_string(),
634            "# End of file".to_string(),
635        ]);
636        let logfile = Logfile::new(&filename).unwrap();
637        assert_that!(logfile.problems()).is_empty();
638    }
639
640    #[test]
641    fn test_problems_blank_line() {
642        let (_tmpdir, filename) = make_timelog(&vec![
643            "2021-11-18 17:01:01 +foo".to_string(),
644            "".to_string(),
645            "2021-11-18 17:04:02 +bar".to_string(),
646            "2021-11-18 17:08:04 +baz".to_string(),
647        ]);
648        let logfile = Logfile::new(&filename).unwrap();
649        let problems = logfile.problems();
650        assert_that!(problems).has_length(1);
651        assert_that!(problems.iter()).contains(Problem::BlankLine(2));
652    }
653
654    #[test]
655    fn test_problems_bad_date() {
656        let (_tmpdir, filename) = make_timelog(&vec![
657            "2021-1-18 17:01:01 +foo".to_string(),
658            "2021-11-18 17:04:02 +bar".to_string(),
659            "2021-11-18 17:08:04 +baz".to_string(),
660            "2021-11-18 17:08:04 stop".to_string(),
661        ]);
662        let logfile = Logfile::new(&filename).unwrap();
663        let problems = logfile.problems();
664        assert_that!(problems).has_length(1);
665        assert_that!(problems.iter()).contains(Problem::InvalidTimeStamp(1));
666    }
667
668    #[test]
669    fn test_problems_bad_time() {
670        let (_tmpdir, filename) = make_timelog(&vec![
671            "2021-11-18 7:01:01 +foo".to_string(),
672            "2021-11-18 17:04:02 +bar".to_string(),
673            "2021-11-18 17:08:04 +baz".to_string(),
674            "2021-11-18 17:08:04 stop".to_string(),
675        ]);
676        let logfile = Logfile::new(&filename).unwrap();
677        let problems = logfile.problems();
678        assert_that!(problems).has_length(1);
679        assert_that!(problems.iter()).contains(Problem::InvalidTimeStamp(1));
680    }
681
682    #[test]
683    fn test_problems_missing_timestamp() {
684        let (_tmpdir, filename) = make_timelog(&vec![
685            "+foo".to_string(),
686            "2021-11-18 17:04:02 +bar".to_string(),
687            "2021-11-18 17:08:04 +baz".to_string(),
688            "2021-11-18 17:08:04 stop".to_string(),
689        ]);
690        let logfile = Logfile::new(&filename).unwrap();
691        let problems = logfile.problems();
692        assert_that!(problems).has_length(1);
693        assert_that!(problems.iter()).contains(Problem::InvalidTimeStamp(1));
694    }
695
696    #[test]
697    fn test_problems_no_task() {
698        let (_tmpdir, filename) = make_timelog(&vec![
699            "2021-11-18 17:01:01 +foo".to_string(),
700            "2021-11-18 17:04:02 +bar".to_string(),
701            "2021-11-18 17:08:04 ".to_string(),
702            "2021-11-18 17:08:04 stop".to_string(),
703        ]);
704        let logfile = Logfile::new(&filename).unwrap();
705        let problems = logfile.problems();
706        assert_that!(problems).has_length(1);
707        assert_that!(problems.iter()).contains(Problem::MissingTask(3));
708    }
709
710    #[test]
711    fn test_problems_unknown_marker() {
712        let (_tmpdir, filename) = make_timelog(&vec![
713            "2021-11-18 17:01:01 +foo".to_string(),
714            "2021-11-18 17:04:02*+bar".to_string(),
715            "2021-11-18 17:08:04 +baz".to_string(),
716            "2021-11-18 17:08:04 stop".to_string(),
717        ]);
718        let logfile = Logfile::new(&filename).unwrap();
719        let problems = logfile.problems();
720        assert_that!(problems).has_length(1);
721        assert_that!(problems.iter()).contains(Problem::InvalidMarker(2));
722    }
723
724    #[test]
725    fn test_problems_entry_unordered() {
726        let (_tmpdir, filename) = make_timelog(&vec![
727            "2021-11-18 17:01:01 +foo".to_string(),
728            "2021-11-18 17:08:04 +baz".to_string(),
729            "2021-11-18 17:04:02 +bar".to_string(),
730            "2021-11-18 17:08:04 stop".to_string(),
731        ]);
732        let logfile = Logfile::new(&filename).unwrap();
733        let problems = logfile.problems();
734        assert_that!(problems).has_length(1);
735        assert_that!(problems.iter()).contains(Problem::EventsOrder(3));
736    }
737
738    #[test]
739    fn test_problems_open_ended() {
740        let (_tmpdir, filename) = make_timelog(&vec![
741            "2021-11-18 17:01:01 +foo".to_string(),
742            "2021-11-18 17:04:02 +bar".to_string(),
743            "2021-11-19 17:08:04 +baz".to_string(),
744            "2021-11-19 17:08:04 stop".to_string(),
745        ]);
746        let logfile = Logfile::new(&filename).unwrap();
747        let problems = logfile.problems();
748        assert_that!(problems).has_length(1);
749        assert_that!(problems.iter()).contains(Problem::EventLength(3));
750    }
751}