Skip to main content

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