diff --git a/Cargo.lock b/Cargo.lock index 4bcd05e..6cd956a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -55,6 +55,7 @@ checksum = "80094f509cf8b5ae86a4966a39b3ff66cd7e2a3e594accec3743ff3fabeab5b2" dependencies = [ "num-integer", "num-traits", + "serde", "time", ] diff --git a/Cargo.toml b/Cargo.toml index a35ea30..e72eee8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,11 +6,11 @@ authors = ["Julio Biason "] edition = "2018" [dependencies] -chrono = "0.4" +chrono = { version = "0.4", features = ["serde"] } clap = "2.33" +env_logger = "0.7" +log = "*" serde = "*" serde_derive = "*" toml = "0.5" uuid = { version = "0.8", features = ["v4"] } -log = "*" -env_logger = "0.7" diff --git a/src/args.rs b/src/args.rs index 63d1afc..f4efc77 100644 --- a/src/args.rs +++ b/src/args.rs @@ -25,7 +25,9 @@ use clap::Arg; use clap::ArgMatches; use clap::SubCommand; -use crate::date::{Date, DateError}; +use crate::date::Date; +use crate::date_errors::DateError; +use crate::datetime::DateTime; type Description = String; @@ -44,7 +46,7 @@ impl From for ParseError { pub enum Action { List, Add(Description, Date), - AddWithTime(Description, String, String), + AddWithTime(Description, DateTime), } pub fn parse() -> Result { @@ -92,8 +94,7 @@ fn parse_add(arguments: &ArgMatches) -> Result { if let Some(time) = arguments.value_of("time") { Ok(Action::AddWithTime( description.into(), - date.into(), - time.into(), + DateTime::try_from(date, time)?, )) } else { Ok(Action::Add(description.into(), Date::try_from(date)?)) diff --git a/src/date.rs b/src/date.rs index 7b7cdaf..fd07a73 100644 --- a/src/date.rs +++ b/src/date.rs @@ -16,31 +16,25 @@ along with this program. If not, see . */ +#![deny(missing_docs)] + // TODO trait TryFrom use chrono::prelude::*; use chrono::LocalResult; +use serde_derive::Deserialize; +use serde_derive::Serialize; -#[derive(Debug, Eq, PartialEq)] -pub enum DateError { - /// The date is not valid - InvalidDate, -} - -impl From for DateError { - fn from(_: std::num::ParseIntError) -> DateError { - DateError::InvalidDate - } -} +use crate::date_errors::DateError; -#[derive(Debug)] -pub struct Date(chrono::Date); +#[derive(Debug, Serialize, Deserialize, Copy, Clone)] +pub struct Date(chrono::NaiveDate); impl Date { /// Returns Ok with the Date or Error in an invalid Date. pub fn new(year: u16, month: u8, day: u8) -> Result { match Local.ymd_opt(year as i32, month as u32, day as u32) { - LocalResult::Single(x) => Ok(Date(x)), + LocalResult::Single(x) => Ok(Date(x.naive_local())), LocalResult::None => Err(DateError::InvalidDate), LocalResult::Ambiguous(_, _) => Err(DateError::InvalidDate), } @@ -70,84 +64,85 @@ impl Date { /// Number of days till the date; None if the date is in the past. pub fn eta(&self) -> Option { - let days = (self.0 - Local::today()).num_days(); + let days = (self.0 - Local::today().naive_local()).num_days(); if days >= 0 { Some(days as u16) } else { None } } -} -#[cfg(test)] -#[test] -pub fn invalid_date() { - assert!(Date::new(2020, 127, 26).is_err()); - assert!(Date::new(2020, 5, 127).is_err()); - assert!(Date::new(2020, 0, 0).is_err()); + pub fn timestamp(&self) -> i64 { + self.0.and_hms(23, 59, 59).timestamp() + } } #[cfg(test)] -#[test] -pub fn valid_date() { - assert!(Date::new(2025, 5, 26).is_ok()); -} +mod date_test { + use chrono::prelude::*; + use chrono::Duration; -#[cfg(test)] -#[test] -pub fn from_string() { - if let Ok(dt) = Date::try_from("2025-05-26") { - assert_eq!(dt.year(), 2025); - assert_eq!(dt.month(), 5); - assert_eq!(dt.day(), 26); - } else { - panic!("Can't parse 2025-05-26") + #[test] + pub fn invalid_date() { + assert!(super::Date::new(2020, 127, 26).is_err()); + assert!(super::Date::new(2020, 5, 127).is_err()); + assert!(super::Date::new(2020, 0, 0).is_err()); } -} -#[cfg(test)] -#[test] -pub fn failed_from_string() { - assert!(Date::try_from("2020-127-26").is_err()); -} + #[test] + pub fn valid_date() { + assert!(super::Date::new(2025, 5, 26).is_ok()); + } -#[cfg(test)] -#[test] -pub fn eta_tomorrow() { - use chrono::Duration; - let future = Local::today() + Duration::days(1); - let date = Date::new( - future.year() as u16, - future.month() as u8, - future.day() as u8, - ) - .unwrap(); - assert_eq!(date.eta(), Some(1)); -} + #[test] + pub fn from_string() { + if let Ok(dt) = super::Date::try_from("2025-05-26") { + assert_eq!(dt.year(), 2025); + assert_eq!(dt.month(), 5); + assert_eq!(dt.day(), 26); + } else { + panic!("Can't parse 2025-05-26") + } + } -#[cfg(test)] -#[test] -pub fn eta_today() { - let future = Local::today(); - let date = Date::new( - future.year() as u16, - future.month() as u8, - future.day() as u8, - ) - .unwrap(); - assert_eq!(date.eta(), Some(0)); -} + #[test] + pub fn failed_from_string() { + assert!(super::Date::try_from("2020-127-26").is_err()); + } -#[cfg(test)] -#[test] -pub fn eta_yesterday() { - use chrono::Duration; - let future = Local::today() - Duration::days(1); - let date = Date::new( - future.year() as u16, - future.month() as u8, - future.day() as u8, - ) - .unwrap(); - assert_eq!(date.eta(), None); + #[test] + pub fn eta_tomorrow() { + let future = Local::today() + Duration::days(1); + let date = super::Date::new( + future.year() as u16, + future.month() as u8, + future.day() as u8, + ) + .unwrap(); + assert_eq!(date.eta(), Some(1)); + } + + #[test] + pub fn eta_today() { + let future = Local::today(); + let date = super::Date::new( + future.year() as u16, + future.month() as u8, + future.day() as u8, + ) + .unwrap(); + assert_eq!(date.eta(), Some(0)); + } + + #[test] + pub fn eta_yesterday() { + let future = Local::today() - Duration::days(1); + let date = super::Date::new( + future.year() as u16, + future.month() as u8, + future.day() as u8, + ) + .unwrap(); + assert_eq!(date.eta(), None); + } } diff --git a/src/date_errors.rs b/src/date_errors.rs new file mode 100644 index 0000000..c3f5a92 --- /dev/null +++ b/src/date_errors.rs @@ -0,0 +1,29 @@ +/* + TU - Time's Up! + Copyright (C) 2020 Julio Biason + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +#[derive(Debug, Eq, PartialEq)] +pub enum DateError { + /// The date is not valid + InvalidDate, +} + +impl From for DateError { + fn from(_: std::num::ParseIntError) -> DateError { + DateError::InvalidDate + } +} diff --git a/src/datetime.rs b/src/datetime.rs new file mode 100644 index 0000000..908439b --- /dev/null +++ b/src/datetime.rs @@ -0,0 +1,151 @@ +/* + TU - Time's Up! + Copyright (C) 2020 Julio Biason + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +use chrono::prelude::*; +use chrono::LocalResult; +use serde_derive::Deserialize; +use serde_derive::Serialize; + +use crate::date_errors::DateError; + +#[derive(Debug, Serialize, Deserialize, Copy, Clone)] +pub struct DateTime(chrono::DateTime); + +impl DateTime { + pub fn new(year: u16, month: u8, day: u8, hour: u8, minute: u8) -> Result { + match Local.ymd_opt(year as i32, month as u32, day as u32) { + LocalResult::None => Err(DateError::InvalidDate), + LocalResult::Ambiguous(_, _) => Err(DateError::InvalidDate), + LocalResult::Single(x) => match x.and_hms_opt(hour as u32, minute as u32, 59) { + Some(x) => Ok(DateTime(x)), + None => Err(DateError::InvalidDate), + }, + } + } + + pub fn year(&self) -> u16 { + self.0.date().year() as u16 + } + + pub fn month(&self) -> u8 { + self.0.date().month() as u8 + } + + pub fn day(&self) -> u8 { + self.0.date().day() as u8 + } + + pub fn hour(&self) -> u8 { + self.0.time().hour() as u8 + } + + pub fn minute(&self) -> u8 { + self.0.time().minute() as u8 + } + + /// Try to convert a string to a Date. + pub fn try_from(date: &str, time: &str) -> Result { + let mut date_frags = date.split("-"); + let mut time_frags = time.split(":"); + + DateTime::new( + date_frags.next().ok_or(DateError::InvalidDate)?.parse()?, + date_frags.next().ok_or(DateError::InvalidDate)?.parse()?, + date_frags.next().ok_or(DateError::InvalidDate)?.parse()?, + time_frags.next().ok_or(DateError::InvalidDate)?.parse()?, + time_frags.next().ok_or(DateError::InvalidDate)?.parse()?, + ) + } + + pub fn eta(&self) -> Option<(u16, u16)> { + let diff = self.0 - Local::now(); + let days = diff.num_days(); + let hours = diff.num_hours() - (24 * diff.num_days()); + + if hours >= 0 { + Some((days as u16, hours as u16)) + } else { + None + } + } + + pub fn timestamp(&self) -> i64 { + self.0.timestamp() + } +} + +#[cfg(test)] +mod datetime_test { + use chrono::prelude::*; + use chrono::Duration; + + #[test] + pub fn invalid_date_time() { + assert!(super::DateTime::new(2020, 127, 2, 0, 0).is_err()); + assert!(super::DateTime::new(2020, 6, 127, 0, 0).is_err()); + assert!(super::DateTime::new(2020, 0, 0, 0, 0).is_err()); + assert!(super::DateTime::new(2020, 6, 2, 24, 0).is_err()); + assert!(super::DateTime::new(2020, 6, 2, 0, 60).is_err()); + } + + #[test] + pub fn valid_date_time() { + assert!(super::DateTime::new(2020, 6, 2, 20, 17).is_ok()); + } + + #[test] + pub fn from_string() { + if let Ok(x) = super::DateTime::try_from("2020-06-02", "20:18") { + assert_eq!(x.year(), 2020); + assert_eq!(x.month(), 6); + assert_eq!(x.day(), 2); + assert_eq!(x.hour(), 20); + assert_eq!(x.minute(), 18); + } else { + panic!("Can't parse 2020-06-02, 20:18"); + } + } + + #[test] + pub fn string_leading_zeroes() { + if let Ok(x) = super::DateTime::try_from("2020-09-09", "09:09") { + assert_eq!(x.year(), 2020); + assert_eq!(x.month(), 9); + assert_eq!(x.day(), 9); + assert_eq!(x.hour(), 9); + assert_eq!(x.minute(), 9); + } else { + panic!("Can't parse 2020-09-09, 09:09"); + } + } + + #[test] + pub fn eta_two_hours() { + let future = Local::now() + Duration::hours(2); + let datetime = super::DateTime::new( + future.year() as u16, + future.month() as u8, + future.day() as u8, + future.hour() as u8, + future.minute() as u8, + ); + + assert!(datetime.is_ok()); + assert_eq!(datetime.unwrap().eta(), Some((0, 2))); + } +} diff --git a/src/eventlist/event/eventtype.rs b/src/eventlist/event/eventtype.rs index b9c1faf..58f9e63 100644 --- a/src/eventlist/event/eventtype.rs +++ b/src/eventlist/event/eventtype.rs @@ -16,46 +16,24 @@ along with this program. If not, see . */ -use chrono::prelude::*; -use chrono::DateTime; use serde_derive::Deserialize; use serde_derive::Serialize; -use crate::eventlist::event::date; -use crate::eventlist::event::time; +use crate::date::Date; +use crate::datetime::DateTime; #[derive(Serialize, Deserialize, Debug)] #[serde(tag = "due", content = "datetime")] pub enum EventType { - AllDay(date::Date), - AtTime(date::Date, time::Time), + AllDay(Date), + AtTime(DateTime), } -impl From<&EventType> for DateTime { - fn from(origin: &EventType) -> Self { - match origin { - EventType::AllDay(d) => Local.ymd(d.year(), d.month(), d.day()).and_hms(23, 59, 59), - EventType::AtTime(d, t) => { - Local - .ymd(d.year(), d.month(), d.day()) - .and_hms(t.hour(), t.minute(), 59) - } - } - } -} - -impl From<&EventType> for String { - fn from(origin: &EventType) -> String { - match origin { - EventType::AllDay(d) => format!("{}{}{}0000", d.year(), d.month(), d.day()), - EventType::AtTime(d, t) => format!( - "{}{}{}{}{}", - d.year(), - d.month(), - d.day(), - t.hour(), - t.minute() - ), +impl EventType { + pub fn timestamp(&self) -> i64 { + match self { + EventType::AllDay(date) => date.timestamp(), + EventType::AtTime(datetime) => datetime.timestamp(), } } } diff --git a/src/eventlist/event/mod.rs b/src/eventlist/event/mod.rs index aeb779c..b416f2b 100644 --- a/src/eventlist/event/mod.rs +++ b/src/eventlist/event/mod.rs @@ -17,31 +17,25 @@ */ use std::cmp::Ordering; -use std::convert::From; -use chrono::prelude::*; -use chrono::DateTime; use serde_derive::Deserialize; use serde_derive::Serialize; use uuid::Uuid; -mod date; -mod eventtype; -mod time; +use crate::date::Date; +use crate::datetime::DateTime; +pub mod eventtype; -use date::Date as EventDate; use eventtype::EventType; -use time::Time as EventTime; - -static DATE_FORMAT: &str = "%Y-%m-%d %H:%M:%S"; #[derive(Serialize, Deserialize, Debug)] pub struct Event { pub id: String, pub description: String, - due: EventType, + pub due: EventType, } +/// TODO inject this fn uuid() -> String { let (id, _, _, _) = Uuid::new_v4().as_fields(); format!("{:x}", id) @@ -53,57 +47,21 @@ pub enum EventError { TooOld, } -impl From for EventError { - fn from(error: chrono::format::ParseError) -> EventError { - EventError::InvalidDate(error.to_string()) - } -} - impl Event { - pub fn new_on_date(description: &str, date: &str) -> Result { - let fake_datetime = format!("{} 00:00:00", date); - let dt = Local.datetime_from_str(&fake_datetime, DATE_FORMAT)?; - - if dt < Local::now() { - Err(EventError::TooOld) - } else { - Ok(Self { - id: uuid(), - description: description.into(), - due: EventType::AllDay(EventDate::from(&dt)), - }) - } + pub fn new_on_date(description: &str, date: &Date) -> Result { + Ok(Self { + id: uuid(), + description: description.into(), + due: EventType::AllDay(date.clone()), + }) } - pub fn new_on_date_time(description: &str, date: &str, time: &str) -> Result { - let fake_datetime = format!("{} {}:00", date, time); - let dt = Local.datetime_from_str(&fake_datetime, DATE_FORMAT)?; - - if dt < Local::now() { - Err(EventError::TooOld) - } else { - Ok(Self { - id: uuid(), - description: description.into(), - due: EventType::AtTime(EventDate::from(&dt), EventTime::from(&dt)), - }) - } - } - - pub fn eta(&self) -> Option { - let now = Local::now(); - let to: DateTime = (&self.due).into(); - let eta = to - now; - log::debug!("ETA for {}: {}", self.id, eta.num_minutes()); - - match self.due { - EventType::AllDay(_) if eta.num_minutes() > 0 => Some(format!("{}d", eta.num_days())), - EventType::AtTime(_, _) if eta.num_days() > 0 => { - Some(format!("{}d {}h", eta.num_days(), eta.num_hours())) - } - EventType::AtTime(_, _) if eta.num_hours() > 0 => Some(format!("{}h", eta.num_hours())), - _ => None, - } + pub fn new_on_date_time(description: &str, datetime: &DateTime) -> Result { + Ok(Self { + id: uuid(), + description: description.into(), + due: EventType::AtTime(datetime.clone()), + }) } } @@ -111,26 +69,18 @@ impl Eq for Event {} impl PartialEq for Event { fn eq(&self, other: &Self) -> bool { - let self_str = String::from(&self.due); - let other_str = String::from(&other.due); - self_str == other_str - } -} - -impl PartialOrd for Event { - fn partial_cmp(&self, other: &Self) -> Option { - let self_str = String::from(&self.due); - let other_str = String::from(&other.due); - - Some(self_str.cmp(&other_str)) + self.due.timestamp() == other.due.timestamp() } } impl Ord for Event { fn cmp(&self, other: &Self) -> Ordering { - let self_str = String::from(&self.due); - let other_str = String::from(&other.due); + self.due.timestamp().cmp(&other.due.timestamp()) + } +} - self_str.cmp(&other_str) +impl PartialOrd for Event { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.due.timestamp().cmp(&other.due.timestamp())) } } diff --git a/src/eventlist/eventlist.rs b/src/eventlist/eventlist.rs index 1028dbf..8644755 100644 --- a/src/eventlist/eventlist.rs +++ b/src/eventlist/eventlist.rs @@ -23,6 +23,8 @@ use serde_derive::Deserialize; use serde_derive::Serialize; use toml; +use crate::date::Date; +use crate::datetime::DateTime; use crate::eventlist::event::Event; use crate::eventlist::event::EventError; @@ -92,7 +94,7 @@ impl EventList { /// Load the event list, add an all day event, and save it back. /// Returns the ID of the new event. - pub fn add_event_with_date(description: &str, date: &str) -> Result { + pub fn add_event_with_date(description: &str, date: &Date) -> Result { let mut list = EventList::load(); let event = Event::new_on_date(description, date)?; let id = String::from(&event.id); @@ -105,11 +107,10 @@ impl EventList { /// Returns the ID of the new event. pub fn add_event_with_date_and_time( description: &str, - date: &str, - time: &str, + datetime: &DateTime, ) -> Result { let mut list = EventList::load(); - let event = Event::new_on_date_time(description, date, time).unwrap(); + let event = Event::new_on_date_time(description, datetime)?; let id = String::from(&event.id); list.push(event); list.save(); diff --git a/src/main.rs b/src/main.rs index 49ba293..20ff88c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,8 +20,11 @@ use log; mod args; mod date; +mod date_errors; +mod datetime; mod eventlist; +use crate::eventlist::event::eventtype::EventType; use crate::eventlist::eventlist::EventList; fn main() { @@ -32,12 +35,12 @@ fn main() { match command { args::Action::List => list(), args::Action::Add(description, date) => { - let event_id = EventList::add_event_with_date(&description, "").unwrap(); + let event_id = EventList::add_event_with_date(&description, &date).unwrap(); println!("Created new event {}", event_id); } - args::Action::AddWithTime(description, date, time) => { + args::Action::AddWithTime(description, datetime) => { let event_id = - EventList::add_event_with_date_and_time(&description, &date, &time).unwrap(); + EventList::add_event_with_date_and_time(&description, &datetime).unwrap(); println!("Created new event {}", event_id); } } @@ -50,14 +53,27 @@ fn list() { let event_list = EventList::load(); // TODO hide load from outside println!("{:^8} | {:^7} | {}", "ID", "ETA", "Description"); // TODO: EventList::iter() - for record in event_list.into_iter() { - let eta = if let Some(eta) = record.eta() { - // TODO: "1d" == Tomorrow; "0d" == Today - eta - } else { - "Over".into() + for event in event_list.into_iter() { + let eta = match event.due { + EventType::AllDay(date) => { + let eta = date.eta(); + match eta { + None => "Over".into(), + Some(0) => "Today".into(), + Some(1) => "Tomorrow".into(), + Some(x) => format!("{}d", x), + } + } + EventType::AtTime(datetime) => { + let eta = datetime.eta(); + match eta { + None => "Over".into(), + Some((0, hours)) => format!("{}h", hours), + Some((days, hours)) => format!("{}d {}h", days, hours), + } + } }; - println!("{:>8} | {:>7} | {}", record.id, eta, record.description); + println!("{:>8} | {:>7} | {}", event.id, eta, event.description); } }