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);
|
|
}
|
|
}
|
|
}
|