You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

329 lines
9.5 KiB

extern crate chrono;
extern crate dirs;
extern crate rusqlite;
use std::collections::VecDeque;
use std::convert::From;
use std::error::Error;
use std::fmt::{Display, Formatter};
use std::path::PathBuf;
use chrono::prelude::*;
use chrono::Duration;
use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ToSql, ToSqlOutput, Value, ValueRef};
#[derive(Debug, Clone, PartialEq)]
enum State {
Work,
Pause,
Unknown,
}
impl From<String> for State {
fn from(val: String) -> Self {
match val.as_ref() {
"work" => State::Work,
"pause" => State::Pause,
_ => State::Unknown,
}
}
}
impl From<State> for String {
fn from(val: State) -> Self {
match val {
State::Work => "work",
State::Pause => "pause",
State::Unknown => "unknown",
}.to_string()
}
}
impl FromSql for State {
fn column_result(value: ValueRef) -> FromSqlResult<Self> {
if let ValueRef::Text(s) = value {
Ok(s.to_string().into())
} else {
Err(FromSqlError::InvalidType)
}
}
}
impl ToSql for State {
fn to_sql(&self) -> rusqlite::Result<ToSqlOutput> {
Ok(ToSqlOutput::Owned(Value::Text(self.clone().into())))
}
}
#[derive(Debug)]
struct Record {
timestamp: DateTime<Local>,
state: State,
}
impl Record {
fn new(state: State) -> Self {
Record {
timestamp: Local::now(),
state,
}
}
fn insert(self, conn: &rusqlite::Connection) -> Result<usize, TTError> {
conn.execute(
"INSERT INTO Timestamps VALUES (?1, ?2)",
&[&self.timestamp.naive_local(), &self.state],
).map_err(TTError::RecordInsertionError)
}
fn from_row(row: &rusqlite::Row) -> Result<Self, TTError> {
Ok(Record {
timestamp: row.get_checked(0).map_err(TTError::RecordMappingError)?,
state: row.get_checked(1).map_err(TTError::RecordMappingError)?,
})
}
}
#[derive(Debug)]
enum TTError {
DataDirNotFound,
UnknownCommand,
DatabaseConnectionError(rusqlite::Error),
DatabaseCreationError(rusqlite::Error),
RecordInsertionError(rusqlite::Error),
RecordSelectionError(rusqlite::Error),
RecordMappingError(rusqlite::Error),
}
impl Display for TTError {
fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
write!(f, "{:?}", self)
}
}
impl Error for TTError {
fn description(&self) -> &str {
match self {
TTError::DataDirNotFound => "unable to find data directory",
TTError::UnknownCommand => "unknown command",
TTError::DatabaseConnectionError(_) => "database connection failed",
TTError::DatabaseCreationError(_) => "failed to create database",
TTError::RecordInsertionError(_) => "failed to insert record database",
TTError::RecordSelectionError(_) => "failed to select today's records",
TTError::RecordMappingError(_) => "failed to map record from database row",
}
}
fn cause(&self) -> Option<&Error> {
match self {
TTError::DatabaseConnectionError(err) => Some(err),
TTError::DatabaseCreationError(err) => Some(err),
TTError::RecordInsertionError(err) => Some(err),
TTError::RecordSelectionError(err) => Some(err),
TTError::RecordMappingError(err) => Some(err),
_ => None,
}
}
}
fn database_path() -> Result<PathBuf, TTError> {
let mut data_dir: PathBuf = dirs::data_dir().ok_or(TTError::DataDirNotFound)?;
data_dir.push("tt.sqlite");
Ok(data_dir)
}
fn prepare_database(conn: &rusqlite::Connection) -> Result<usize, TTError> {
conn.execute(
"CREATE TABLE IF NOT EXISTS Timestamps (
timestamp DATETIME NOT NULL,
state TEXT NOT NULL
)",
&[],
).map_err(TTError::DatabaseCreationError)
}
fn todays_recs(conn: &rusqlite::Connection) -> Result<Vec<Record>, TTError> {
let today = Local::today();
let tomorrow = today.succ();
let mut stmt = conn.prepare(
"SELECT timestamp, state FROM Timestamps WHERE timestamp >= ? AND timestamp < ? ORDER BY timestamp ASC",
).map_err(TTError::RecordSelectionError)?;
let rec_iter = stmt
.query_map(
&[&today.naive_local(), &tomorrow.naive_local()],
Record::from_row,
).map_err(TTError::RecordMappingError)?;
let recs: Vec<Record> = rec_iter
.filter_map(Result::ok)
.filter_map(Result::ok)
.collect();
Ok(recs)
}
fn usage(program: &str) -> String {
format!(
"{} <cmd>
usage | help: shows this message
begin | stop: begin or stop work
pause: starts/stops a pause
show: prints today's work time",
program
)
}
fn print_usage(program: &str) {
println!("{}", usage(&program))
}
fn main() -> Result<(), TTError> {
let db_path = database_path()?;
let conn = rusqlite::Connection::open(db_path).map_err(TTError::DatabaseConnectionError)?;
prepare_database(&conn)?;
let mut args: VecDeque<String> = std::env::args().collect();
let program = args
.pop_front()
.expect("huh, the first argument is not the application path?");
if args.is_empty() {
print_usage(&program);
return Err(TTError::UnknownCommand);
}
if let Some(subcommand) = args.pop_front() {
match subcommand.as_ref() {
"help" | "usage" => print_usage(&program),
"show" => show(&conn)?,
"begin" | "stop" => record(&conn, State::Work)?,
"pause" => record(&conn, State::Pause)?,
_ => {
print_usage(&program);
return Err(TTError::UnknownCommand);
}
};
}
Ok(())
}
fn show(conn: &rusqlite::Connection) -> Result<(), TTError> {
println!("{}", format_duration(work_dur(&todays_recs(&conn)?)));
Ok(())
}
fn record(conn: &rusqlite::Connection, state: State) -> Result<(), TTError> {
Record::new(state).insert(conn)?;
Ok(())
}
fn format_duration(dur: Option<Duration>) -> String {
if let Some(dur) = dur {
format!("{:0>#2}h {:0>#2}m", dur.num_hours(), dur.num_minutes() % 60)
} else {
"".to_owned()
}
}
fn pause_dur(chunk: &[&Record]) -> Option<Duration> {
if chunk.len() < 2 {
return None;
}
let start = chunk.first()?;
let end = chunk.last()?;
Some(end.timestamp - start.timestamp)
}
fn fold_opt_dur(acc: Duration, dur: Option<Duration>) -> Duration {
if let Some(dur) = dur {
acc + dur
} else {
acc
}
}
fn work_dur(recs: &[Record]) -> Option<Duration> {
if recs.is_empty() {
return None;
}
let work_recs: Vec<&Record> = recs.iter().filter(|rec| rec.state == State::Work).collect();
if work_recs.len() < 2 {
return None;
}
let pause_recs: Vec<&Record> = recs
.iter()
.filter(|rec| rec.state == State::Pause)
.collect();
let start = work_recs.first()?;
let end = work_recs.last()?;
let pause_time = pause_recs
.chunks(2)
.map(pause_dur)
.fold(Duration::zero(), fold_opt_dur);
Some(end.timestamp - start.timestamp - pause_time)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn new_record(timestamp: &str, state: State) -> Record {
Record {
timestamp: DateTime::parse_from_rfc3339(timestamp)
.unwrap()
.with_timezone(&Local),
state,
}
}
#[test]
fn test_work_dur() {
struct TestCase {
expected: Option<Duration>,
data: Vec<Record>,
}
let mut ts: HashMap<&str, TestCase> = HashMap::new();
ts.insert(
"valid",
TestCase {
expected: Some(Duration::hours(8) + Duration::minutes(15)),
data: vec![
new_record("2018-09-11T09:00:00+00:00", State::Work),
new_record("2018-09-11T18:00:00+00:00", State::Work),
new_record("2018-09-11T12:00:00+00:00", State::Pause),
new_record("2018-09-11T12:45:00+00:00", State::Pause),
],
},
);
ts.insert(
"only-one-pause-entry",
TestCase {
expected: Some(Duration::hours(9)),
data: vec![
new_record("2018-09-11T09:00:00+00:00", State::Work),
new_record("2018-09-11T18:00:00+00:00", State::Work),
new_record("2018-09-11T12:00:00+00:00", State::Pause),
],
},
);
ts.insert(
"only-beginning",
TestCase {
expected: None,
data: vec![
new_record("2018-09-11T09:00:00+00:00", State::Work),
new_record("2018-09-11T12:00:00+00:00", State::Pause),
new_record("2018-09-11T12:45:00+00:00", State::Pause),
],
},
);
ts.insert(
"no-work-timestamp",
TestCase {
expected: None,
data: vec![
new_record("2018-09-11T12:00:00+00:00", State::Pause),
new_record("2018-09-11T12:45:00+00:00", State::Pause),
],
},
);
for (name, t) in ts {
println!("test: {}", name);
assert_eq!(work_dur(t.data), t.expected);
}
}
}