parent
afd8a74e43
commit
5502c6d320
@ -0,0 +1,29 @@ |
|||||||
|
//! Typed time file for cassiopeia
|
||||||
|
//!
|
||||||
|
//! This data gets generated by the `format` module, and can later be
|
||||||
|
//! used to generate new files, and perform various lookups and
|
||||||
|
//! analysis tasks.
|
||||||
|
|
||||||
|
use crate::format::LineCfg; |
||||||
|
use chrono::{Date, DateTime, FixedOffset as Offset}; |
||||||
|
use std::collections::BTreeMap; |
||||||
|
|
||||||
|
#[derive(Default)] |
||||||
|
pub struct TimeFile { |
||||||
|
header: BTreeMap<String, String>, |
||||||
|
sessions: Vec<Session>, |
||||||
|
invoices: Vec<Date<Offset>>, |
||||||
|
} |
||||||
|
|
||||||
|
impl TimeFile { |
||||||
|
pub(crate) fn append(self, line: LineCfg) -> Self { |
||||||
|
println!("{:?}", line); |
||||||
|
|
||||||
|
self |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
pub struct Session { |
||||||
|
start: DateTime<Offset>, |
||||||
|
stop: DateTime<Offset>, |
||||||
|
} |
@ -1,145 +0,0 @@ |
|||||||
//! Parse the cassiopeia file format
|
|
||||||
//!
|
|
||||||
//! Each file is associated with a single project. This way there is
|
|
||||||
//! no need to associate session enries with multiple customers and
|
|
||||||
//! projcets. Currently there's also no way to cross-relate sessions
|
|
||||||
//! between projects or clients, although the metadata in the header
|
|
||||||
//! is available to do so in the future
|
|
||||||
//!
|
|
||||||
//! ## Structure
|
|
||||||
//!
|
|
||||||
//! `cassiopeia` files should use the `.cass` extension, although this
|
|
||||||
//! implementation is not opinionated on that.
|
|
||||||
//!
|
|
||||||
//! A line starting with `;` is a comment and can be ignored. A line
|
|
||||||
//! can have a comment anywhere, which means that everything after it
|
|
||||||
//! gets ignored. There are no block comments.
|
|
||||||
//!
|
|
||||||
//! A regular statements has two parts: a key, and a value. Available
|
|
||||||
//! keys are:
|
|
||||||
//!
|
|
||||||
//! - HEADER
|
|
||||||
//! - START
|
|
||||||
//! - STOP
|
|
||||||
//! - FINISH
|
|
||||||
//!
|
|
||||||
//! A file has to have at least one `HEADER` key, containing a certain
|
|
||||||
//! number of fields to be considered valid. The required number of
|
|
||||||
//! fields may vary between versions.
|
|
||||||
//!
|
|
||||||
//! ### HEADER
|
|
||||||
//!
|
|
||||||
//! `cassiopeia` in princpile only needs a single value to parse a
|
|
||||||
//! file, which is `version`. It is however recommended to add
|
|
||||||
//! additional metadata to allow future processing into clients and
|
|
||||||
//! cross-referencing projects. Importantly: header keys that are not
|
|
||||||
//! expected will be ignored.
|
|
||||||
//!
|
|
||||||
//! The general header format is a comma-separated list with a key
|
|
||||||
//! value pair, separated by an equals sign. You can use spaces in
|
|
||||||
//! both keys and values without having to escape them or use special
|
|
||||||
//! quotes. Leading and trailing spaces will be removed.
|
|
||||||
//!
|
|
||||||
//! ```
|
|
||||||
//! HEADER version=0.0.0,location=Berlin
|
|
||||||
//! HEADER work schedule=mon tue wed
|
|
||||||
//! ```
|
|
||||||
//!
|
|
||||||
//! When re-writing the file format, known/ accepted keys should go
|
|
||||||
//! first. All other unknown keys will be printed alphabetically at
|
|
||||||
//! the end. This way it's possible for an outdated implementation to
|
|
||||||
//! pass through unknown keys, or users to add their own keys.
|
|
||||||
|
|
||||||
use chrono::{DateTime, Utc}; |
|
||||||
use std::{fs::File, io::Read, path::Path}; |
|
||||||
|
|
||||||
/// A cassiopeia file that has been successfully parsed
|
|
||||||
pub struct TimeFile { |
|
||||||
path: PathBuf, |
|
||||||
content: Vec<Statement>, |
|
||||||
} |
|
||||||
|
|
||||||
impl TimeFile { |
|
||||||
/// Open an existing `.cass` file on disk. Panics!
|
|
||||||
pub fn open(p: impl Into<Path>) -> Self { |
|
||||||
let mut f = File::open(p).unwrap(); |
|
||||||
let mut cont = String::new(); |
|
||||||
f.read_to_string(&mut cont).unwrap(); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/// A statement in a `.cass` line
|
|
||||||
///
|
|
||||||
/// While the whole file get's re-written on every run to update
|
|
||||||
/// version numbers and header values, the structure of the file is
|
|
||||||
/// preserved.
|
|
||||||
pub enum Statement { |
|
||||||
/// A blank line
|
|
||||||
Blank, |
|
||||||
/// A comment line that is echo-ed back out
|
|
||||||
Comment(String), |
|
||||||
/// Header value
|
|
||||||
Header(Vec<HeaderVal>), |
|
||||||
/// A session start value
|
|
||||||
Start(DateTime<Utc>), |
|
||||||
/// A session stop value
|
|
||||||
Stop(DateTime<Utc>), |
|
||||||
/// A project finish value
|
|
||||||
Finish(DateTime<Utc>), |
|
||||||
} |
|
||||||
|
|
||||||
/// A set of header value
|
|
||||||
pub struct HeaderVal { |
|
||||||
/// Header key
|
|
||||||
key: String, |
|
||||||
/// Header value
|
|
||||||
val: String, |
|
||||||
} |
|
||||||
|
|
||||||
impl HeaderVal { |
|
||||||
fn new<S: Into<String>>(key: S, val: S) -> Self { |
|
||||||
Self { |
|
||||||
key: key.into(), |
|
||||||
val: val.into(), |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/// Test if a header value is known to this implementation
|
|
||||||
fn known(&self) -> bool { |
|
||||||
match self.key { |
|
||||||
"version" => true, |
|
||||||
_ => false, |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/// A builder for cass files
|
|
||||||
#[cfg(tests)] |
|
||||||
struct FileBuilder { |
|
||||||
acc: Vec<Statement>, |
|
||||||
} |
|
||||||
|
|
||||||
impl FileBuilder { |
|
||||||
fn new() -> Self { |
|
||||||
Self { acc: vec![] } |
|
||||||
} |
|
||||||
|
|
||||||
fn header(mut self, data: Vec<(&str, &str)>) -> Self { |
|
||||||
self.acc.push(Statement::Header( |
|
||||||
data.into_iter() |
|
||||||
.map(|(key, val)| HeaderVal::new(key, val)) |
|
||||||
.collect(), |
|
||||||
)); |
|
||||||
|
|
||||||
self |
|
||||||
} |
|
||||||
|
|
||||||
fn build(self) -> String { |
|
||||||
format!(";; This file was generated by cassiopeia (reference)\n{}", self.acc.into_iter().map(|s| s.render()).collect::<Vec<_>().join("\n")) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
#[test] |
|
||||||
fn empty_file() { |
|
||||||
let fb = FileBuilder::new().header(vec![("version", "0.3.0"), ("project", "testing")]); |
|
||||||
} |
|
@ -1,3 +1,25 @@ |
|||||||
//! cassiopeia file format handling
|
//! cassiopeia file format
|
||||||
|
|
||||||
pub(crate) mod lexer; |
mod lexer; |
||||||
|
mod parser; |
||||||
|
|
||||||
|
pub(crate) use lexer::{LineLexer, LineToken, Token}; |
||||||
|
pub(crate) use parser::LineCfg; |
||||||
|
|
||||||
|
use crate::TimeFile; |
||||||
|
use std::{fs::File, io::Read}; |
||||||
|
|
||||||
|
pub(crate) fn load_file(path: &str) { |
||||||
|
let mut f = File::open(path).unwrap(); |
||||||
|
let mut content = String::new(); |
||||||
|
f.read_to_string(&mut content).unwrap(); |
||||||
|
|
||||||
|
let mut lines: Vec<String> = content.split("\n").map(|l| l.to_owned()).collect(); |
||||||
|
|
||||||
|
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)); |
||||||
|
} |
||||||
|
@ -0,0 +1,78 @@ |
|||||||
|
//! cassiopeia parser
|
||||||
|
//!
|
||||||
|
//! Takes a lexer's token stream as an input, and outputs a fully
|
||||||
|
//! parsed time file.
|
||||||
|
|
||||||
|
use crate::format::{LineLexer, LineToken, Token}; |
||||||
|
use chrono::{DateTime, FixedOffset as Offset}; |
||||||
|
use logos::Lexer; |
||||||
|
use std::collections::BTreeMap; |
||||||
|
use std::iter::Iterator; |
||||||
|
|
||||||
|
/// A type-parsed line in a time file
|
||||||
|
#[derive(Debug)] |
||||||
|
pub enum LineCfg { |
||||||
|
/// A header line with a set of keys and values
|
||||||
|
Header(BTreeMap<String, String>), |
||||||
|
/// A session start line with a date and time
|
||||||
|
Start(Option<DateTime<Offset>>), |
||||||
|
/// A session stop line with a date and time
|
||||||
|
Stop(Option<DateTime<Offset>>), |
||||||
|
/// An invoice line with a date
|
||||||
|
Invoice(Option<DateTime<Offset>>), |
||||||
|
/// An empty line
|
||||||
|
Empty, |
||||||
|
/// A temporary value that is invalid
|
||||||
|
#[doc(hidden)] |
||||||
|
Ignore, |
||||||
|
} |
||||||
|
|
||||||
|
impl LineCfg { |
||||||
|
pub(crate) fn valid(&self) -> bool { |
||||||
|
match self { |
||||||
|
LineCfg::Ignore => false, |
||||||
|
_ => true, |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
pub(crate) fn parse<'l>(lex: LineLexer<'l>) -> LineCfg { |
||||||
|
use LineCfg::*; |
||||||
|
use Token as T; |
||||||
|
|
||||||
|
#[cfg_attr(rustfmt, rustfmt_skip)] |
||||||
|
lex.get_all().into_iter().fold(Ignore, |cfg, tok| match (cfg, tok) { |
||||||
|
// If the first token is a comment, we ignore it
|
||||||
|
(Ignore, LineToken { tt: T::Comment, .. }, ) => Ignore, |
||||||
|
// If the first token is a keyword, we wait for more data
|
||||||
|
(Ignore, LineToken { tt: T::Header, .. }) => Header(Default::default()), |
||||||
|
(Ignore, LineToken { tt: T::Start, .. }) => Start(None), |
||||||
|
(Ignore, LineToken { tt: T::Stop, .. }) => Stop(None), |
||||||
|
(Ignore, LineToken { tt: T::Invoice, .. }) => Invoice(None), |
||||||
|
|
||||||
|
// If the first token _was_ a keyword, fill in the data
|
||||||
|
(Header(map), LineToken { tt: T::HeaderData, slice }) => Header(append_data(map, slice)), |
||||||
|
(Start(_), LineToken { tt: T::Date, slice }) => Start(parse_date(slice)), |
||||||
|
(Stop(_), LineToken { tt: T::Date, slice }) => Stop(parse_date(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, |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
fn append_data(mut map: BTreeMap<String, String>, slice: &str) -> BTreeMap<String, String> { |
||||||
|
let split = slice.split("=").collect::<Vec<_>>(); |
||||||
|
map.insert(split[0].into(), split[1].into()); |
||||||
|
map |
||||||
|
} |
||||||
|
|
||||||
|
fn parse_date(slice: &str) -> Option<DateTime<Offset>> { |
||||||
|
Some( |
||||||
|
DateTime::parse_from_str(slice, "%Y-%m-%d %H:%M:%S%:z") |
||||||
|
.expect("Failed to parse date; invalid format!"), |
||||||
|
) |
||||||
|
} |
@ -1,5 +1,8 @@ |
|||||||
mod format; |
mod format; |
||||||
|
mod data; |
||||||
|
|
||||||
fn main() { |
pub use data::{TimeFile, Session}; |
||||||
|
|
||||||
|
fn main() { |
||||||
|
format::load_file("/home/projects/clients/nyantec-nix-workshops/time.cass") |
||||||
} |
} |
||||||
|
Loading…
Reference in new issue