diff options
-rw-r--r-- | apps/cassiopeia/src/data.rs | 159 | ||||
-rw-r--r-- | apps/cassiopeia/src/date.rs | 26 | ||||
-rw-r--r-- | apps/cassiopeia/src/format/ir.rs | 63 | ||||
-rw-r--r-- | apps/cassiopeia/src/format/mod.rs | 32 | ||||
-rw-r--r-- | apps/cassiopeia/src/format/parser.rs | 3 | ||||
-rw-r--r-- | apps/cassiopeia/src/lib.rs | 30 | ||||
-rw-r--r-- | apps/cassiopeia/src/time.rs | 29 |
7 files changed, 289 insertions, 53 deletions
diff --git a/apps/cassiopeia/src/data.rs b/apps/cassiopeia/src/data.rs index 7442d699be88..47bb6e35b6e5 100644 --- a/apps/cassiopeia/src/data.rs +++ b/apps/cassiopeia/src/data.rs @@ -4,14 +4,15 @@ //! used to generate new files, and perform various lookups and //! analysis tasks. -use crate::format::LineCfg; -use chrono::{DateTime, Duration, Local, FixedOffset as Offset, NaiveDate}; +use crate::{ + format::{IrItem, IrType, MakeIr}, + Date, Time, +}; +use chrono::{DateTime, Duration, FixedOffset as Offset, Local, NaiveDate}; use std::collections::BTreeMap; #[derive(Debug, Default)] pub struct TimeFile { - /// Raw line buffers to echo back into the file - lines: Vec<LineCfg>, /// A parsed header structure header: BTreeMap<String, String>, /// A parsed session structure @@ -21,70 +22,158 @@ pub struct TimeFile { } impl TimeFile { - pub(crate) fn append(mut self, line: LineCfg) -> Self { - let lo = self.lines.len(); + pub(crate) fn append(&mut self, line: IrItem) { match line { - LineCfg::Header(ref header) => self.header = header.clone(), - LineCfg::Start(Some(time)) => self.sessions.push(Session::start(time, lo)), - LineCfg::Stop(Some(time)) => self.get_last_session().stop(time, lo), - LineCfg::Invoice(Some(date)) => self.invoices.push(Invoice::new(date, lo)), + IrItem { + tt: IrType::Header(ref header), + .. + } => self.header = header.clone(), + IrItem { + tt: IrType::Start(time), + lo, + } => self.sessions.push(Session::start(time.into())), + IrItem { + tt: IrType::Stop(time), + lo, + } => self.get_last_session().unwrap().stop(time.into()), + IrItem { + tt: IrType::Invoice(date), + lo, + } => self.invoices.push(Invoice::new(date.into())), _ => {} } + } - self.lines.push(line); - self + fn get_last_session(&mut self) -> Option<&mut Session> { + self.sessions.last_mut() } - fn get_last_session(&mut self) -> &mut Session { - self.sessions.last_mut().unwrap() + fn get_last_invoice(&mut self) -> Option<&mut Invoice> { + self.invoices.last_mut() } /// Start a new session (optionally 15-minute rounded) - pub fn start(&mut self, round: bool) -> Option<()> { - let now = Local::now(); - + /// + /// 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<()> { + 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 + _ => {} + } + + self.invoices.push(Invoice::new(today)); Some(()) } } -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct Session { - start: DateTime<Offset>, - stop: Option<DateTime<Offset>>, - /// Track the lines this session took place in - lines: (usize, usize), + start: Time, + stop: Option<Time>, } impl Session { /// Create a new session with a start time - fn start(start: DateTime<Offset>, line: usize) -> Self { - Self { - start, - stop: None, - lines: (line, 0), - } + fn start(start: Time) -> Self { + Self { start, stop: None } } /// Finalise a session with a stop time - fn stop(&mut self, stop: DateTime<Offset>, line: usize) { + fn stop(&mut self, stop: Time) { self.stop = Some(stop); - self.lines.1 = line; + } + + /// Check whether this session was already finished + pub fn finished(&self) -> bool { + self.stop.is_some() } /// Get the length of the session, if it was already finished pub fn length(&self) -> Option<Duration> { - self.stop.map(|stop| stop - self.start) + self.stop.as_ref().map(|stop| stop - &self.start) + } +} + +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)] pub struct Invoice { - date: NaiveDate, - line: usize, + date: Date, } impl Invoice { - fn new(date: NaiveDate, line: usize) -> Self { - Self { date, line } + fn new(date: Date) -> Self { + Self { date } + } +} + +impl MakeIr for Invoice { + fn make_ir(&self) -> IrType { + IrType::Invoice(self.date.clone()) } } diff --git a/apps/cassiopeia/src/date.rs b/apps/cassiopeia/src/date.rs new file mode 100644 index 000000000000..00d8a700859a --- /dev/null +++ b/apps/cassiopeia/src/date.rs @@ -0,0 +1,26 @@ +use crate::Time; +use chrono::{FixedOffset as Offset, NaiveDate}; + +/// A convenienc wrapper around [chrono::NaiveDate](chrono::NaiveDate) +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct Date { + inner: NaiveDate, +} + +impl Date { + pub fn today() -> Self { + Self::from(Time::now().date()) + } + + pub(crate) fn from(d: chrono::Date<Offset>) -> Self { + Self { + inner: d.naive_local(), + } + } +} + +impl From<NaiveDate> for Date { + fn from(inner: NaiveDate) -> Self { + Self { inner } + } +} diff --git a/apps/cassiopeia/src/format/ir.rs b/apps/cassiopeia/src/format/ir.rs new file mode 100644 index 000000000000..32922ec079e7 --- /dev/null +++ b/apps/cassiopeia/src/format/ir.rs @@ -0,0 +1,63 @@ +use crate::{format::LineCfg, Date, Time, TimeFile}; +use std::collections::BTreeMap; + +/// A set of IR parsed items that makes up a whole cass file +pub(crate) type IrStream = Vec<IrItem>; + +/// Intermediate representation for parsing and generating files +/// +/// The CASS IR is largely based on the output of the parser's +/// [`LineCfg`](crate::format::LineCfg), but with concrete types used +/// in the data layer (namely [`Date`][date] and [`Time`][time]), +/// while also keeping track of the line numbers to allow idempotent +/// file changes. +/// +/// Something not yet implemented is comment pass-through (this needs +/// to happen in the parser first), but will likely be implemented in +/// a future version. +/// +/// [date]: crate::Date +/// [time]: crate::Time +#[derive(Debug, Clone)] +pub(crate) struct IrItem { + pub(crate) tt: IrType, + pub(crate) lo: usize, +} + +/// Disambiguate between different IR line types with their payload +#[derive(Debug, Clone)] +pub(crate) enum IrType { + /// A line with parsed header information + Header(BTreeMap<String, String>), + /// Start a session at a given timestapm + Start(Time), + /// Stop a session at a given timestamp + Stop(Time), + /// Invoice a block of previous work + Invoice(Date), + /// An item that gets ignored + Ignore, +} + +/// Generate a stream of IR items from the raw parser output +pub(crate) fn generate_ir(buf: impl Iterator<Item = LineCfg>) -> IrStream { + buf.enumerate().fold(vec![], |mut buf, (lo, item)| { + #[cfg_attr(rustfmt, rustfmt_skip)] + buf.push(match item { + LineCfg::Header(map) => IrItem { tt: IrType::Header(map), lo }, + LineCfg::Start(Some(time)) => IrItem { tt: IrType::Start(time.into()), lo }, + LineCfg::Stop(Some(time)) => IrItem { tt: IrType::Stop(time.into()), lo }, + LineCfg::Invoice(Some(date)) => IrItem { tt: IrType::Invoice(date.into()), lo }, + LineCfg::Ignore => IrItem { tt: IrType::Ignore, lo }, + _ => IrItem { tt: IrType::Ignore, lo }, + }); + + buf + }) +} + + +pub(crate) trait MakeIr { + /// Make a new IR line from an object + fn make_ir(&self) -> IrType; +} diff --git a/apps/cassiopeia/src/format/mod.rs b/apps/cassiopeia/src/format/mod.rs index e55658d515ff..814c08656dbe 100644 --- a/apps/cassiopeia/src/format/mod.rs +++ b/apps/cassiopeia/src/format/mod.rs @@ -1,17 +1,33 @@ //! cassiopeia file format +mod ir; mod lexer; mod parser; +pub(crate) use ir::{IrItem, IrStream, IrType, MakeIr}; pub(crate) use lexer::{LineLexer, LineToken, Token}; pub(crate) use parser::LineCfg; use crate::TimeFile; use std::{fs::File, io::Read}; +#[derive(Default)] +pub struct ParseOutput { + pub(crate) ir: IrStream, + pub(crate) tf: TimeFile, +} + +impl ParseOutput { + fn append(mut self, ir: IrItem) -> Self { + self.tf.append(ir.clone()); + self.ir.push(ir); + self + } +} + /// Load a file from disk and parse it into a /// [`TimeFile`](crate::TimeFile) -pub fn load_file(path: &str) -> Option<TimeFile> { +pub fn load_file(path: &str) -> Option<ParseOutput> { let mut f = File::open(path).ok()?; let mut content = String::new(); f.read_to_string(&mut content).ok()?; @@ -19,11 +35,13 @@ pub fn load_file(path: &str) -> Option<TimeFile> { let mut lines: Vec<String> = content.split("\n").map(|l| l.to_owned()).collect(); Some( - lines - .iter_mut() - .map(|line| lexer::lex(line)) - .map(|lex| parser::parse(lex)) - .filter(|line| line.valid()) - .fold(TimeFile::default(), |file, line| file.append(line)), + ir::generate_ir( + lines + .iter_mut() + .map(|line| lexer::lex(line)) + .map(|lex| parser::parse(lex)), + ) + .into_iter() + .fold(ParseOutput::default(), |output, ir| output.append(ir)), ) } diff --git a/apps/cassiopeia/src/format/parser.rs b/apps/cassiopeia/src/format/parser.rs index 430fee6332a7..d1f0bcdebc68 100644 --- a/apps/cassiopeia/src/format/parser.rs +++ b/apps/cassiopeia/src/format/parser.rs @@ -53,6 +53,9 @@ pub(crate) fn parse<'l>(lex: LineLexer<'l>) -> LineCfg { (Stop(_), LineToken { tt: T::Date, slice }) => Stop(parse_datetime(slice)), (Invoice(_), LineToken { tt: T::Date, slice }) => Invoice(parse_date(slice)), + // Pass empty lines through, + (Empty, _) => Empty, + // Ignore everything else (which will be filtered) _ => Ignore, }) diff --git a/apps/cassiopeia/src/lib.rs b/apps/cassiopeia/src/lib.rs index 1ea50fb8bc17..d4ebf5355bb9 100644 --- a/apps/cassiopeia/src/lib.rs +++ b/apps/cassiopeia/src/lib.rs @@ -9,28 +9,32 @@ //! https://git.spacekookie.de/kookienomicon/tree/apps/cassiopeia mod data; +mod date; mod format; pub mod meta; mod time; -pub use data::{Session, TimeFile}; +pub use date::Date; pub use format::load_file; +pub use time::Time; -/// A state handler for all cass interactions +use data::{Invoice, Session, TimeFile}; +use format::{ir, IrStream, ParseOutput}; + +/// A state handler and primary API for all cass interactions +/// /// -/// This could be a stateless API, but I like being able to refer to -/// fields that need to be saved for later here. This API wraps -/// around [`TimeFile`](crate::TimeFile), so that you don't have to! ✨ pub struct Cassiopeia { path: String, tf: TimeFile, + ir: IrStream, } impl Cassiopeia { /// Load a cass file from disk, parsing it into a [`TimeFile`](crate::TimeFile) pub fn load(path: &str) -> Option<Self> { let path = path.to_owned(); - load_file(path.as_str()).map(|tf| Self { path, tf }) + load_file(path.as_str()).map(|ParseOutput { tf, ir }| Self { path, tf, ir }) } /// Store the modified time file back to disk @@ -40,11 +44,15 @@ impl Cassiopeia { /// Start a new work session (with optional 15 minute rounding) pub fn start(&mut self, round: bool) -> Option<()> { - self.tf.start(round) + self.tf.start(round)?; + + Some(()) } /// Stop the existing work session (with optional 15 minute rounding) pub fn stop(&mut self, round: bool) -> Option<()> { + self.tf.stop(round)?; + Some(()) } @@ -96,7 +104,7 @@ impl<'cass> Invoicer<'cass> { } } - /// S + /// Enable the invoice generation feature pub fn generate(self) -> Self { Self { generate: true, @@ -120,10 +128,12 @@ impl<'cass> Invoicer<'cass> { pub fn run(self) -> Option<()> { if self.generate { - eprintln!("Integration with invoice(1) is currently not implemented. Sorry :()"); + eprintln!("Integration with invoice(1) is currently not implemented. Sorry :("); return None; } - None + self.tf.tf.invoice()?; + + Some(()) } } diff --git a/apps/cassiopeia/src/time.rs b/apps/cassiopeia/src/time.rs index 56804713033b..4ac4ce7db900 100644 --- a/apps/cassiopeia/src/time.rs +++ b/apps/cassiopeia/src/time.rs @@ -1,14 +1,32 @@ +use crate::Date; use chrono::{ - DateTime, FixedOffset as Offset, Local, NaiveDateTime, NaiveTime, TimeZone, Timelike, Utc, + DateTime, Duration, FixedOffset as Offset, Local, NaiveDateTime, NaiveTime, TimeZone, Timelike, + Utc, }; +use std::{cmp::Ordering, ops::Sub}; /// A convenience wrapper around [DateTime][t] with fixed timezone /// /// [t]: chrono::DateTime +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] pub struct Time { inner: DateTime<Offset>, } +impl From<DateTime<Offset>> for Time { + fn from(inner: DateTime<Offset>) -> Self { + Self { inner } + } +} + +impl<'t> Sub for &'t Time { + type Output = Duration; + + fn sub(self, o: &'t Time) -> Self::Output { + self.inner - o.inner + } +} + impl Time { /// Get the current local time and pin it to a fixed Tz offset pub fn now() -> Self { @@ -18,6 +36,15 @@ impl Time { } } + pub(crate) fn date(&self) -> chrono::Date<Offset> { + self.inner.date() + } + + /// Check if a time stamp happened _after_ a date + pub fn after(&self, date: &Date) -> bool { + &Date::from(self.date()) > date + } + #[cfg(test)] pub(crate) fn fixed(hour: u32, min: u32, sec: u32) -> Self { Self { |