1use 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
27impl EntryLine {
29 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 fn extract_year(&self) -> Option<i32> {
53 self.line.as_ref().and_then(|ln| Entry::extract_year(ln))
54 }
55
56 fn is_stop_line(&self) -> bool { self.line.as_ref().map_or(false, |l| Entry::is_stop_line(l)) }
58
59 fn to_entry(&self) -> Option<Result<Entry, EntryError>> {
64 self.line.as_ref().map(|l| Entry::from_line(l))
65 }
66
67 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 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
90struct EntryLineIter<'a> {
92 lines: std::io::Lines<BufReader<&'a File>>
93}
94
95impl<'a> EntryLineIter<'a> {
96 #[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 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
118pub(crate) struct Archiver<'a> {
120 config: &'a Config,
122 curr_year: i32,
124 new_file: String,
126 back_file: String
128}
129
130impl<'a> Archiver<'a> {
131 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 fn logfile(&self) -> crate::Result<Logfile> {
149 Logfile::new(&self.config.logfile()).map_err(Into::into)
150 }
151
152 fn archive_filepath(&self, year: i32) -> String {
154 format!("{}/timelog-{year}.txt", self.config.dir())
155 }
156
157 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 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 writeln!(&mut arc_stream, "{}", Self::entry_end_year(&entry)).map_err(
213 |e| PathError::FileWrite(archive_filename.clone(), e.to_string())
214 )?;
215 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 #[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 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 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 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}