timelog/
archive.rs

1//! Handle the archiving of old records from the timelog.
2
3use std::fmt::{self, Display};
4use std::fs::{self, File};
5use std::io::prelude::*;
6use std::io::{BufRead, BufReader, BufWriter};
7use std::path::Path;
8
9use crate::config::Config;
10#[doc(inline)]
11use crate::date::Date;
12#[doc(inline)]
13use crate::date::DateTime;
14#[doc(inline)]
15use crate::entry::{Entry, EntryError};
16#[doc(inline)]
17use crate::error::PathError;
18#[doc(inline)]
19use crate::logfile::Logfile;
20
21#[derive(Debug, Default)]
22struct EntryLine {
23    comments: Vec<String>,
24    line:     Option<String>
25}
26
27// Representation of an entry line with optional leading comment lines.
28impl EntryLine {
29    // Add an optional text line to the [`EntryLine`] object.
30    //
31    // If the supplied optional line is None, do nothing and return false.
32    // If the supplied line starts with the comment character, store as leading
33    // comment and return false.
34    // If the supplied line is not a comment, add as the entry line and return true.
35    fn add_line<OS>(&mut self, oline: OS) -> bool
36    where
37        OS: Into<Option<String>>
38    {
39        if let Some(line) = oline.into() {
40            if Entry::is_comment_line(&line) {
41                self.comments.push(line);
42            }
43            else {
44                self.line = Some(line);
45                return true;
46            }
47        }
48        false
49    }
50
51    // Extract the year from the contained [`EntryLine`] returning an optional year number.
52    fn extract_year(&self) -> Option<i32> {
53        self.line.as_ref().and_then(|ln| Entry::extract_year(ln))
54    }
55
56    // Return true if there is an entry line and it is a stop.
57    fn is_stop_line(&self) -> bool { self.line.as_ref().map_or(false, |l| Entry::is_stop_line(l)) }
58
59    // Convert the entry line to an entry if it parses and exists.
60    //
61    // Return None if no Entry line exists.
62    // Return Some(Err(EntryError)) if the Entry conversion fails.
63    fn to_entry(&self) -> Option<Result<Entry, EntryError>> {
64        self.line.as_ref().map(|l| Entry::from_line(l))
65    }
66
67    // Convert the [`EntryLine`] to an optional EntryLine.
68    //
69    // If there are no comments and no entry line, return None.
70    // Otherwise, return itself as a Some().
71    fn make_option(self) -> Option<Self> {
72        (!self.comments.is_empty() || self.line.is_some()).then_some(self)
73    }
74}
75
76impl Display for EntryLine {
77    // Format the [`EntryLine`] for display.
78    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79        let mut iter = self.comments.iter().chain(self.line.iter());
80        if let Some(line) = iter.next() {
81            write!(f, "{line}")?;
82            for line in iter {
83                write!(f, "\n{line}")?;
84            }
85        }
86        Ok(())
87    }
88}
89
90// Iterator that returns [`EntryLine`]s.
91struct EntryLineIter<'a> {
92    lines: std::io::Lines<BufReader<&'a File>>
93}
94
95impl<'a> EntryLineIter<'a> {
96    // Create an [`EntryLineIter`] from a [`File`] reference.
97    #[rustfmt::skip]
98    pub fn new(file: &'a File) -> Self {
99        Self { lines: BufReader::new(file).lines() }
100    }
101}
102
103impl<'a> Iterator for EntryLineIter<'a> {
104    type Item = EntryLine;
105
106    // Return next [`EntryLine`] if it exists.
107    fn next(&mut self) -> Option<Self::Item> {
108        let mut eline = EntryLine::default();
109        for line in self.lines.by_ref() {
110            if eline.add_line(line.ok()) {
111                return eline.make_option();
112            }
113        }
114        eline.make_option()
115    }
116}
117
118/// Configuration for archiving previous year information from the timelog.txt file.
119pub(crate) struct Archiver<'a> {
120    // Reference to the rtimelog configuration.
121    config:    &'a Config,
122    // The current year
123    curr_year: i32,
124    // The name for the new file created to store the previous year.
125    new_file:  String,
126    // The name for the backup file.
127    back_file: String
128}
129
130impl<'a> Archiver<'a> {
131    /// Create a new Archiver object.
132    pub(crate) fn new(config: &'a Config) -> Self {
133        let logfile = config.logfile();
134        Self {
135            config,
136            curr_year: Date::today().year(),
137            new_file: format!("{logfile}.new"),
138            back_file: format!("{logfile}.bak")
139        }
140    }
141
142    // Return the [`Logfile`] object representing the file on disk
143    //
144    // # Errors
145    //
146    // - Return [`PathError::FilenameMissing`] if the `file` has no filename.
147    // - Return [`PathError::InvalidPath`] if the path part of `file` is not a valid path.
148    fn logfile(&self) -> crate::Result<Logfile> {
149        Logfile::new(&self.config.logfile()).map_err(Into::into)
150    }
151
152    // Return an appropriate path/filename for the supplied year.
153    fn archive_filepath(&self, year: i32) -> String {
154        format!("{}/timelog-{year}.txt", self.config.dir())
155    }
156
157    // Return a [`BufWriter`] wrapping the file for writing to the supplied filename.
158    //
159    // # Errors
160    //
161    // - Return [`PathError::FileAccess`] if unable to open the file.
162    fn archive_writer(filename: &str) -> crate::Result<BufWriter<File>> {
163        let file = fs::OpenOptions::new()
164            .create(true)
165            .write(true)
166            .truncate(true)
167            .open(filename)
168            .map_err(|e| PathError::FileAccess(filename.to_string(), e.to_string()))?;
169        Ok(BufWriter::new(file))
170    }
171
172    /// Archive the first (non-current) year from the logfile.
173    ///
174    /// - Return Ok(Some(year)) if `year` was archived.
175    /// - Return Ok(None) if no previous year to archive.
176    ///
177    /// # Errors
178    ///
179    /// - Return [`Error::PathError`] for any error accessing the log or archive files.
180    pub(crate) fn archive(&self) -> crate::Result<Option<i32>> {
181        let file = self.logfile()?.open()?;
182        let mut elines = EntryLineIter::new(&file);
183        let Some(first) = elines.next() else { return Ok(None); };
184
185        let arc_year = first.extract_year().ok_or(EntryError::InvalidTimeStamp)?;
186        if arc_year >= self.curr_year { return Ok(None); }
187
188        let logfile = self.config.logfile();
189        let archive_filename = self.archive_filepath(arc_year);
190        if Path::new(&archive_filename).exists() {
191            return Err(PathError::AlreadyExists(archive_filename).into());
192        }
193        let mut arc_stream = Self::archive_writer(&archive_filename)?;
194        let mut new_stream = Self::archive_writer(&self.new_file)?;
195
196        writeln!(&mut arc_stream, "{first}")
197            .map_err(|e| PathError::FileWrite(archive_filename.clone(), e.to_string()))?;
198
199        let mut prev = Some(first);
200        let mut save = false;
201        for line in elines {
202            save = line.extract_year().map_or(save, |y| y == arc_year);
203
204            let mut stream = if save {
205                &mut arc_stream
206            }
207            else if let Some(pline) = prev {
208                if !pline.is_stop_line() {
209                    if let Some(ev) = pline.to_entry() {
210                        let entry = ev?;
211                        // finish archive file
212                        writeln!(&mut arc_stream, "{}", Self::entry_end_year(&entry)).map_err(
213                            |e| PathError::FileWrite(archive_filename.clone(), e.to_string())
214                        )?;
215                        // start new file
216                        writeln!(&mut new_stream, "{}", Self::entry_next_year(&entry)).map_err(
217                            |e| PathError::FileWrite(archive_filename.clone(), e.to_string())
218                        )?;
219                    }
220                }
221                prev = None;
222                &mut new_stream
223            }
224            else {
225                &mut new_stream
226            };
227
228            writeln!(&mut stream, "{line}")
229                .map_err(|e| PathError::FileWrite(archive_filename.clone(), e.to_string()))?;
230            if save {
231                prev = Some(line);
232            }
233        }
234
235        Self::flush(&archive_filename, &mut arc_stream)?;
236        Self::flush(&self.new_file, &mut new_stream)?;
237        Self::rename(&logfile, &self.back_file)?;
238        Self::rename(&self.new_file, &logfile)?;
239
240        Ok(Some(arc_year))
241    }
242
243    /// Clone supplied [`Entry`] moved to the beginning of the next year
244    #[rustfmt::skip]
245    fn entry_end_year(entry: &Entry) -> Entry {
246        Entry::new_stop(
247            DateTime::new((entry.date().year() + 1, 1, 1), (0, 0, 0)).expect("Not at end of time")
248        )
249    }
250
251    /// Clone supplied [`Entry`] moved to the beginning of the next year
252    fn entry_next_year(entry: &Entry) -> Entry {
253        Entry::new(
254            entry.entry_text(),
255            DateTime::new((entry.date().year() + 1, 1, 1), (0, 0, 0)).expect("Not at end of time")
256        )
257    }
258
259    // Utility method for flushing a writer and properly reporting any error.
260    fn flush(filename: &str, stream: &mut BufWriter<File>) -> crate::Result<()> {
261        stream
262            .flush()
263            .map_err(|e| PathError::FileWrite(filename.to_string(), e.to_string()).into())
264    }
265
266    // Utility method for renaming a file and properly reporting any error.
267    fn rename(old: &str, new: &str) -> crate::Result<()> {
268        fs::rename(old, new)
269            .map_err(|e| PathError::RenameFailure(old.to_string(), e.to_string()).into())
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use std::iter::once;
276
277    use spectral::prelude::*;
278    use tempfile::TempDir;
279
280    use super::*;
281    use crate::Date;
282
283    fn make_timelog(lines: &Vec<String>) -> (TempDir, String) {
284        let tmpdir = TempDir::new().expect("Cannot make tempfile");
285        let mut path = tmpdir.path().to_path_buf();
286        path.push("timelog.txt");
287        let filename = path.to_str().unwrap();
288        let file = fs::OpenOptions::new()
289            .create(true)
290            .append(true)
291            .open(filename)
292            .unwrap();
293        let mut stream = BufWriter::new(file);
294        lines
295            .iter()
296            .for_each(|line| writeln!(&mut stream, "{}", line).unwrap());
297        stream.flush().unwrap();
298        (tmpdir, filename.to_string())
299    }
300
301    #[test]
302    fn test_new() {
303        let config = Config::default();
304        let arch = Archiver::new(&config);
305        let logfile = config.logfile();
306        assert_that!(arch.config).is_equal_to(&config);
307        assert_that!(arch.curr_year).is_equal_to(&(Date::today().year()));
308        assert_that!(arch.new_file).is_equal_to(&format!("{}.new", logfile));
309        assert_that!(arch.back_file).is_equal_to(&format!("{}.bak", logfile));
310    }
311
312    #[test]
313    fn test_archive_filepath() {
314        let config = Config::default();
315        let arch = Archiver::new(&config);
316        let expect = format!("{}/timelog-{}.txt", config.dir(), 2011);
317        assert_that!(arch.archive_filepath(2011)).is_equal_to(&expect);
318    }
319
320    #[test]
321    fn test_archive_this_year() {
322        let curr_year = Date::today().year();
323        let (tmpdir, _filename) = make_timelog(&vec![
324            format!("{curr_year}-02-10 09:01:00 +foo"),
325            format!("{curr_year}-02-10 09:10:00 stop"),
326            format!("{curr_year}-02-15 09:01:00 +foo"),
327            format!("{curr_year}-02-15 09:01:00 stop"),
328        ]);
329        #[rustfmt::skip]
330        let config = Config::new(
331            ".timelog",
332            Some(tmpdir.path().to_str().expect("tempdir failed to return string")),
333            Some("vim"),
334            None,
335            None
336        ).expect("Legal config");
337        let arch = Archiver::new(&config);
338        assert_that!(arch.archive()).is_ok().is_none();
339    }
340
341    #[test]
342    fn test_archive_this_year_with_comments() {
343        let curr_year = Date::today().year();
344        let (tmpdir, _filename) = make_timelog(&vec![
345            String::from("# Initial comment"),
346            format!("{curr_year}-02-10 09:01:00 +foo"),
347            format!("{curr_year}-02-10 09:10:00 stop"),
348            String::from("# Middle comment"),
349            format!("{curr_year}-02-15 09:01:00 +foo"),
350            format!("{curr_year}-02-15 09:01:00 stop"),
351            String::from("# Trailing comment"),
352        ]);
353        #[rustfmt::skip]
354        let config = Config::new(
355            ".timelog",
356            Some(tmpdir.path().to_str().expect("tempdir failed to return string")),
357            Some("vim"),
358            None,
359            None
360        ).expect("Legal config");
361        let arch = Archiver::new(&config);
362        assert_that!(arch.archive()).is_ok().is_none();
363    }
364
365    #[test]
366    fn test_archive_prev_year() {
367        let prev_year = Date::today().year() - 1;
368        let (tmpdir, filename) = make_timelog(&vec![
369            format!("{prev_year}-02-10 09:01:00 +foo"),
370            format!("{prev_year}-02-10 09:10:00 stop"),
371            format!("{prev_year}-02-15 09:01:00 +foo"),
372            format!("{prev_year}-02-15 09:01:00 stop"),
373        ]);
374        let expected = std::fs::read_to_string(&filename).expect("Could not read archive");
375        #[rustfmt::skip]
376        let config = Config::new(
377            ".timelog",
378            Some(tmpdir.path().to_str().expect("tempdir failed to return string")),
379            Some("vim"),
380            None,
381            None
382        ).expect("Legal config");
383        let arch = Archiver::new(&config);
384        assert_that!(arch.archive()).is_ok().contains(prev_year);
385
386        let archive_file = arch.archive_filepath(prev_year);
387        let actual = std::fs::read_to_string(&archive_file).expect("Could not read archive");
388        assert_that!(actual).is_equal_to(&expected);
389
390        let metadata = std::fs::metadata(&filename).expect("metadata failed");
391        assert_that!(metadata.is_file()).is_true();
392        assert_that!(metadata.len()).is_equal_to(0u64);
393    }
394
395    #[test]
396    fn test_archive_split_years() {
397        let curr_year = Date::today().year();
398        let prev_year = curr_year - 1;
399        let prev_year_lines = vec![
400            format!("{prev_year}-12-10 19:01:00 +foo"),
401            format!("{prev_year}-12-10 19:10:00 stop"),
402            format!("{prev_year}-12-15 19:01:00 +foo"),
403            format!("{prev_year}-12-15 19:01:00 stop"),
404        ];
405        let curr_year_lines = vec![
406            format!("{curr_year}-02-10 09:01:00 +foo"),
407            format!("{curr_year}-02-10 09:10:00 stop"),
408            format!("{curr_year}-02-15 09:01:00 +foo"),
409            format!("{curr_year}-02-15 09:01:00 stop"),
410        ];
411        let mut expected = curr_year_lines.join("\n");
412        expected.push_str("\n");
413        let lines: Vec<String> = prev_year_lines
414            .iter()
415            .chain(curr_year_lines.iter())
416            .cloned()
417            .collect();
418        let (tmpdir, filename) = make_timelog(&lines);
419        #[rustfmt::skip]
420        let config = Config::new(
421            ".timelog",
422            Some(tmpdir.path().to_str().expect("tempdir failed to return string")),
423            Some("vim"),
424            None,
425            None
426        ).expect("Legal config");
427        let arch = Archiver::new(&config);
428        assert_that!(arch.archive()).is_ok().contains(prev_year);
429
430        let actual = std::fs::read_to_string(&filename).expect("Could not read archive");
431        assert_that!(actual).is_equal_to(&expected);
432
433        let mut expected = prev_year_lines.join("\n");
434        expected.push_str("\n");
435        let archive_file = arch.archive_filepath(prev_year);
436        let actual = std::fs::read_to_string(&archive_file).expect("Could not read archive");
437        assert_that!(actual).is_equal_to(&expected);
438    }
439
440    #[test]
441    fn test_archive_split_years_with_comments() {
442        let curr_year = Date::today().year();
443        let prev_year = curr_year - 1;
444        let prev_year_lines = vec![
445            String::from("# Initial commment"),
446            format!("{prev_year}-12-10 19:01:00 +foo"),
447            format!("{prev_year}-12-10 19:10:00 stop"),
448            format!("{prev_year}-12-15 19:01:00 +foo"),
449            format!("{prev_year}-12-15 19:01:00 stop"),
450        ];
451        let curr_year_lines = vec![
452            String::from("# Breaking commment"),
453            format!("{curr_year}-02-10 09:01:00 +foo"),
454            format!("{curr_year}-02-10 09:10:00 stop"),
455            format!("{curr_year}-02-15 09:01:00 +foo"),
456            format!("{curr_year}-02-15 09:01:00 stop"),
457            String::from("# Trailing commment"),
458        ];
459        let mut expected = curr_year_lines.join("\n");
460        expected.push_str("\n");
461        let lines: Vec<String> = prev_year_lines
462            .iter()
463            .chain(curr_year_lines.iter())
464            .cloned()
465            .collect();
466        let (tmpdir, filename) = make_timelog(&lines);
467        #[rustfmt::skip]
468        let config = Config::new(
469            ".timelog",
470            Some(tmpdir.path().to_str().expect("tempdir failed to return string")),
471            Some("vim"),
472            None,
473            None
474        ).expect("Legal config");
475        let arch = Archiver::new(&config);
476        assert_that!(arch.archive()).is_ok().contains(prev_year);
477
478        let actual = std::fs::read_to_string(&filename).expect("Could not read archive");
479        assert_that!(actual).is_equal_to(&expected);
480
481        let mut expected = prev_year_lines.join("\n");
482        expected.push_str("\n");
483        let archive_file = arch.archive_filepath(prev_year);
484        let actual = std::fs::read_to_string(&archive_file).expect("Could not read archive");
485        assert_that!(actual).is_equal_to(&expected);
486    }
487
488    #[test]
489    fn test_archive_split_years_task_crosses() {
490        let curr_year = Date::today().year();
491        let prev_year = curr_year - 1;
492        let prev_year_lines = vec![
493            format!("{prev_year}-12-10 19:01:00 +foo"),
494            format!("{prev_year}-12-10 19:10:00 stop"),
495            format!("{prev_year}-12-15 19:01:00 +foo"),
496            format!("{prev_year}-12-15 19:01:00 stop"),
497            format!("{prev_year}-12-31 23:01:00 +bar"),
498        ];
499        let curr_year_lines = vec![
500            format!("{curr_year}-01-01 00:10:00 stop"),
501            format!("{curr_year}-02-10 09:01:00 +foo"),
502            format!("{curr_year}-02-10 09:10:00 stop"),
503            format!("{curr_year}-02-15 09:01:00 +foo"),
504            format!("{curr_year}-02-15 09:01:00 stop"),
505        ];
506        let mut expected = once(&format!("{}-01-01 00:00:00 +bar", curr_year))
507            .chain(curr_year_lines.iter())
508            .cloned()
509            .collect::<Vec<String>>()
510            .join("\n");
511        expected.push_str("\n");
512        let lines: Vec<String> = prev_year_lines
513            .iter()
514            .chain(curr_year_lines.iter())
515            .cloned()
516            .collect();
517        let (tmpdir, filename) = make_timelog(&lines);
518        #[rustfmt::skip]
519        let config = Config::new(
520            ".timelog",
521            Some(tmpdir.path().to_str().expect("tempdir failed to return string")),
522            Some("vim"),
523            None,
524            None
525        ).expect("Legal config");
526        let arch = Archiver::new(&config);
527        assert_that!(arch.archive()).is_ok().contains(prev_year);
528
529        let actual = std::fs::read_to_string(&filename).expect("Could not read archive");
530        assert_that!(actual).is_equal_to(&expected);
531
532        let mut expected = prev_year_lines
533            .iter()
534            .chain(once(&format!("{curr_year}-01-01 00:00:00 stop")))
535            .cloned()
536            .collect::<Vec<String>>()
537            .join("\n");
538        expected.push_str("\n");
539        let archive_file = arch.archive_filepath(prev_year);
540        let actual = std::fs::read_to_string(&archive_file).expect("Could not read archive");
541        assert_that!(actual).is_equal_to(&expected);
542    }
543
544    #[test]
545    fn test_archive_split_years_task_crosses_with_comments() {
546        let curr_year = Date::today().year();
547        let prev_year = curr_year - 1;
548        let prev_year_lines = vec![
549            String::from("# Initial comment"),
550            format!("{prev_year}-12-10 19:01:00 +foo"),
551            format!("{prev_year}-12-10 19:10:00 stop"),
552            format!("{prev_year}-12-15 19:01:00 +foo"),
553            format!("{prev_year}-12-15 19:01:00 stop"),
554            format!("{prev_year}-12-31 23:01:00 +bar"),
555        ];
556        let curr_year_lines = vec![
557            String::from("# Split comment"),
558            format!("{curr_year}-01-01 00:10:00 stop"),
559            format!("{curr_year}-02-10 09:01:00 +foo"),
560            format!("{curr_year}-02-10 09:10:00 stop"),
561            format!("{curr_year}-02-15 09:01:00 +foo"),
562            format!("{curr_year}-02-15 09:01:00 stop"),
563            String::from("# Trailing comment"),
564        ];
565        let mut expected = once(&format!("{}-01-01 00:00:00 +bar", curr_year))
566            .chain(curr_year_lines.iter())
567            .cloned()
568            .collect::<Vec<String>>()
569            .join("\n");
570        expected.push_str("\n");
571        let lines: Vec<String> = prev_year_lines
572            .iter()
573            .chain(curr_year_lines.iter())
574            .cloned()
575            .collect();
576        let (tmpdir, filename) = make_timelog(&lines);
577        #[rustfmt::skip]
578        let config = Config::new(
579            ".timelog",
580            Some(tmpdir.path().to_str().expect("tempdir failed to return string")),
581            Some("vim"),
582            None,
583            None
584        ).expect("Legal config");
585        let arch = Archiver::new(&config);
586        assert_that!(arch.archive()).is_ok().contains(prev_year);
587
588        let actual = std::fs::read_to_string(&filename).expect("Could not read archive");
589        assert_that!(actual).is_equal_to(&expected);
590
591        let mut expected = prev_year_lines
592            .iter()
593            .chain(once(&format!("{curr_year}-01-01 00:00:00 stop")))
594            .cloned()
595            .collect::<Vec<String>>()
596            .join("\n");
597        expected.push_str("\n");
598        let archive_file = arch.archive_filepath(prev_year);
599        let actual = std::fs::read_to_string(&archive_file).expect("Could not read archive");
600        assert_that!(actual).is_equal_to(&expected);
601    }
602}