use std::collections::BTreeSet;
use std::fs::File;
use std::io::BufReader;
use std::path::{Path, PathBuf};
use anyhow::{Result, anyhow, bail};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use serde_jsonlines::JsonLinesIter;
use time::{OffsetDateTime, UtcOffset};
pub struct Store {
timelog: PathBuf,
taskset: PathBuf,
current_task: PathBuf,
last_task: PathBuf,
}
impl Store {
pub fn new(data_dir: &Path) -> Result<Self> {
if !data_dir.exists() {
std::fs::create_dir_all(data_dir).map_err(|e| {
anyhow!(
"failed to create project data directory: {}: {e}",
data_dir.display()
)
})?;
}
Ok(Self {
timelog: data_dir.join("timelog.jsonl"),
taskset: data_dir.join("taskset.json"),
current_task: data_dir.join("current"),
last_task: data_dir.join("last"),
})
}
pub fn with_project_dir() -> Result<Self> {
let pdirs = ProjectDirs::from("codes", "hitchcock", "kimai-timer")
.ok_or_else(|| anyhow!("failed to derive project directory path"))?;
Self::new(pdirs.data_dir())
}
pub fn append_interval(&self, interval: TimeInterval) -> Result<()> {
Self::touch_file(&self.timelog)?;
let event = StoreEvent::CreateInterval(interval);
serde_jsonlines::append_json_lines(&self.timelog, &[event])
.map_err(|e| anyhow!("failed to write interval event to timelog: {e}"))?;
Ok(())
}
pub fn fetch_events(&self) -> Result<PersistedEventIterator> {
Self::touch_file(&self.timelog)?;
let lines_iter = serde_jsonlines::json_lines(&self.timelog)
.map_err(|e| anyhow!("failed to read timelog: {e}"))?;
Ok(PersistedEventIterator { inner: lines_iter })
}
pub fn add_task(&self, task: impl Into<String>) -> Result<()> {
let task = task.into();
if !task.chars().next().unwrap().is_ascii_alphabetic()
|| task
.chars()
.any(|c| !(c.is_ascii_alphanumeric() || c == '-'))
{
bail!(
"invalid task name: must start with letter and only contain alphanumerics or dashes"
);
}
let mut tasks = self.get_tasks()?;
tasks.insert(task);
let contents = serde_json::to_string(&tasks)
.map_err(|e| anyhow!("failed to save task to taskset: {e}"))?;
Self::write_file(&self.taskset, &contents)
}
pub fn get_tasks(&self) -> Result<BTreeSet<String>> {
let contents = Self::read_file(&self.taskset)?;
let tasks = if contents.is_empty() {
BTreeSet::new()
} else {
serde_json::from_str(&contents).map_err(|e| anyhow!("failed to parse taskset: {e}"))?
};
Ok(tasks)
}
pub fn get_current_task(&self) -> Result<Option<CurrentTask>> {
let contents = Self::read_file(&self.current_task)?;
if contents.is_empty() {
Ok(None)
} else {
let current = serde_json::from_str(&contents)
.map_err(|e| anyhow!("failed to parse current task: {e}"))?;
Ok(Some(current))
}
}
pub fn set_current_task(&self, task: &str, start: i64) -> Result<()> {
let current = CurrentTask {
task: task.to_string(),
start,
};
let contents = serde_json::to_string(¤t)
.map_err(|e| anyhow!("failed to serialize current task: {e}"))?;
Self::write_file(&self.current_task, &contents)
}
pub fn clear_current_task(&self) -> Result<()> {
Self::write_file(&self.current_task, "")
}
pub fn get_last_task(&self) -> Result<Option<String>> {
let last = Self::read_file(&self.last_task)?;
if last.is_empty() {
Ok(None)
} else {
Ok(Some(last))
}
}
pub fn set_last_task(&self, task: &str) -> Result<()> {
Self::write_file(&self.last_task, task)
}
fn read_file(p: &Path) -> Result<String> {
if !std::fs::exists(p)
.map_err(|e| anyhow!("could not determine if file exists: {}: {e}", p.display()))?
{
return Ok(String::new());
}
std::fs::read_to_string(p).map_err(|e| anyhow!("failed to read file: {}: {e}", p.display()))
}
fn write_file(p: &Path, contents: &str) -> Result<()> {
std::fs::write(p, contents.as_bytes())
.map_err(|e| anyhow!("failed to write file: {}: {e}", p.display()))
}
fn touch_file(p: &Path) -> Result<()> {
let exists = std::fs::exists(p)
.map_err(|e| anyhow!("could not determine if file exists: {}: {e}", p.display()))?;
if !exists {
std::fs::create_dir_all(p.parent().unwrap())
.map_err(|e| anyhow!("could not create store directory: {e}"))?;
}
let _ = File::options()
.create(true)
.write(true)
.truncate(false)
.open(p)
.map_err(|e| anyhow!("failed to touch store file: {}: {e}", p.display()))?;
Ok(())
}
}
pub struct PersistedEventIterator {
inner: JsonLinesIter<BufReader<File>, StoreEvent>,
}
impl Iterator for PersistedEventIterator {
type Item = Result<StoreEvent, std::io::Error>;
fn next(&mut self) -> Option<Self::Item> {
self.inner.next()
}
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
pub enum StoreEvent {
CreateInterval(TimeInterval),
}
#[derive(Clone, Serialize, Deserialize)]
pub struct TimeInterval {
pub id: String,
#[serde(with = "time::serde::timestamp")]
pub created_at: OffsetDateTime,
pub updated_at: Option<OffsetDateTime>,
pub task: String,
#[serde(with = "time::serde::timestamp")]
pub start: OffsetDateTime,
#[serde(with = "time::serde::timestamp")]
pub end: OffsetDateTime,
}
impl TimeInterval {
pub fn new(task: impl Into<String>, start: OffsetDateTime, end: OffsetDateTime) -> Self {
Self {
id: uuid::Uuid::new_v4().to_string(),
created_at: OffsetDateTime::now_utc().truncate_to_second(),
updated_at: None,
task: task.into(),
start,
end,
}
}
pub fn to_local(&self, offset: UtcOffset) -> Self {
Self {
id: self.id.clone(),
created_at: self.created_at.to_offset(offset),
updated_at: self.updated_at,
task: self.task.clone(),
start: self.start.to_offset(offset),
end: self.end.to_offset(offset),
}
}
}
#[derive(Serialize, Deserialize)]
pub struct CurrentTask {
pub task: String,
pub start: i64,
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn new_creates_directory_if_missing() {
let dir = tempdir().unwrap();
let nested = dir.path().join("a").join("b");
assert!(!nested.exists());
Store::new(&nested).unwrap();
assert!(nested.exists());
}
#[test]
fn add_and_get_tasks_roundtrip() {
let dir = tempdir().unwrap();
let store = Store::new(dir.path()).unwrap();
store.add_task("my-task").unwrap();
let tasks = store.get_tasks().unwrap();
assert!(tasks.contains("my-task"));
}
#[test]
fn set_and_get_current_task_roundtrip() {
let dir = tempdir().unwrap();
let store = Store::new(dir.path()).unwrap();
store.set_current_task("my-task", 1_000_000).unwrap();
let current = store.get_current_task().unwrap().unwrap();
assert_eq!(current.task, "my-task");
assert_eq!(current.start, 1_000_000);
}
#[test]
fn clear_current_task_returns_none() {
let dir = tempdir().unwrap();
let store = Store::new(dir.path()).unwrap();
store.set_current_task("my-task", 1_000_000).unwrap();
store.clear_current_task().unwrap();
assert!(store.get_current_task().unwrap().is_none());
}
#[test]
fn set_and_get_last_task_roundtrip() {
let dir = tempdir().unwrap();
let store = Store::new(dir.path()).unwrap();
store.set_last_task("my-task").unwrap();
let last = store.get_last_task().unwrap().unwrap();
assert_eq!(last, "my-task");
}
#[test]
fn append_and_fetch_interval_roundtrip() {
let dir = tempdir().unwrap();
let store = Store::new(dir.path()).unwrap();
let start = OffsetDateTime::from_unix_timestamp(1_000_000).unwrap();
let end = OffsetDateTime::from_unix_timestamp(1_003_600).unwrap();
let interval = TimeInterval::new("my-task", start, end);
let id = interval.id.clone();
store.append_interval(interval).unwrap();
let events: Vec<_> = store.fetch_events().unwrap().collect();
assert_eq!(events.len(), 1);
let StoreEvent::CreateInterval(fetched) = events[0].as_ref().unwrap().clone();
assert_eq!(fetched.id, id);
assert_eq!(fetched.task, "my-task");
}
}