use std::fmt::{self, Display};
use std::fs::{self, File};
use std::io::prelude::*;
use std::io::{BufRead, BufReader, BufWriter};
use std::path::Path;
use crate::config::Config;
#[doc(inline)]
use crate::date::Date;
#[doc(inline)]
use crate::date::DateTime;
#[doc(inline)]
use crate::entry::{Entry, EntryError};
#[doc(inline)]
use crate::error::PathError;
#[doc(inline)]
use crate::logfile::Logfile;
#[derive(Debug, Default)]
struct EntryLine {
comments: Vec<String>,
line: Option<String>
}
impl EntryLine {
fn add_line<OS>(&mut self, oline: OS) -> bool
where
OS: Into<Option<String>>
{
if let Some(line) = oline.into() {
if Entry::is_comment_line(&line) {
self.comments.push(line);
}
else {
self.line = Some(line);
return true;
}
}
false
}
fn extract_year(&self) -> Option<i32> {
self.line.as_ref().and_then(|ln| Entry::extract_year(ln))
}
fn is_stop_line(&self) -> bool { self.line.as_ref().is_some_and(|l| Entry::is_stop_line(l)) }
fn to_entry(&self) -> Option<Result<Entry, EntryError>> {
self.line.as_ref().map(|l| Entry::from_line(l))
}
fn make_option(self) -> Option<Self> {
(!self.comments.is_empty() || self.line.is_some()).then_some(self)
}
}
impl Display for EntryLine {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut iter = self.comments.iter().chain(self.line.iter());
if let Some(line) = iter.next() {
write!(f, "{line}")?;
for line in iter {
write!(f, "\n{line}")?;
}
}
Ok(())
}
}
struct EntryLineIter<'a> {
lines: std::io::Lines<BufReader<&'a File>>
}
impl<'a> EntryLineIter<'a> {
#[rustfmt::skip]
pub fn new(file: &'a File) -> Self {
Self { lines: BufReader::new(file).lines() }
}
}
impl<'a> Iterator for EntryLineIter<'a> {
type Item = EntryLine;
fn next(&mut self) -> Option<Self::Item> {
let mut eline = EntryLine::default();
for line in self.lines.by_ref() {
if eline.add_line(line.ok()) {
return eline.make_option();
}
}
eline.make_option()
}
}
pub(crate) struct Archiver<'a> {
config: &'a Config,
curr_year: i32,
new_file: String,
back_file: String
}
impl<'a> Archiver<'a> {
pub(crate) fn new(config: &'a Config) -> Self {
let logfile = config.logfile();
Self {
config,
curr_year: Date::today().year(),
new_file: format!("{logfile}.new"),
back_file: format!("{logfile}.bak")
}
}
fn logfile(&self) -> crate::Result<Logfile> {
Logfile::new(&self.config.logfile()).map_err(Into::into)
}
fn archive_filepath(&self, year: i32) -> String {
format!("{}/timelog-{year}.txt", self.config.dir())
}
fn archive_writer(filename: &str) -> crate::Result<BufWriter<File>> {
let file = fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(filename)
.map_err(|e| PathError::FileAccess(filename.to_string(), e.to_string()))?;
Ok(BufWriter::new(file))
}
pub(crate) fn archive(&self) -> crate::Result<Option<i32>> {
let file = self.logfile()?.open()?;
let mut elines = EntryLineIter::new(&file);
let Some(first) = elines.next() else { return Ok(None); };
let arc_year = first.extract_year().ok_or(EntryError::InvalidTimeStamp)?;
if arc_year >= self.curr_year { return Ok(None); }
let logfile = self.config.logfile();
let archive_filename = self.archive_filepath(arc_year);
if Path::new(&archive_filename).exists() {
return Err(PathError::AlreadyExists(archive_filename).into());
}
let mut arc_stream = Self::archive_writer(&archive_filename)?;
let mut new_stream = Self::archive_writer(&self.new_file)?;
writeln!(&mut arc_stream, "{first}")
.map_err(|e| PathError::FileWrite(archive_filename.clone(), e.to_string()))?;
let mut prev = Some(first);
let mut save = false;
for line in elines {
save = line.extract_year().map_or(save, |y| y == arc_year);
let mut stream = if save {
&mut arc_stream
}
else if let Some(pline) = prev {
if !pline.is_stop_line() {
if let Some(ev) = pline.to_entry() {
let entry = ev?;
writeln!(&mut arc_stream, "{}", Self::entry_end_year(&entry)).map_err(
|e| PathError::FileWrite(archive_filename.clone(), e.to_string())
)?;
writeln!(&mut new_stream, "{}", Self::entry_next_year(&entry)).map_err(
|e| PathError::FileWrite(archive_filename.clone(), e.to_string())
)?;
}
}
prev = None;
&mut new_stream
}
else {
&mut new_stream
};
writeln!(&mut stream, "{line}")
.map_err(|e| PathError::FileWrite(archive_filename.clone(), e.to_string()))?;
if save {
prev = Some(line);
}
}
Self::flush(&archive_filename, &mut arc_stream)?;
Self::flush(&self.new_file, &mut new_stream)?;
Self::rename(&logfile, &self.back_file)?;
Self::rename(&self.new_file, &logfile)?;
Ok(Some(arc_year))
}
#[rustfmt::skip]
fn entry_end_year(entry: &Entry) -> Entry {
Entry::new_stop(
DateTime::new((entry.date().year() + 1, 1, 1), (0, 0, 0)).expect("Not at end of time")
)
}
fn entry_next_year(entry: &Entry) -> Entry {
Entry::new(
entry.entry_text(),
DateTime::new((entry.date().year() + 1, 1, 1), (0, 0, 0)).expect("Not at end of time")
)
}
fn flush(filename: &str, stream: &mut BufWriter<File>) -> crate::Result<()> {
stream
.flush()
.map_err(|e| PathError::FileWrite(filename.to_string(), e.to_string()).into())
}
fn rename(old: &str, new: &str) -> crate::Result<()> {
fs::rename(old, new)
.map_err(|e| PathError::RenameFailure(old.to_string(), e.to_string()).into())
}
}
#[cfg(test)]
mod tests {
use std::iter::once;
use assert2::{assert, let_assert};
use tempfile::TempDir;
use super::*;
use crate::Date;
fn make_timelog(lines: &[String]) -> (TempDir, String) {
let_assert!(Ok(tmpdir) = TempDir::new());
let mut path = tmpdir.path().to_path_buf();
path.push("timelog.txt");
let_assert!(Some(filename) = path.to_str());
let_assert!(Ok(file) = fs::OpenOptions::new()
.create(true)
.append(true)
.open(filename));
let mut stream = BufWriter::new(file);
lines
.iter()
.for_each(|line| writeln!(&mut stream, "{line}").expect("Hardcoded path"));
let_assert!(Ok(_) = stream.flush());
(tmpdir, filename.to_string())
}
#[test]
fn test_new() {
let config = Config::default();
let arch = Archiver::new(&config);
let logfile = config.logfile();
assert!(arch.config == &config);
assert!(arch.curr_year == Date::today().year());
assert!(arch.new_file == format!("{logfile}.new"));
assert!(arch.back_file == format!("{logfile}.bak"));
}
#[test]
fn test_archive_filepath() {
let config = Config::default();
let arch = Archiver::new(&config);
let expect = format!("{}/timelog-{}.txt", config.dir(), 2011);
assert!(arch.archive_filepath(2011) == expect);
}
#[test]
fn test_archive_this_year() {
let curr_year = Date::today().year();
let (tmpdir, _filename) = make_timelog(&[
format!("{curr_year}-02-10 09:01:00 +foo"),
format!("{curr_year}-02-10 09:10:00 stop"),
format!("{curr_year}-02-15 09:01:00 +foo"),
format!("{curr_year}-02-15 09:01:00 stop"),
]);
#[rustfmt::skip]
let_assert!(Ok(config) = Config::new(
".timelog",
Some(tmpdir.path().to_str().expect("tempdir failed to return string")),
Some("vim"),
None,
None
));
let arch = Archiver::new(&config);
assert!(Ok(None) == arch.archive());
}
#[test]
fn test_archive_this_year_with_comments() {
let curr_year = Date::today().year();
let (tmpdir, _filename) = make_timelog(&[
String::from("# Initial comment"),
format!("{curr_year}-02-10 09:01:00 +foo"),
format!("{curr_year}-02-10 09:10:00 stop"),
String::from("# Middle comment"),
format!("{curr_year}-02-15 09:01:00 +foo"),
format!("{curr_year}-02-15 09:01:00 stop"),
String::from("# Trailing comment"),
]);
#[rustfmt::skip]
let_assert!(Ok(config) = Config::new(
".timelog",
Some(tmpdir.path().to_str().expect("tempdir failed to return string")),
Some("vim"),
None,
None
));
let arch = Archiver::new(&config);
assert!(Ok(None) == arch.archive());
}
#[test]
fn test_archive_prev_year() {
let prev_year = Date::today().year() - 1;
let (tmpdir, filename) = make_timelog(&[
format!("{prev_year}-02-10 09:01:00 +foo"),
format!("{prev_year}-02-10 09:10:00 stop"),
format!("{prev_year}-02-15 09:01:00 +foo"),
format!("{prev_year}-02-15 09:01:00 stop"),
]);
let_assert!(Ok(expected) = std::fs::read_to_string(&filename));
#[rustfmt::skip]
let_assert!(Ok(config) = Config::new(
".timelog",
Some(tmpdir.path().to_str().expect("tempdir failed to return string")),
Some("vim"),
None,
None
));
let arch = Archiver::new(&config);
let_assert!(Ok(Some(actual)) = arch.archive());
assert!(actual == prev_year);
let archive_file = arch.archive_filepath(prev_year);
let_assert!(Ok(actual) = std::fs::read_to_string(&archive_file));
assert!(actual == expected);
let_assert!(Ok(metadata) = std::fs::metadata(&filename));
assert!(metadata.is_file());
assert!(metadata.len() == 0u64);
}
#[test]
fn test_archive_split_years() {
let curr_year = Date::today().year();
let prev_year = curr_year - 1;
let prev_year_lines = [
format!("{prev_year}-12-10 19:01:00 +foo"),
format!("{prev_year}-12-10 19:10:00 stop"),
format!("{prev_year}-12-15 19:01:00 +foo"),
format!("{prev_year}-12-15 19:01:00 stop"),
];
let curr_year_lines = [
format!("{curr_year}-02-10 09:01:00 +foo"),
format!("{curr_year}-02-10 09:10:00 stop"),
format!("{curr_year}-02-15 09:01:00 +foo"),
format!("{curr_year}-02-15 09:01:00 stop"),
];
let mut expected = curr_year_lines.join("\n");
expected.push('\n');
let lines: Vec<String> = prev_year_lines
.iter()
.chain(curr_year_lines.iter())
.cloned()
.collect();
let (tmpdir, filename) = make_timelog(&lines);
#[rustfmt::skip]
let_assert!(Ok(config) = Config::new(
".timelog",
Some(tmpdir.path().to_str().expect("tempdir failed to return string")),
Some("vim"),
None,
None
));
let arch = Archiver::new(&config);
let_assert!(Ok(Some(actual)) = arch.archive());
assert!(actual == prev_year);
let_assert!(Ok(actual) = std::fs::read_to_string(&filename));
assert!(actual == expected);
let mut expected = prev_year_lines.join("\n");
expected.push('\n');
let archive_file = arch.archive_filepath(prev_year);
let_assert!(Ok(actual) = std::fs::read_to_string(&archive_file));
assert!(actual == expected);
}
#[test]
fn test_archive_split_years_with_comments() {
let curr_year = Date::today().year();
let prev_year = curr_year - 1;
let prev_year_lines = [
String::from("# Initial commment"),
format!("{prev_year}-12-10 19:01:00 +foo"),
format!("{prev_year}-12-10 19:10:00 stop"),
format!("{prev_year}-12-15 19:01:00 +foo"),
format!("{prev_year}-12-15 19:01:00 stop"),
];
let curr_year_lines = [
String::from("# Breaking commment"),
format!("{curr_year}-02-10 09:01:00 +foo"),
format!("{curr_year}-02-10 09:10:00 stop"),
format!("{curr_year}-02-15 09:01:00 +foo"),
format!("{curr_year}-02-15 09:01:00 stop"),
String::from("# Trailing commment"),
];
let mut expected = curr_year_lines.join("\n");
expected.push('\n');
let lines: Vec<String> = prev_year_lines
.iter()
.chain(curr_year_lines.iter())
.cloned()
.collect();
let (tmpdir, filename) = make_timelog(&lines);
#[rustfmt::skip]
let_assert!(Ok(config) = Config::new(
".timelog",
Some(tmpdir.path().to_str().expect("tempdir failed to return string")),
Some("vim"),
None,
None
));
let arch = Archiver::new(&config);
let_assert!(Ok(Some(actual)) = arch.archive());
assert!(actual == prev_year);
let_assert!(Ok(actual) = std::fs::read_to_string(&filename));
assert!(actual == expected);
let mut expected = prev_year_lines.join("\n");
expected.push('\n');
let archive_file = arch.archive_filepath(prev_year);
let_assert!(Ok(actual) = std::fs::read_to_string(&archive_file));
assert!(actual == expected);
}
#[test]
fn test_archive_split_years_task_crosses() {
let curr_year = Date::today().year();
let prev_year = curr_year - 1;
let prev_year_lines = [
format!("{prev_year}-12-10 19:01:00 +foo"),
format!("{prev_year}-12-10 19:10:00 stop"),
format!("{prev_year}-12-15 19:01:00 +foo"),
format!("{prev_year}-12-15 19:01:00 stop"),
format!("{prev_year}-12-31 23:01:00 +bar"),
];
let curr_year_lines = [
format!("{curr_year}-01-01 00:10:00 stop"),
format!("{curr_year}-02-10 09:01:00 +foo"),
format!("{curr_year}-02-10 09:10:00 stop"),
format!("{curr_year}-02-15 09:01:00 +foo"),
format!("{curr_year}-02-15 09:01:00 stop"),
];
let mut expected = once(&format!("{curr_year}-01-01 00:00:00 +bar"))
.chain(curr_year_lines.iter())
.cloned()
.collect::<Vec<String>>()
.join("\n");
expected.push('\n');
let lines: Vec<String> = prev_year_lines
.iter()
.chain(curr_year_lines.iter())
.cloned()
.collect();
let (tmpdir, filename) = make_timelog(&lines);
#[rustfmt::skip]
let_assert!(Ok(config) = Config::new(
".timelog",
Some(tmpdir.path().to_str().expect("tempdir failed to return string")),
Some("vim"),
None,
None
));
let arch = Archiver::new(&config);
let_assert!(Ok(Some(actual)) = arch.archive());
assert!(actual == prev_year);
let_assert!(Ok(actual) = std::fs::read_to_string(&filename));
assert!(actual == expected);
let mut expected = prev_year_lines
.iter()
.chain(once(&format!("{curr_year}-01-01 00:00:00 stop")))
.cloned()
.collect::<Vec<String>>()
.join("\n");
expected.push('\n');
let archive_file = arch.archive_filepath(prev_year);
let_assert!(Ok(actual) = std::fs::read_to_string(&archive_file));
assert!(actual == expected);
}
#[test]
fn test_archive_split_years_task_crosses_with_comments() {
let curr_year = Date::today().year();
let prev_year = curr_year - 1;
let prev_year_lines = [
String::from("# Initial comment"),
format!("{prev_year}-12-10 19:01:00 +foo"),
format!("{prev_year}-12-10 19:10:00 stop"),
format!("{prev_year}-12-15 19:01:00 +foo"),
format!("{prev_year}-12-15 19:01:00 stop"),
format!("{prev_year}-12-31 23:01:00 +bar"),
];
let curr_year_lines = [
String::from("# Split comment"),
format!("{curr_year}-01-01 00:10:00 stop"),
format!("{curr_year}-02-10 09:01:00 +foo"),
format!("{curr_year}-02-10 09:10:00 stop"),
format!("{curr_year}-02-15 09:01:00 +foo"),
format!("{curr_year}-02-15 09:01:00 stop"),
String::from("# Trailing comment"),
];
let mut expected = once(&format!("{curr_year}-01-01 00:00:00 +bar"))
.chain(curr_year_lines.iter())
.cloned()
.collect::<Vec<String>>()
.join("\n");
expected.push('\n');
let lines: Vec<String> = prev_year_lines
.iter()
.chain(curr_year_lines.iter())
.cloned()
.collect();
let (tmpdir, filename) = make_timelog(&lines);
#[rustfmt::skip]
let_assert!(Ok(config) = Config::new(
".timelog",
Some(tmpdir.path().to_str().expect("tempdir failed to return string")),
Some("vim"),
None,
None
));
let arch = Archiver::new(&config);
let_assert!(Ok(Some(actual)) = arch.archive());
assert!(actual == prev_year);
let_assert!(Ok(actual) = std::fs::read_to_string(&filename));
assert!(actual == expected);
let mut expected = prev_year_lines
.iter()
.chain(once(&format!("{curr_year}-01-01 00:00:00 stop")))
.cloned()
.collect::<Vec<String>>()
.join("\n");
expected.push('\n');
let archive_file = arch.archive_filepath(prev_year);
let_assert!(Ok(actual) = std::fs::read_to_string(&archive_file));
assert!(actual == expected);
}
}