aboutsummaryrefslogtreecommitdiff
path: root/apps/cassiopeia
diff options
context:
space:
mode:
Diffstat (limited to 'apps/cassiopeia')
-rw-r--r--apps/cassiopeia/src/data.rs159
-rw-r--r--apps/cassiopeia/src/date.rs26
-rw-r--r--apps/cassiopeia/src/format/ir.rs63
-rw-r--r--apps/cassiopeia/src/format/mod.rs32
-rw-r--r--apps/cassiopeia/src/format/parser.rs3
-rw-r--r--apps/cassiopeia/src/lib.rs30
-rw-r--r--apps/cassiopeia/src/time.rs29
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 {