1use std::collections::BTreeMap;
17use std::path::{Path, PathBuf};
18
19use serde::{Deserialize, Serialize};
20
21const VERSION: u32 = 1;
23
24#[derive(Debug, Clone, Default, Serialize, Deserialize)]
25pub struct Timers {
26 #[serde(default)]
27 pub version: u32,
28 #[serde(default)]
30 pub running: BTreeMap<String, Entry>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct Entry {
35 pub org: String,
36 pub profile: String,
39 pub key: String,
40 pub started: jiff::Timestamp,
42}
43
44#[must_use]
46pub fn path_for(config_file: &Path) -> PathBuf {
47 config_file.with_file_name("timers.json")
48}
49
50fn slot(org: &str, key: &str) -> String {
52 format!("{org}/{key}")
53}
54
55impl Timers {
56 #[must_use]
63 pub fn load(path: &Path) -> Self {
64 let Ok(text) = std::fs::read_to_string(path) else {
65 return Self::default();
66 };
67 match serde_json::from_str::<Self>(&text) {
68 Ok(timers) if timers.version == VERSION => timers,
69 Ok(_) => {
70 tracing::warn!("ignoring timers written by another version");
71 Self::default()
72 }
73 Err(error) => {
74 tracing::warn!(%error, "ignoring an unreadable timers file");
75 Self::default()
76 }
77 }
78 }
79
80 pub fn save(&mut self, path: &Path) -> std::io::Result<()> {
82 self.version = VERSION;
83 let text = serde_json::to_string_pretty(self)
84 .map_err(|error| std::io::Error::other(error.to_string()))?;
85 std::fs::write(path, text + "\n")
86 }
87
88 #[must_use]
90 pub fn get(&self, org: &str, key: &str) -> Option<&Entry> {
91 self.running.get(&slot(org, key))
92 }
93
94 pub fn start(
99 &mut self,
100 org: &str,
101 profile: &str,
102 key: &str,
103 at: jiff::Timestamp,
104 ) -> Result<(), &Entry> {
105 if self.running.contains_key(&slot(org, key)) {
106 return Err(self
107 .running
108 .get(&slot(org, key))
109 .unwrap_or_else(|| unreachable!("just checked")));
110 }
111 self.running.insert(
112 slot(org, key),
113 Entry {
114 org: org.to_owned(),
115 profile: profile.to_owned(),
116 key: key.to_owned(),
117 started: at,
118 },
119 );
120 Ok(())
121 }
122
123 pub fn take(&mut self, org: &str, key: &str) -> Option<Entry> {
125 self.running.remove(&slot(org, key))
126 }
127
128 #[must_use]
134 pub fn elsewhere(&self, org: &str, key: &str) -> Option<&Entry> {
135 self.running
136 .values()
137 .find(|entry| entry.key == key && entry.org != org)
138 }
139
140 #[must_use]
142 pub fn all(&self) -> Vec<&Entry> {
143 let mut entries: Vec<&Entry> = self.running.values().collect();
144 entries.sort_by_key(|entry| entry.started);
145 entries
146 }
147}
148
149#[cfg(test)]
150#[allow(clippy::expect_used)]
151mod tests {
152 use super::*;
153
154 fn stamp(text: &str) -> jiff::Timestamp {
155 text.parse().expect("timestamp")
156 }
157
158 #[test]
160 fn two_organisations_can_time_the_same_key_at_once() {
161 let mut timers = Timers::default();
162 assert!(
163 timers
164 .start("1", "work", "PROJ-1", stamp("2026-08-29T09:00:00Z"))
165 .is_ok()
166 );
167 assert!(
168 timers
169 .start("2", "personal", "PROJ-1", stamp("2026-08-29T10:00:00Z"))
170 .is_ok()
171 );
172
173 assert_eq!(timers.all().len(), 2);
174 assert_eq!(
175 timers.take("1", "PROJ-1").map(|entry| entry.started),
176 Some(stamp("2026-08-29T09:00:00Z"))
177 );
178 assert!(timers.get("2", "PROJ-1").is_some());
179 }
180
181 #[test]
183 fn starting_twice_is_refused_and_keeps_the_first_start() {
184 let mut timers = Timers::default();
185 let _ = timers.start("1", "work", "PROJ-1", stamp("2026-08-29T09:00:00Z"));
186
187 let refused = timers.start("1", "work", "PROJ-1", stamp("2026-08-29T11:00:00Z"));
188 assert_eq!(
189 refused.err().map(|entry| entry.started),
190 Some(stamp("2026-08-29T09:00:00Z"))
191 );
192 }
193
194 #[test]
197 fn a_timer_in_another_organisation_is_findable() {
198 let mut timers = Timers::default();
199 let _ = timers.start("1", "work", "PROJ-1", stamp("2026-08-29T09:00:00Z"));
200
201 assert!(timers.get("2", "PROJ-1").is_none());
202 assert_eq!(
203 timers
204 .elsewhere("2", "PROJ-1")
205 .map(|entry| entry.profile.as_str()),
206 Some("work")
207 );
208 }
209
210 #[test]
211 fn a_file_from_another_version_is_ignored_rather_than_misread() {
212 let dir = tempfile::tempdir().expect("temp dir");
213 let path = dir.path().join("timers.json");
214 std::fs::write(&path, r#"{"version": 99, "running": {"work/PROJ-1": {}}}"#).expect("write");
215
216 assert!(Timers::load(&path).running.is_empty());
217 }
218
219 #[test]
220 fn what_was_saved_comes_back() {
221 let dir = tempfile::tempdir().expect("temp dir");
222 let path = dir.path().join("timers.json");
223
224 let mut timers = Timers::default();
225 let _ = timers.start("1", "work", "PROJ-1", stamp("2026-08-29T09:00:00Z"));
226 timers.save(&path).expect("save");
227
228 let loaded = Timers::load(&path);
229 assert_eq!(
230 loaded.get("1", "PROJ-1").map(|entry| entry.started),
231 Some(stamp("2026-08-29T09:00:00Z"))
232 );
233 }
234}