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