diff options
-rw-r--r-- | apps/cassiopeia/src/bin/cass.rs | 8 | ||||
-rw-r--r-- | apps/cassiopeia/src/data.rs | 142 | ||||
-rw-r--r-- | apps/cassiopeia/src/error.rs | 72 | ||||
-rw-r--r-- | apps/cassiopeia/src/lib.rs | 19 | ||||
-rw-r--r-- | apps/cassiopeia/src/meta.rs | 3 | ||||
-rw-r--r-- | apps/cassiopeia/src/time.rs | 9 | ||||
-rw-r--r-- | apps/cassiopeia/src/timeline.rs | 132 |
7 files changed, 273 insertions, 112 deletions
diff --git a/apps/cassiopeia/src/bin/cass.rs b/apps/cassiopeia/src/bin/cass.rs index 90f84661ae0c..8bceddc911a4 100644 --- a/apps/cassiopeia/src/bin/cass.rs +++ b/apps/cassiopeia/src/bin/cass.rs @@ -64,6 +64,7 @@ If you want to report a bug, please do so on my mailing list: lists.sr.ht/~space .help(meta::ARG_CLIENT_DB_ABOUT), ) ) + .subcommand(SubCommand::with_name(meta::CMD_STAT).about(meta::CMD_STAT_ABOUT)) .get_matches(); let cass_file = cli.value_of(meta::ARG_FILE).unwrap(); @@ -119,6 +120,13 @@ If you want to report a bug, please do so on my mailing list: lists.sr.ht/~space std::process::exit(1); } }, + (meta::CMD_STAT, _) => match cass.stat() { + Some(s) => println!("{}", s), + None => { + eprintln!("Failed to collect time statistics..."); + std::process::exit(1); + } + }, (_, _) => todo!(), } } diff --git a/apps/cassiopeia/src/data.rs b/apps/cassiopeia/src/data.rs index 188c0255203d..3034d020b1e9 100644 --- a/apps/cassiopeia/src/data.rs +++ b/apps/cassiopeia/src/data.rs @@ -5,7 +5,9 @@ //! analysis tasks. use crate::{ + error::{ParseError, ParseResult, UserResult}, format::ir::{IrItem, IrType, MakeIr}, + timeline::{Entry, Timeline}, Date, Time, }; use chrono::{DateTime, Duration, FixedOffset as Offset, Local, NaiveDate}; @@ -14,116 +16,42 @@ use std::collections::BTreeMap; #[derive(Debug, Default)] pub struct TimeFile { /// A parsed header structure - header: BTreeMap<String, String>, - /// A parsed session structure - sessions: Vec<Session>, - /// A parsed invoice list - invoices: Vec<Invoice>, + pub(crate) header: BTreeMap<String, String>, + /// A parsed timeline of events + pub(crate) timeline: Timeline, } impl TimeFile { - pub(crate) fn append(&mut self, line: IrItem) { + /// Append entries to the timeline from the parsed IR + /// + /// Report any errors that occur back to the parser, that will + /// print a message to the user and terminate the program. + pub(crate) fn append(&mut self, line: IrItem) -> ParseResult<()> { match line { IrItem { tt: IrType::Header(ref header), .. - } => self.header = header.clone(), + } => Ok(header.iter().for_each(|(k, v)| { + self.header.insert(k.clone(), v.clone()); + })), IrItem { tt: IrType::Start(time), lo, - } => self.sessions.push(Session::start(time.into())), + } => Ok(self.timeline.start(time).map(|_| ())?), IrItem { tt: IrType::Stop(time), lo, - } => self.get_last_session().unwrap().stop(time.into()), + } => Ok(self.timeline.stop(time).map(|_| ())?), IrItem { tt: IrType::Invoice(date), lo, - } => self.invoices.push(Invoice::new(date.into())), - _ => {} - } - } - - fn get_last_session(&mut self) -> Option<&mut Session> { - self.sessions.last_mut() - } - - fn get_last_invoice(&mut self) -> Option<&mut Invoice> { - self.invoices.last_mut() - } - - /// Start a new session (optionally 15-minute rounded) - /// - /// This function returns the new session object that will have to - /// be turned into an IR line to be written back into the file - pub(crate) fn start(&mut self, round: bool) -> Option<Session> { - // Check if the last session was closed - match self.get_last_session() { - Some(s) if !s.finished() => return None, - _ => {} - } - - // Create a new time - let now = if round { - Time::now().round() - } else { - Time::now() - }; - - Some(Session::start(now)) - } - - /// Stop the last session that was started, returning a completed - /// session - pub(crate) fn stop(&mut self, round: bool) -> Option<Session> { - match self.get_last_session() { - Some(s) if s.finished() => return None, - None => return None, - _ => {} - } - - // Create a new time - let now = if round { - Time::now().round() - } else { - Time::now() - }; - - self.get_last_session().cloned().map(|mut s| { - s.stop(now); - s - }) - } - - /// Add a new invoice block to the time file - pub(crate) fn invoice(&mut self) -> Option<Invoice> { - let today = Date::today(); - - let last_sess = self.get_last_session().cloned(); - - match self.get_last_invoice() { - // Check if _today_ there has been already an invoice - Some(i) if i.date == today => return None, - - // Check if since the last invoice there has been at least - // _one_ terminated session. - Some(i) - if !last_sess - .map(|s| !s.stop.map(|s| s.after(&i.date)).unwrap_or(false)) - .unwrap_or(false) => - { - return None - } - - // Otherwise, we create an invoice - _ => {} + } => Ok(self.timeline.invoice(date).map(|_| ())?), + _ => Err(ParseError::Unknown), } - - Some(Invoice::new(today)) } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub struct Session { start: Time, stop: Option<Time>, @@ -131,12 +59,12 @@ pub struct Session { impl Session { /// Create a new session with a start time - fn start(start: Time) -> Self { + pub(crate) fn start(start: Time) -> Self { Self { start, stop: None } } /// Finalise a session with a stop time - fn stop(&mut self, stop: Time) { + pub(crate) fn stop(&mut self, stop: Time) { self.stop = Some(stop); } @@ -151,28 +79,30 @@ impl Session { } } -impl MakeIr for Session { - fn make_ir(&self) -> IrType { - match self.stop { - Some(ref time) => IrType::Stop(time.clone()), - None => IrType::Start(self.start.clone()), - } - } -} - -#[derive(Debug)] +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub struct Invoice { - date: Date, + pub(crate) date: Date, } impl Invoice { - fn new(date: Date) -> Self { + pub(crate) fn new(date: Date) -> Self { Self { date } } } -impl MakeIr for Invoice { +/// Changes to the timeline are encoded in a delta +pub(crate) enum Delta { + Start(Time), + Stop(Time), + Invoice(Date), +} + +impl MakeIr for Delta { fn make_ir(&self) -> IrType { - IrType::Invoice(self.date.clone()) + match self { + Self::Start(ref time) => IrType::Start(time.clone()), + Self::Stop(ref time) => IrType::Stop(time.clone()), + Self::Invoice(ref date) => IrType::Invoice(date.clone()), + } } } diff --git a/apps/cassiopeia/src/error.rs b/apps/cassiopeia/src/error.rs new file mode 100644 index 000000000000..31f5414c4f86 --- /dev/null +++ b/apps/cassiopeia/src/error.rs @@ -0,0 +1,72 @@ +//! A set of error types for cassiopeia + +use std::error::Error; +use std::fmt::{self, Display, Formatter}; + +/// User errors that can occur when using cassiopeia +/// +/// None of these errors are the fault of the program, but rather +/// fault of the user for giving invalid commands. They must never +/// make the program crash, but instead need to print human friendly +/// error messages. +#[derive(Debug)] +pub enum UserError { + /// Trying to start a session when one exists + ActiveSessionExists, + /// Trying to stop a session when none exists + NoActiveSession, + /// Trying to create a second invoice on the same day + SameDayInvoice, + /// No work was done since the last invoice + NoWorkInvoice, +} + +impl Display for UserError { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + write!(f, "You're doing it wrong!") + } +} + +impl Error for UserError {} + +pub type UserResult<T> = Result<T, UserError>; + +/// Errors that occur when parsing a file +/// +/// These errors can pre-maturely terminate the run of the program, +/// but must print a detailed error about what is wrong. Also, +/// because they are technically a superset of +/// [`UserError`](self::UserError), one of the variants is an embedded +/// user error. +#[derive(Debug)] +pub enum ParseError { + /// An embedded user error + /// + /// This error means that the structure of the parsed file is + /// wrong, with an invalid sequence of events expressed + User(UserError), + /// An invalid keyword was found + BadKeyword { line: usize, tokn: String }, + /// A bad timestamp was found + BadTimestamp { line: usize, tokn: String }, + /// A bad date was found + BadDate { line: usize, tokn: String }, + /// An unknown parse error occured + Unknown, +} + +impl Display for ParseError { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + write!(f, "The parsed file was bad :(") + } +} + +impl Error for ParseError {} + +pub type ParseResult<T> = Result<T, ParseError>; + +impl From<UserError> for ParseError { + fn from(user: UserError) -> Self { + ParseError::User(user) + } +} diff --git a/apps/cassiopeia/src/lib.rs b/apps/cassiopeia/src/lib.rs index e391c695237e..895110fe218d 100644 --- a/apps/cassiopeia/src/lib.rs +++ b/apps/cassiopeia/src/lib.rs @@ -10,9 +10,11 @@ mod data; mod date; +mod error; mod format; pub mod meta; mod time; +mod timeline; pub use date::Date; pub use time::Time; @@ -46,17 +48,17 @@ impl Cassiopeia { /// Start a new work session (with optional 15 minute rounding) pub fn start(&mut self, round: bool) -> Option<()> { - let s = self.tf.start(round)?; + let delta = self.tf.timeline.start(Time::rounded(round)).ok()?; clean_ir(&mut self.ir); - append_ir(&mut self.ir, s.make_ir()); + append_ir(&mut self.ir, delta.make_ir()); format::write_file(self.path.as_str(), &mut self.ir) } /// Stop the existing work session (with optional 15 minute rounding) pub fn stop(&mut self, round: bool) -> Option<()> { - let s = self.tf.stop(round)?; + let delta = self.tf.timeline.stop(Time::rounded(round)).ok()?; clean_ir(&mut self.ir); - append_ir(&mut self.ir, s.make_ir()); + append_ir(&mut self.ir, delta.make_ir()); format::write_file(self.path.as_str(), &mut self.ir) } @@ -70,6 +72,11 @@ impl Cassiopeia { clean_ir(&mut self.ir); format::write_file(self.path.as_str(), &mut self.ir) } + + /// Collect statistics on previous work sessions + pub fn stat(&self) -> Option<String> { + None + } } /// An invoice generator builder @@ -142,9 +149,9 @@ impl<'cass> Invoicer<'cass> { return None; } - let inv = self.tf.tf.invoice()?; + let delta = self.tf.tf.timeline.invoice(Date::today()).ok()?; clean_ir(&mut self.tf.ir); - append_ir(&mut self.tf.ir, inv.make_ir()); + append_ir(&mut self.tf.ir, delta.make_ir()); format::write_file(self.tf.path.as_str(), &mut self.tf.ir) } } diff --git a/apps/cassiopeia/src/meta.rs b/apps/cassiopeia/src/meta.rs index d29f8b9b30e0..ec71498986e3 100644 --- a/apps/cassiopeia/src/meta.rs +++ b/apps/cassiopeia/src/meta.rs @@ -41,3 +41,6 @@ pub const ARG_GEN_YAML_ABOUT: &'static str = pub const ARG_CLIENT_DB: &'static str = "CLIENT_DB"; pub const ARG_CLIENT_DB_ABOUT: &'static str = "Provide your client database file (.yml format) used by invoice(1)"; + +pub const CMD_STAT: &'static str = "stat"; +pub const CMD_STAT_ABOUT: &'static str = "Get statistics of previous work sessions"; diff --git a/apps/cassiopeia/src/time.rs b/apps/cassiopeia/src/time.rs index 0161742a0c92..a5147b93aaac 100644 --- a/apps/cassiopeia/src/time.rs +++ b/apps/cassiopeia/src/time.rs @@ -47,6 +47,15 @@ impl Time { } } + /// Get the time that might be rounded to the next 15 minutes + pub(crate) fn rounded(r: bool) -> Self { + if r { + Time::now().round() + } else { + Time::now() + } + } + pub(crate) fn date(&self) -> chrono::Date<Offset> { self.inner.date() } diff --git a/apps/cassiopeia/src/timeline.rs b/apps/cassiopeia/src/timeline.rs new file mode 100644 index 000000000000..70119b30a725 --- /dev/null +++ b/apps/cassiopeia/src/timeline.rs @@ -0,0 +1,132 @@ +use crate::{ + data::{Delta, Invoice, Session}, + error::{UserError, UserResult}, + Date, Time, +}; + +/// A timeline entry of sessions and invoices +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum Entry { + Session(Session), + Invoice(Invoice), +} + +impl From<Session> for Entry { + fn from(s: Session) -> Self { + Self::Session(s) + } +} + +impl From<Invoice> for Entry { + fn from(i: Invoice) -> Self { + Self::Invoice(i) + } +} + +/// A timeline of sessions and invoices, ordered chronologically +#[derive(Debug, Default, Clone)] +pub(crate) struct Timeline { + inner: Vec<Entry>, +} + +impl Timeline { + /// Take a set of sessions and invoices to sort into a timeline + pub(crate) fn build(s: Vec<Session>, i: Vec<Invoice>) -> Self { + let mut inner: Vec<_> = s.into_iter().map(|s| Entry::Session(s)).collect(); + inner.append(&mut i.into_iter().map(|i| Entry::Invoice(i)).collect()); + Self { inner } + } + + /// Utility function to get the last session in the timeline + fn last_session(&mut self) -> Option<&mut Session> { + self.inner + .iter_mut() + .find(|e| match e { + Entry::Session(_) => true, + _ => false, + }) + .map(|e| match e { + Entry::Session(ref mut s) => s, + _ => unreachable!(), + }) + } + + /// Utility function to get the last invoice in the timeline + fn last_invoice(&self) -> Option<&Invoice> { + self.inner + .iter() + .find(|e| match e { + Entry::Invoice(_) => true, + _ => false, + }) + .map(|e| match e { + Entry::Invoice(ref s) => s, + _ => unreachable!(), + }) + } + + /// Get a list of sessions that happened up to a certain invoice date + /// + /// **WARNING** If there is no invoice with the given date, this + /// function will return garbage data, so don't call it with + /// invoice dates that don't exist. + /// + /// Because: if the date passes other invoices on the way, the accumulator + /// will be discarded and a new count will be started. + pub(crate) fn session_iter(&self, date: &Date) -> Vec<&Session> { + self.inner + .iter() + .fold((false, vec![]), |(mut done, mut acc), entry| { + match (done, entry) { + // Put sessions into the accumulator + (false, Entry::Session(ref s)) => acc.push(s), + // When we reach the target invoice, terminate the iterator + (false, Entry::Invoice(ref i)) if &i.date == date => done = true, + // When we hit another invoice, empty accumulator + (false, Entry::Invoice(_)) => acc.clear(), + // When we are ever "done", skip all other entries + (true, _) => {} + } + + (done, acc) + }) + .1 + } + + /// Start a new session, if no active session is already in progress + pub(crate) fn start(&mut self, time: Time) -> UserResult<Delta> { + match self.last_session() { + Some(s) if !s.finished() => Err(UserError::ActiveSessionExists), + _ => Ok(()), + }?; + + self.inner.push(Session::start(time.clone()).into()); + Ok(Delta::Start(time)) + } + + /// Stop an ongoing session, if one exists + pub(crate) fn stop(&mut self, time: Time) -> UserResult<Delta> { + match self.last_session() { + Some(s) if s.finished() => Err(UserError::NoActiveSession), + _ => Ok(()), + }?; + + self.last_session().unwrap().stop(time.clone()); + Ok(Delta::Stop(time)) + } + + /// Create a new invoice on the given day + pub(crate) fn invoice(&mut self, date: Date) -> UserResult<Delta> { + match self.last_invoice() { + // If an invoice on the same day exists already + Some(i) if i.date == date => Err(UserError::SameDayInvoice), + // If there was no work since the last invoice + Some(ref i) if self.session_iter(&i.date).len() == 0 => Err(UserError::NoWorkInvoice), + // Otherwise everything is coolio + _ => Ok(()), + }?; + + self.inner.push(Invoice::new(date.clone()).into()); + Ok(Delta::Invoice(date)) + } +} |