use std::convert::TryFrom; use std::io::{BufRead, Write}; use clap::ArgMatches; use chrono::{DateTime, Utc}; use crate::database::{Database, DBVersion}; use crate::error::{Error, Result}; use crate::commands::{Facts, Command}; use crate::timeparse::parse_time; use crate::old::{entries_or_warning, time_or_warning, warn_if_needed}; use crate::formatters::text; use crate::editor; use crate::io::Streams; #[derive(Default)] pub struct Args { id: Option, start: Option>, end: Option>, append: bool, r#move: Option, note: Option, } impl Args { /// returns true only if no argument was passed other that possibly --id. /// This means that an edit was requested without specifying what to edit, /// therefore let's edit the note because why not fn none_given(&self) -> bool { !(self.start.is_some() || self.end.is_some() || self.r#move.is_some() || self.note.is_some()) } } impl<'a> TryFrom<&'a ArgMatches<'a>> for Args { type Error = Error; fn try_from(matches: &'a ArgMatches) -> Result { Ok(Args { id: matches.value_of("id").map(|i| i.parse().unwrap()), start: matches.value_of("start").map(parse_time).transpose()?, end: matches.value_of("end").map(parse_time).transpose()?, append: matches.is_present("append"), r#move: matches.value_of("move").map(|s| s.into()), note: matches.value_of("note").map(|s| s.into()), }) } } pub struct EditCommand {} impl<'a> Command<'a> for EditCommand { type Args = Args; fn handle(args: Args, streams: &mut Streams, facts: &Facts) -> Result<()> where D: Database, I: BufRead, O: Write, E: Write, { let current_sheet = streams.db.current_sheet()?; let needs_warning = streams.db.version()? == DBVersion::Timetrap; let entry = if let Some(id) = args.id { if let Some(entry) = streams.db.entry_by_id(id)? { entry } else { writeln!(streams.out, "Entry with id \"{}\" does not exist. Perhaps it was deleted", id)?; return Ok(()); } } else if let Some(entry) = streams.db.last_entry_of_sheet(¤t_sheet)? { entry } else { writeln!(streams.out, "No entries to edit in sheet \"{}\".", current_sheet)?; return Ok(()); }; // normalize the entry in case it comes from an old database let entry = entries_or_warning(vec![entry], &streams.db)?.0.into_iter().next().unwrap(); // try really hard to obtain the note let note = if let Some(new_note) = args.note { // either from the command's args if args.append { Some(entry.note.unwrap_or_else(|| "".to_owned()) + &facts.config.append_notes_delimiter + &new_note) } else { Some(new_note) } } else if args.none_given() { // or from the editor if no arguments where given Some(editor::get_string(facts.config.note_editor.as_deref(), entry.note)?) } else { // or just use watever was previously there is the user is editing // something else entry.note }; streams.db.entry_update( entry.id, time_or_warning(args.start.unwrap_or(entry.start), &streams.db)?.0, args.end.or(entry.end).map(|e| time_or_warning(e, &streams.db)).transpose()?.map(|o| o.0), note, &args.r#move.unwrap_or(entry.sheet), )?; let updated_entry = entries_or_warning(vec![streams.db.entry_by_id(entry.id)?.unwrap()], &streams.db)?.0; text::print_formatted( updated_entry, &mut streams.out, facts, true, )?; warn_if_needed(&mut streams.err, needs_warning, &facts.env)?; Ok(()) } } #[cfg(test)] mod tests { use std::fs; use pretty_assertions::assert_eq; use chrono::{Duration, TimeZone}; use tempfile::NamedTempFile; use crate::database::SqliteDatabase; use crate::config::Config; use super::*; #[test] fn edit_last_note() { std::env::set_var("TZ", "CST+6"); let mut streams = Streams::fake(b""); let args = Args { note: Some("new note".into()), ..Default::default() }; let now = Utc.ymd(2021, 8, 3).and_hms(20, 29, 0); let an_hour_ago = now - Duration::hours(1); let facts = Facts::new().with_now(now); streams.db.entry_insert(an_hour_ago, Some(now), Some("a note".into()), "default").unwrap(); streams.db.entry_insert(an_hour_ago, Some(now), Some("should be left intact".into()), "sheet1").unwrap(); EditCommand::handle(args, &mut streams, &facts).unwrap(); let entry2 = streams.db.entry_by_id(2).unwrap().unwrap(); assert_eq!(entry2.note, Some("should be left intact".into())); assert_eq!(&String::from_utf8_lossy(&streams.out), "Timesheet: default ID Day Start End Duration Notes 1 Tue Aug 03, 2021 13:29:00 - 14:29:00 1:00:00 new note 1:00:00 -------------------------------------------------------------- Total 1:00:00 "); assert_eq!(&String::from_utf8_lossy(&streams.err), ""); } #[test] fn edit_with_id() { std::env::set_var("TZ", "CST+6"); let mut streams = Streams::fake(b""); let args = Args { id: Some(2), note: Some("new note".into()), ..Default::default() }; let now = Utc.ymd(2021, 8, 3).and_hms(20, 29, 0); let an_hour_ago = now - Duration::hours(1); let facts = Facts::new().with_now(now); streams.db.entry_insert(an_hour_ago, Some(now), Some("a note".into()), "default").unwrap(); streams.db.entry_insert(an_hour_ago, Some(now), Some("should be left intact".into()), "sheet1").unwrap(); EditCommand::handle(args, &mut streams, &facts).unwrap(); assert_eq!(&String::from_utf8_lossy(&streams.out), "Timesheet: sheet1 ID Day Start End Duration Notes 2 Tue Aug 03, 2021 13:29:00 - 14:29:00 1:00:00 new note 1:00:00 -------------------------------------------------------------- Total 1:00:00 "); assert_eq!(&String::from_utf8_lossy(&streams.err), ""); } #[test] fn edit_start() { std::env::set_var("TZ", "CST+6"); let mut streams = Streams::fake(b""); let now = Utc.ymd(2021, 8, 3).and_hms(20, 29, 0); let args = Args { start: Some(now - Duration::minutes(30)), ..Default::default() }; let an_hour_ago = now - Duration::hours(1); let facts = Facts::new().with_now(now); streams.db.entry_insert(an_hour_ago, Some(now), Some("a note".into()), "default").unwrap(); EditCommand::handle(args, &mut streams, &facts).unwrap(); assert_eq!(&String::from_utf8_lossy(&streams.out), "Timesheet: default ID Day Start End Duration Notes 1 Tue Aug 03, 2021 13:59:00 - 14:29:00 0:30:00 a note 0:30:00 ------------------------------------------------------------ Total 0:30:00 "); assert_eq!(&String::from_utf8_lossy(&streams.err), ""); } #[test] fn edit_end() { std::env::set_var("TZ", "CST+6"); let mut streams = Streams::fake(b""); let now = Utc.ymd(2021, 8, 3).and_hms(20, 29, 0); let args = Args { end: Some(now - Duration::minutes(30)), ..Default::default() }; let an_hour_ago = now - Duration::hours(1); let facts = Facts::new().with_now(now); streams.db.entry_insert(an_hour_ago, Some(now), Some("a note".into()), "default").unwrap(); EditCommand::handle(args, &mut streams, &facts).unwrap(); assert_eq!(&String::from_utf8_lossy(&streams.out), "Timesheet: default ID Day Start End Duration Notes 1 Tue Aug 03, 2021 13:29:00 - 13:59:00 0:30:00 a note 0:30:00 ------------------------------------------------------------ Total 0:30:00 "); assert_eq!(&String::from_utf8_lossy(&streams.err), ""); } #[test] fn edit_append() { std::env::set_var("TZ", "CST+6"); let mut streams = Streams::fake(b""); let args = Args { note: Some("new note".into()), append: true, ..Default::default() }; let now = Utc.ymd(2021, 8, 3).and_hms(20, 29, 0); let an_hour_ago = now - Duration::hours(1); let facts = Facts::new().with_now(now); streams.db.entry_insert(an_hour_ago, Some(now), Some("a note".into()), "default").unwrap(); EditCommand::handle(args, &mut streams, &facts).unwrap(); assert_eq!(&String::from_utf8_lossy(&streams.out), "Timesheet: default ID Day Start End Duration Notes 1 Tue Aug 03, 2021 13:29:00 - 14:29:00 1:00:00 a note new note 1:00:00 --------------------------------------------------------------------- Total 1:00:00 "); assert_eq!(&String::from_utf8_lossy(&streams.err), ""); } #[test] fn edit_move() { std::env::set_var("TZ", "CST+6"); let mut streams = Streams::fake(b""); let now = Utc.ymd(2021, 8, 3).and_hms(20, 29, 0); let args = Args { r#move: Some("new sheet".to_owned()), ..Default::default() }; let an_hour_ago = now - Duration::hours(1); let facts = Facts::new().with_now(now); streams.db.entry_insert(an_hour_ago, Some(now), Some("a note".into()), "default").unwrap(); EditCommand::handle(args, &mut streams, &facts).unwrap(); assert_eq!(&String::from_utf8_lossy(&streams.out), "Timesheet: new sheet ID Day Start End Duration Notes 1 Tue Aug 03, 2021 13:29:00 - 14:29:00 1:00:00 a note 1:00:00 ------------------------------------------------------------ Total 1:00:00 "); assert_eq!(&String::from_utf8_lossy(&streams.err), ""); } #[test] fn non_default_delimiter() { std::env::set_var("TZ", "CST+6"); let mut streams = Streams::fake(b""); let args = Args { note: Some("new note".into()), append: true, ..Default::default() }; let now = Utc.ymd(2021, 8, 3).and_hms(20, 29, 0); let an_hour_ago = now - Duration::hours(1); let facts = Facts::new().with_now(now).with_config(Config { append_notes_delimiter: ";".to_owned(), ..Default::default() }); streams.db.entry_insert(an_hour_ago, Some(now), Some("a note".into()), "default").unwrap(); EditCommand::handle(args, &mut streams, &facts).unwrap(); assert_eq!(&String::from_utf8_lossy(&streams.out), "Timesheet: default ID Day Start End Duration Notes 1 Tue Aug 03, 2021 13:29:00 - 14:29:00 1:00:00 a note;new note 1:00:00 --------------------------------------------------------------------- Total 1:00:00 "); assert_eq!(&String::from_utf8_lossy(&streams.err), ""); } fn copy_db(path: &str) -> NamedTempFile { let tmpfile = NamedTempFile::new().unwrap(); fs::copy(path, &tmpfile).unwrap(); tmpfile } #[test] fn warn_old() { std::env::set_var("TZ", "CST+6"); let database_file = copy_db("assets/test_old_db.db"); let mut streams = Streams::fake(b"").with_db( SqliteDatabase::from_path(&database_file).unwrap() ); let now = Utc.ymd(2021, 8, 3).and_hms(20, 29, 0); let new_end = Utc.ymd(2021, 6, 29).and_hms(14, 26, 52); let args = Args { end: Some(new_end), ..Default::default() }; let facts = Facts::new().with_now(now); EditCommand::handle(args, &mut streams, &facts).unwrap(); assert_eq!(&String::from_utf8_lossy(&streams.out), "Timesheet: default ID Day Start End Duration Notes 1 Tue Jun 29, 2021 06:26:49 - 08:26:52 2:00:02 lets do some rust 2:00:02 ----------------------------------------------------------------------- Total 2:00:02 "); assert_eq!( String::from_utf8_lossy(&streams.err), "[WARNING] You are using the old timetrap format, it is advised that you update your database using t migrate. To supress this warning set TIEMPO_SUPRESS_TIMETRAP_WARNING=1\n" ); std::mem::drop(streams.db); std::mem::drop(database_file); } }