use std::str::FromStr; use std::io::Write; use serde::{Serialize, Deserialize}; use itertools::Itertools; use chrono::{DateTime, Utc, Offset, TimeZone}; use crate::error; use crate::models::Entry; #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Formatter { Text, Custom(String), } impl Formatter { /// Prints the given entries to the specified output device. /// /// the current time is given as the `now` argument and the offset from UTC /// to the local timezone is given in `offset` to prevent this function from /// using a secondary effect to retrieve the time and conver dates. This /// also makes it easier to test. pub fn print_formatted(&self, entries: Vec, out: &mut W, now: DateTime, offset: O) -> error::Result<()> { match &self { Formatter::Text => self.print_formatted_text(entries, out, now, offset)?, Formatter::Custom(name) => { } } Ok(()) } /// Print in the default text format. Assume entries are sorted by sheet and /// then by start fn print_formatted_text(&self, entries: Vec, out: &mut W, now: DateTime, offset: O) -> error::Result<()> { let grouped_entries = entries.into_iter().group_by(|e| e.sheet.to_string()); // Build a timezone based on the offset given to this function. This // will later be used to properly group the entries by date in the local // timezone. let fixed_offset = offset.fix(); for (key, group) in grouped_entries.into_iter() { writeln!(out, "Timesheet: {}", key)?; writeln!(out, " Day Start End Duration Notes")?; let by_day = group.group_by(|e| fixed_offset.from_utc_datetime(&e.start.naive_utc()).date()); for (date, entries) in by_day.into_iter() { for (i,entry) in entries.into_iter().enumerate() { let start = fixed_offset.from_utc_datetime(&entry.start.naive_utc()).time().to_string(); let end = entry.end.map(|t| fixed_offset.from_utc_datetime(&t.naive_utc()).time().to_string()).unwrap_or(" ".into()); let duration = entry.end.unwrap_or(now) - entry.start; let duration = format!("{}:{:02}:{:02}", duration.num_hours(), duration.num_minutes() % 60, duration.num_seconds() & 60); if i == 0 { let date = date.format("%a %b %d, %Y").to_string(); writeln!( out, " {date} {start} - {end} {duration} {note}", date=date, start=start, end=end, duration=duration, note=entry.note, )?; } else { writeln!( out, " {start} - {end} {duration} {note}", start=start, end=end, duration=duration, note=entry.note, )?; } } } } Ok(()) } } impl FromStr for Formatter { type Err = error::Error; fn from_str(s: &str) -> error::Result { let lower = s.to_lowercase(); Ok(match &*lower { "text" => Formatter::Text, custom_format => Formatter::Custom(custom_format.into()), }) } } #[cfg(test)] mod tests { use super::*; use std::fmt; use pretty_assertions::assert_eq; use chrono::TimeZone; #[derive(PartialEq, Eq)] pub struct PrettyString<'a>(pub &'a str); /// Make diff to display string as multi-line string impl<'a> fmt::Debug for PrettyString<'a> { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { f.write_str(self.0) } } #[test] fn test_text_output() { let formatter = Formatter::Text; let mut output = Vec::new(); let entries = vec![ Entry::new_sample(1, Utc.ymd(2008, 10, 3).and_hms(12, 0, 0), Some(Utc.ymd(2008, 10, 3).and_hms(14, 0, 0))), Entry::new_sample(2, Utc.ymd(2008, 10, 3).and_hms(12, 0, 0), Some(Utc.ymd(2008, 10, 3).and_hms(14, 0, 0))), Entry::new_sample(3, Utc.ymd(2008, 10, 5).and_hms(16, 0, 0), Some(Utc.ymd(2008, 10, 3).and_hms(18, 0, 0))), Entry::new_sample(4, Utc.ymd(2008, 10, 5).and_hms(18, 0, 0), None), ]; let now = Utc.ymd(2008, 10, 5).and_hms(20, 0, 0); let offset = Utc; formatter.print_formatted(entries, &mut output, now, offset).unwrap(); assert_eq!(PrettyString(&String::from_utf8_lossy(&output)), PrettyString("Timesheet: default Day Start End Duration Notes Fri Oct 03, 2008 12:00:00 - 14:00:00 2:00:00 entry 1 16:00:00 - 18:00:00 2:00:00 entry 2 4:00:00 Sun Oct 05, 2008 16:00:00 - 18:00:00 2:00:00 entry 3 18:00:00 - 2:00:00 entry 4 4:00:00 ----------------------------------------------------------- Total 8:00:00 ")); } }