use std::convert::TryFrom; use std::io::{BufRead, Write}; use clap::ArgMatches; use chrono::{DateTime, Utc, Duration}; use regex::Regex; use crate::database::Database; use crate::error::{Error, Result}; use crate::commands::{Command, Facts}; use crate::timeparse::{parse_time, parse_hours}; use crate::old::{entries_or_warning, time_or_warning}; use crate::formatters::text; use crate::regex::parse_regex; use crate::interactive::ask; use crate::io::Streams; use crate::models::Entry; #[derive(Default)] pub struct Args { start: Option>, end: Option>, grep: Option, hours: Option, fake: bool, sheet: Option, } impl<'a> TryFrom<&'a ArgMatches> for Args { type Error = Error; fn try_from(matches: &'a ArgMatches) -> Result { Ok(Args { start: matches.value_of("start").map(parse_time).transpose()?, end: matches.value_of("end").map(parse_time).transpose()?, grep: matches.value_of("grep").map(parse_regex).transpose()?, hours: matches.value_of("time").map(parse_hours).transpose()?, fake: matches.is_present("fake"), sheet: matches.value_of("sheet").map(|s| s.to_owned()), }) } } /// Modify the given Entry such that it only lasts the given `time`, and return /// the data needed to create a new entry with mostly the same attributes such /// that it accounts for the time substracted from the original. fn split_entry(entry: &mut Entry, time: Duration) -> (DateTime, Option>, Option, String) { let Entry { id: _, note, start, end, sheet, } = entry.clone(); let old_entry_end = start + time; let new_entry_start = old_entry_end; entry.end = Some(old_entry_end); (new_entry_start, end, note, sheet) } pub struct ArchiveCommand {} impl<'a> Command<'a> for ArchiveCommand { type Args = Args; fn handle(args: Args, streams: &mut Streams, facts: &Facts) -> Result<()> where D: Database, I: BufRead, O: Write, E: Write, { // Get all entries from the database that match the filter criteria // given from the command line: start time, end time and sheet. let entries = { let started_after = args.start.map(|s| time_or_warning(s, &streams.db)).transpose()?.map(|s| s.0); let started_before = args.end.map(|e| time_or_warning(e, &streams.db)).transpose()?.map(|e| e.0); let sheet = args.sheet.map(Ok).unwrap_or_else(|| streams.db.current_sheet())?; let mut entries = streams.db.entries_by_sheet(&sheet, started_after, started_before)?; // only archive those entries that are finished. entries.retain(|e| e.end.is_some()); if let Some(re) = args.grep { entries.retain(|e| re.is_match(&e.note.clone().unwrap_or_default())); } entries }; // If the user requested to archive entries by a total time then not all // entries will be archived, and instead just those oldest ones that // accumulate the given time will be. If the total time of the filtered // entries is more than the requested time the last one will be split // into two pieces. let (time, entries, new, extra_msg) = if let Some(hours) = args.hours { let requested_time = Duration::hours(hours as i64); // archive the maximum amount of consecutive entries whose // accumulated time is not bigger that `time`. let mut selected_entries = Vec::with_capacity(entries.len()); let mut accumulated_time = Duration::seconds(0); let mut new = None; for entry in entries { // Can unwrap because only entries with an end time get this far let timespan = entry.timespan().unwrap(); if accumulated_time < requested_time { if accumulated_time + timespan > requested_time { // should split the last entry let missing_time = requested_time - accumulated_time; let mut entry = entry; let parts = split_entry(&mut entry, missing_time); new.replace(parts); selected_entries.push(entry); accumulated_time = accumulated_time + missing_time; } else { // fits perfectly, just add it selected_entries.push(entry); accumulated_time = accumulated_time + timespan; } } else { // accumulated_time is equal or higher than requested_time, // no more entries are admitted break; } } let msg = if new.is_some() { String::from("\nAdditionally an entry will be split so that the total archived time is exact.") } else if accumulated_time < requested_time { let requested_time_str = text::format_hours(requested_time); let missing_time_str = text::format_hours(requested_time - accumulated_time); format!("\nThere were not enough entries to fulfill the requested time of {requested_time_str} (difference: {missing_time_str}).") } else { String::new() }; (text::format_hours(accumulated_time), selected_entries, new, msg) } else { (text::format_hours( entries .iter() .filter_map(|e| e.end.map(|end| end - e.start)) .fold(Duration::seconds(0), |acc, new| { acc + new }) ), entries, None, String::from("")) }; let n = entries.len(); let n_entries = if n == 1 { String::from("1 entry") } else { format!("{n} entries") }; if args.fake { let (entries, _) = entries_or_warning(entries, &streams.db)?; writeln!(streams.out, "These entries would be archived:\n")?; text::print_formatted( entries, &mut streams.out, facts, true, )?; } else if ask(streams, &format!("A total of {n_entries} accounting for {time} will be archived.{extra_msg}\nProceed?"))? { for entry in entries { streams.db.entry_update(entry.id, entry.start, entry.end, entry.note, &format!("_{}", entry.sheet))?; } if let Some((start, end, note, sheet)) = new { streams.db.entry_insert(start, end, note, &sheet)?; } } else { writeln!(streams.out, "Ok, they're still there")?; } Ok(()) } } #[cfg(test)] mod tests { use pretty_assertions::{assert_eq, assert_str_eq}; use chrono::TimeZone; use crate::models::Entry; use super::*; #[test] fn archive_archives() { let args: Args = Default::default(); let mut streams = Streams::fake(b"y\n"); let facts = Facts::new(); streams.db.set_current_sheet("foo").unwrap(); streams.db.entry_insert(facts.now - Duration::hours(3), Some(facts.now - Duration::minutes(90)), Some("first".into()), "foo").unwrap(); streams.db.entry_insert(facts.now - Duration::minutes(90), Some(facts.now), Some("second".into()), "foo").unwrap(); ArchiveCommand::handle(args, &mut streams, &facts).unwrap(); let remaining = streams.db.entries_by_sheet("foo", None, None).unwrap(); let archived = streams.db.entries_by_sheet("_foo", None, None).unwrap(); assert_eq!(String::from_utf8_lossy(&streams.out), "A total of 2 entries accounting for 3h will be archived.\nProceed? [y/N] "); // First entry gets archived whole, second entry gets split in two: // - a 30 minute piece to complete the requested 2 hour span // - a 1 hour piece that remains unarchived assert_eq!(archived, vec![ Entry { id: 1, note: Some("first".into()), start: facts.now - Duration::hours(3), end: Some(facts.now - Duration::minutes(90)), sheet: "_foo".into(), }, Entry { id: 2, note: Some("second".into()), start: facts.now - Duration::minutes(90), end: Some(facts.now), sheet: "_foo".into(), }, ]); assert_eq!(remaining, vec![]); } #[test] fn no_running_entry_is_archived() { let args: Args = Default::default(); let mut streams = Streams::fake(b"y\n"); let facts = Facts::new(); streams.db.set_current_sheet("foo").unwrap(); streams.db.entry_insert(facts.now - Duration::hours(3), Some(facts.now - Duration::minutes(90)), Some("first".into()), "foo").unwrap(); streams.db.entry_insert(facts.now - Duration::minutes(90), None, Some("running".into()), "foo").unwrap(); ArchiveCommand::handle(args, &mut streams, &facts).unwrap(); let remaining = streams.db.entries_by_sheet("foo", None, None).unwrap(); let archived = streams.db.entries_by_sheet("_foo", None, None).unwrap(); assert_eq!(String::from_utf8_lossy(&streams.out), "A total of 1 entry accounting for 1h 30m will be archived.\nProceed? [y/N] "); // First entry gets archived whole, second entry gets split in two: // - a 30 minute piece to complete the requested 2 hour span // - a 1 hour piece that remains unarchived assert_eq!(archived, vec![ Entry { id: 1, note: Some("first".into()), start: facts.now - Duration::hours(3), end: Some(facts.now - Duration::minutes(90)), sheet: "_foo".into(), }, ]); assert_eq!(remaining, vec![ Entry { id: 2, note: Some("running".into()), start: facts.now - Duration::minutes(90), end: None, sheet: "foo".into(), }, ]); } #[test] fn entries_are_split_properly() { let mut old_entry = Entry { id: 1, start: Utc.with_ymd_and_hms(2022, 7, 29, 10, 0, 0).unwrap(), end: Some(Utc.with_ymd_and_hms(2022, 7, 29, 11, 0, 0).unwrap()), note: Some("an entry".to_string()), sheet: "foo".to_string(), }; assert_eq!(split_entry(&mut old_entry, Duration::minutes(25)), ( Utc.with_ymd_and_hms(2022, 7, 29, 10, 25, 0).unwrap(), Some(Utc.with_ymd_and_hms(2022, 7, 29, 11, 0, 0).unwrap()), Some("an entry".to_string()), "foo".to_string(), )); assert_eq!(old_entry, Entry { id: 1, start: Utc.with_ymd_and_hms(2022, 7, 29, 10, 0, 0).unwrap(), end: Some(Utc.with_ymd_and_hms(2022, 7, 29, 10, 25, 0).unwrap()), note: Some("an entry".to_string()), sheet: "foo".to_string(), }); } #[test] fn archive_by_hours() { let args = Args { hours: Some(2), ..Default::default() }; let mut streams = Streams::fake(b"y\n"); let facts = Facts::new(); streams.db.set_current_sheet("foo").unwrap(); streams.db.entry_insert(facts.now - Duration::hours(3), Some(facts.now - Duration::minutes(90)), Some("first".into()), "foo").unwrap(); streams.db.entry_insert(facts.now - Duration::minutes(90), Some(facts.now), Some("second".into()), "foo").unwrap(); ArchiveCommand::handle(args, &mut streams, &facts).unwrap(); let remaining = streams.db.entries_by_sheet("foo", None, None).unwrap(); let archived = streams.db.entries_by_sheet("_foo", None, None).unwrap(); assert_str_eq!(String::from_utf8_lossy(&streams.out), "A total of 2 entries accounting for 2h will be archived. Additionally an entry will be split so that the total archived time is exact. Proceed? [y/N] "); // First entry gets archived whole, second entry gets split in two: // - a 30 minute piece to complete the requested 2 hour span // - a 1 hour piece that remains unarchived assert_eq!(archived, vec![ Entry { id: 1, note: Some("first".into()), start: facts.now - Duration::hours(3), end: Some(facts.now - Duration::minutes(90)), sheet: "_foo".into(), }, Entry { id: 2, note: Some("second".into()), start: facts.now - Duration::minutes(90), end: Some(facts.now - Duration::hours(1)), sheet: "_foo".into(), }, ]); assert_eq!(remaining, vec![ Entry { id: 3, note: Some("second".into()), start: facts.now - Duration::hours(1), end: Some(facts.now), sheet: "foo".into(), }, ]); } #[test] fn not_enough_entries_to_archive_time() { let args = Args { hours: Some(2), ..Default::default() }; let mut streams = Streams::fake(b"y\n"); let facts = Facts::new(); streams.db.set_current_sheet("foo").unwrap(); streams.db.entry_insert(facts.now - Duration::hours(3), Some(facts.now - Duration::minutes(90)), Some("first".into()), "foo").unwrap(); ArchiveCommand::handle(args, &mut streams, &facts).unwrap(); let remaining = streams.db.entries_by_sheet("foo", None, None).unwrap(); let archived = streams.db.entries_by_sheet("_foo", None, None).unwrap(); assert_str_eq!(String::from_utf8_lossy(&streams.out), "A total of 1 entry accounting for 1h 30m will be archived. There were not enough entries to fulfill the requested time of 2h (difference: 30m). Proceed? [y/N] "); // First entry gets archived whole, second entry gets split in two: // - a 30 minute piece to complete the requested 2 hour span // - a 1 hour piece that remains unarchived assert_eq!(archived, vec![ Entry { id: 1, note: Some("first".into()), start: facts.now - Duration::hours(3), end: Some(facts.now - Duration::minutes(90)), sheet: "_foo".into(), }, ]); assert_eq!(remaining, vec![]); } #[test] fn fake_and_split_work_well_together() { std::env::set_var("TZ", "CST+6"); let args = Args { hours: Some(2), fake: true, ..Default::default() }; let mut streams = Streams::fake(b"y\n"); let facts = Facts::new(); let time_a = Utc.with_ymd_and_hms(2022, 8, 1, 10, 0, 0).unwrap(); let time_b = time_a + Duration::minutes(90); let time_d = time_a + Duration::hours(3); streams.db.set_current_sheet("foo").unwrap(); streams.db.entry_insert(time_a, Some(time_b), Some("first".into()), "foo").unwrap(); streams.db.entry_insert(time_b, Some(time_d), Some("second".into()), "foo").unwrap(); ArchiveCommand::handle(args, &mut streams, &facts).unwrap(); let remaining = streams.db.entries_by_sheet("foo", None, None).unwrap(); let archived = streams.db.entries_by_sheet("_foo", None, None).unwrap(); assert_str_eq!(String::from_utf8_lossy(&streams.out), "These entries would be archived: Timesheet: foo ID Day Start End Duration Notes 1 Mon Aug 01, 2022 04:00:00 - 05:30:00 1:30:00 first 2 05:30:00 - 06:00:00 0:30:00 second 2:00:00 ------------------------------------------------------------ Total 2:00:00 "); // First entry gets archived whole, second entry gets split in two: // - a 30 minute piece to complete the requested 2 hour span // - a 1 hour piece that remains unarchived assert_eq!(archived, vec![]); assert_eq!(remaining, vec![ Entry { id: 1, note: Some("first".into()), start: time_a, end: Some(time_b), sheet: "foo".into(), }, Entry { id: 2, note: Some("second".into()), start: time_b, end: Some(time_d), sheet: "foo".into(), }, ]); } }