Skip to main content

ytcli/config/
timers.rs

1//! Timers running locally, kept between runs.
2//!
3//! A timer is not a Tracker object: there is no endpoint for "started working",
4//! only for "worked this long". So the start is remembered here and turned into
5//! a worklog on stop, which is the whole mechanism.
6//!
7//! Two decisions worth stating. It lives beside the config rather than in a
8//! dotdir of our own, because it is derived state of the same account and
9//! nobody should have to learn a second place to look. And it is keyed by
10//! organisation as well as by issue: `PROJ-1` in two organisations is two
11//! issues, and stopping the wrong one would log somebody else's time. By
12//! organisation rather than by profile, because two profiles onto the same
13//! organisation are two ways of saying the same issue — a timer started through
14//! one should stop through the other.
15
16use std::collections::BTreeMap;
17use std::path::{Path, PathBuf};
18
19use serde::{Deserialize, Serialize};
20
21/// Bumped when the shape changes, so an old file is ignored rather than misread.
22const VERSION: u32 = 1;
23
24#[derive(Debug, Clone, Default, Serialize, Deserialize)]
25pub struct Timers {
26    #[serde(default)]
27    pub version: u32,
28    /// `org/KEY` -> when it started.
29    #[serde(default)]
30    pub running: BTreeMap<String, Entry>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct Entry {
35    pub org: String,
36    /// The profile it was started through, for saying so. Not what it is keyed
37    /// by: that is the organisation.
38    pub profile: String,
39    pub key: String,
40    /// When the timer was started, as Tracker will be told.
41    pub started: jiff::Timestamp,
42}
43
44/// Where the timers live: beside the config, like the queue cache.
45#[must_use]
46pub fn path_for(config_file: &Path) -> PathBuf {
47    config_file.with_file_name("timers.json")
48}
49
50/// One timer's name in the file.
51fn slot(org: &str, key: &str) -> String {
52    format!("{org}/{key}")
53}
54
55impl Timers {
56    /// Read them, or start empty.
57    ///
58    /// Unlike the queue cache, an unreadable file here loses work somebody did:
59    /// it is still not an error to read, because refusing to start a new timer
60    /// because an old file is corrupt helps nobody, but it is logged rather
61    /// than passed over in silence.
62    #[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    /// Write them back.
81    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    /// The timer for this issue in this organisation.
89    #[must_use]
90    pub fn get(&self, org: &str, key: &str) -> Option<&Entry> {
91        self.running.get(&slot(org, key))
92    }
93
94    /// Start one, refusing to replace a timer that is already running.
95    ///
96    /// Replacing it would throw away however long it had been running, which is
97    /// exactly the data the command exists to keep.
98    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    /// Take one off the list.
124    pub fn take(&mut self, org: &str, key: &str) -> Option<Entry> {
125        self.running.remove(&slot(org, key))
126    }
127
128    /// The same issue key running in some other organisation.
129    ///
130    /// What this answers is the confusing case: a timer was started as `work`
131    /// and stopped as `personal`, and "no timer running" would be a true
132    /// sentence that sends somebody looking in the wrong place.
133    #[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    /// Everything running, oldest first — the one most likely to be forgotten.
141    #[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    /// The same key in two organisations is two issues.
159    #[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    /// Starting over a running timer would discard the time it had collected.
182    #[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    /// "No timer running" is true and unhelpful when it is running as somebody
195    /// else.
196    #[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}