aboutsummaryrefslogtreecommitdiff
path: root/apps/cassiopeia/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/cassiopeia/src')
-rw-r--r--apps/cassiopeia/src/bin/cass.rs8
-rw-r--r--apps/cassiopeia/src/data.rs142
-rw-r--r--apps/cassiopeia/src/error.rs72
-rw-r--r--apps/cassiopeia/src/lib.rs19
-rw-r--r--apps/cassiopeia/src/meta.rs3
-rw-r--r--apps/cassiopeia/src/time.rs9
-rw-r--r--apps/cassiopeia/src/timeline.rs132
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))
+ }
+}