1use 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#[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#[derive(Debug)]
84pub struct Logfile(String);
85
86impl Logfile {
87 pub fn new(file: &str) -> result::Result<Self, PathError> {
95 file::canonical_filename(file, file::FileKind::LogFile).map(Self)
96 }
97
98 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 pub fn clone_file(&self) -> String { self.0.clone() }
109
110 pub fn exists(&self) -> bool { Path::new(&self.0).exists() }
112
113 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 pub fn add_task(&self, task: &str) -> result::Result<(), PathError> {
137 self.add_entry(&Entry::new(task, DateTime::now()))
138 }
139
140 pub fn add_entry(&self, entry: &Entry) -> result::Result<(), PathError> {
147 let line = format!("{entry}");
148 self.add_line(&line)
149 }
150
151 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 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 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 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 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 pub fn ignore_last_entry(&self) -> result::Result<(), Error> {
234 self.change_last_entry(|entry| Ok(entry.ignore()))
235 }
236
237 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 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 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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 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 #[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}