use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
const VERSION: u32 = 1;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Timers {
#[serde(default)]
pub version: u32,
#[serde(default)]
pub running: BTreeMap<String, Entry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Entry {
pub org: String,
pub profile: String,
pub key: String,
pub started: jiff::Timestamp,
}
#[must_use]
pub fn path_for(config_file: &Path) -> PathBuf {
config_file.with_file_name("timers.json")
}
fn slot(org: &str, key: &str) -> String {
format!("{org}/{key}")
}
impl Timers {
#[must_use]
pub fn load(path: &Path) -> Self {
let Ok(text) = std::fs::read_to_string(path) else {
return Self::default();
};
match serde_json::from_str::<Self>(&text) {
Ok(timers) if timers.version == VERSION => timers,
Ok(_) => {
tracing::warn!("ignoring timers written by another version");
Self::default()
}
Err(error) => {
tracing::warn!(%error, "ignoring an unreadable timers file");
Self::default()
}
}
}
pub fn save(&mut self, path: &Path) -> std::io::Result<()> {
self.version = VERSION;
let text = serde_json::to_string_pretty(self)
.map_err(|error| std::io::Error::other(error.to_string()))?;
std::fs::write(path, text + "\n")
}
#[must_use]
pub fn get(&self, org: &str, key: &str) -> Option<&Entry> {
self.running.get(&slot(org, key))
}
pub fn start(
&mut self,
org: &str,
profile: &str,
key: &str,
at: jiff::Timestamp,
) -> Result<(), &Entry> {
if self.running.contains_key(&slot(org, key)) {
return Err(self
.running
.get(&slot(org, key))
.unwrap_or_else(|| unreachable!("just checked")));
}
self.running.insert(
slot(org, key),
Entry {
org: org.to_owned(),
profile: profile.to_owned(),
key: key.to_owned(),
started: at,
},
);
Ok(())
}
pub fn take(&mut self, org: &str, key: &str) -> Option<Entry> {
self.running.remove(&slot(org, key))
}
#[must_use]
pub fn elsewhere(&self, org: &str, key: &str) -> Option<&Entry> {
self.running
.values()
.find(|entry| entry.key == key && entry.org != org)
}
#[must_use]
pub fn all(&self) -> Vec<&Entry> {
let mut entries: Vec<&Entry> = self.running.values().collect();
entries.sort_by_key(|entry| entry.started);
entries
}
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
fn stamp(text: &str) -> jiff::Timestamp {
text.parse().expect("timestamp")
}
#[test]
fn two_organisations_can_time_the_same_key_at_once() {
let mut timers = Timers::default();
assert!(
timers
.start("1", "work", "PROJ-1", stamp("2026-08-29T09:00:00Z"))
.is_ok()
);
assert!(
timers
.start("2", "personal", "PROJ-1", stamp("2026-08-29T10:00:00Z"))
.is_ok()
);
assert_eq!(timers.all().len(), 2);
assert_eq!(
timers.take("1", "PROJ-1").map(|entry| entry.started),
Some(stamp("2026-08-29T09:00:00Z"))
);
assert!(timers.get("2", "PROJ-1").is_some());
}
#[test]
fn starting_twice_is_refused_and_keeps_the_first_start() {
let mut timers = Timers::default();
let _ = timers.start("1", "work", "PROJ-1", stamp("2026-08-29T09:00:00Z"));
let refused = timers.start("1", "work", "PROJ-1", stamp("2026-08-29T11:00:00Z"));
assert_eq!(
refused.err().map(|entry| entry.started),
Some(stamp("2026-08-29T09:00:00Z"))
);
}
#[test]
fn a_timer_in_another_organisation_is_findable() {
let mut timers = Timers::default();
let _ = timers.start("1", "work", "PROJ-1", stamp("2026-08-29T09:00:00Z"));
assert!(timers.get("2", "PROJ-1").is_none());
assert_eq!(
timers
.elsewhere("2", "PROJ-1")
.map(|entry| entry.profile.as_str()),
Some("work")
);
}
#[test]
fn a_file_from_another_version_is_ignored_rather_than_misread() {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("timers.json");
std::fs::write(&path, r#"{"version": 99, "running": {"work/PROJ-1": {}}}"#).expect("write");
assert!(Timers::load(&path).running.is_empty());
}
#[test]
fn what_was_saved_comes_back() {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("timers.json");
let mut timers = Timers::default();
let _ = timers.start("1", "work", "PROJ-1", stamp("2026-08-29T09:00:00Z"));
timers.save(&path).expect("save");
let loaded = Timers::load(&path);
assert_eq!(
loaded.get("1", "PROJ-1").map(|entry| entry.started),
Some(stamp("2026-08-29T09:00:00Z"))
);
}
}