1use crate::config::data_dir;
23use serde::{Deserialize, Serialize};
24use std::collections::BTreeMap;
25use std::path::{Path, PathBuf};
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
29#[serde(rename_all = "lowercase")]
30pub enum Kind {
31 Measured,
33 Typed,
35 Corrected,
37}
38
39impl Kind {
40 pub fn id(kind: Option<Kind>) -> &'static str {
42 match kind {
43 Some(Kind::Measured) => "measured",
44 Some(Kind::Typed) => "typed",
45 Some(Kind::Corrected) => "corrected",
46 None => "unknown",
47 }
48 }
49
50 pub fn timed(kind: Option<Kind>) -> bool {
52 matches!(kind, Some(Kind::Measured | Kind::Corrected))
53 }
54}
55
56#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
58pub struct Session {
59 #[serde(default, skip_serializing_if = "String::is_empty")]
64 pub id: String,
65 #[serde(default, skip_serializing_if = "String::is_empty")]
67 pub task: String,
68 #[serde(default, skip_serializing_if = "String::is_empty")]
70 pub project: String,
71 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
73 pub unbilled: bool,
74 #[serde(default, skip_serializing_if = "String::is_empty")]
76 pub invoice: String,
77 pub start: i64,
79 pub minutes: u32,
80 #[serde(default)]
82 pub done: bool,
83 #[serde(default, skip_serializing_if = "String::is_empty")]
85 pub note: String,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub kind: Option<Kind>,
89}
90
91#[derive(Debug, Default, Serialize, Deserialize)]
92struct Month {
93 #[serde(default, rename = "session")]
94 sessions: Vec<Session>,
95}
96
97pub fn folder() -> PathBuf {
99 data_dir().join("time")
100}
101
102fn month_file(dir: &Path, start: i64) -> PathBuf {
103 let month = jiff::Timestamp::from_second(start).map(|t| t.to_zoned(jiff::tz::TimeZone::system()).strftime("%Y-%m").to_string()).unwrap_or_else(|_| "unknown".into());
104 dir.join(format!("{month}.toml"))
105}
106
107fn write_atomically(path: &Path, text: &str) -> Result<(), String> {
108 let fail = |e: std::io::Error| format!("{}: {e}", path.display());
109 if let Some(parent) = path.parent() {
110 std::fs::create_dir_all(parent).map_err(fail)?;
111 }
112 let temporary = path.with_extension("toml.new");
113 std::fs::write(&temporary, text).map_err(fail)?;
114 std::fs::rename(&temporary, path).map_err(fail)
115}
116
117pub fn record_in(dir: &Path, session: &Session) -> Result<(), String> {
119 let path = month_file(dir, session.start);
120 crate::filelock::with_lock(&path, || {
122 let mut month: Month = match std::fs::read_to_string(&path) {
125 Ok(text) => toml::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?,
126 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Month::default(),
127 Err(e) => return Err(format!("{}: {e}", path.display())),
128 };
129 let mut session = session.clone();
130 if session.id.is_empty() {
131 session.id = crate::ids::new();
132 }
133 month.sessions.push(session);
134 write_atomically(&path, &toml::to_string(&month).map_err(|e| e.to_string())?)
135 })
136}
137
138pub fn record(session: &Session) -> Result<(), String> {
140 record_in(&folder(), session)
141}
142
143pub fn sessions_in(dir: &Path) -> Vec<Session> {
145 let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() };
146 let mut files: Vec<PathBuf> = entries
147 .filter_map(Result::ok)
148 .map(|e| e.path())
149 .filter(|p| p.extension().is_some_and(|x| x == "toml") && p.file_stem().is_some_and(|s| s.to_string_lossy().chars().next().is_some_and(|c| c.is_ascii_digit())))
150 .collect();
151 files.sort();
152 let mut all: Vec<Session> = files.iter().filter_map(|p| std::fs::read_to_string(p).ok()).filter_map(|t| toml::from_str::<Month>(&t).ok()).flat_map(|m| m.sessions).collect();
153 all.sort_by_key(|s| s.start);
154 all
155}
156
157pub fn sessions() -> Vec<Session> {
159 sessions_in(&folder())
160}
161
162impl Session {
163 pub fn key(&self) -> String {
165 format!("{}:{}:{}", self.start, if self.task.is_empty() { &self.project } else { &self.task }, self.minutes)
166 }
167
168 pub fn set_minutes(&mut self, minutes: u32) {
171 if minutes != self.minutes && self.kind == Some(Kind::Measured) {
172 self.kind = Some(Kind::Corrected);
173 }
174 self.minutes = minutes;
175 }
176}
177
178pub fn update_in(dir: &Path, keys: &[String], change: impl Fn(&mut Session)) -> Result<usize, String> {
181 let Ok(entries) = std::fs::read_dir(dir) else { return Ok(0) };
182 let mut changed = 0;
183 for path in entries.filter_map(Result::ok).map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "toml") && p.file_name().is_some_and(|n| n != "running.toml")) {
184 changed += crate::filelock::with_lock(&path, || -> Result<usize, String> {
186 let Some(mut month) = std::fs::read_to_string(&path).ok().and_then(|t| toml::from_str::<Month>(&t).ok()) else { return Ok(0) };
187 let mut here = 0;
188 for session in month.sessions.iter_mut().filter(|s| keys.contains(&s.key())) {
189 change(session);
190 here += 1;
191 }
192 if here > 0 {
193 write_atomically(&path, &toml::to_string(&month).map_err(|e| e.to_string())?)?;
194 }
195 Ok(here)
196 })?;
197 }
198 Ok(changed)
199}
200
201pub fn change_in(dir: &Path, key: &str, change: impl Fn(&mut Session)) -> Result<bool, String> {
205 let Some(found) = sessions_in(dir).into_iter().find(|s| s.key() == key) else { return Ok(false) };
206 let path = month_file(dir, found.start);
207 let new = crate::filelock::with_lock(&path, || {
210 let month: Month = std::fs::read_to_string(&path).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default();
211 month.sessions.into_iter().find(|s| s.key() == key).map(|mut session| {
212 change(&mut session);
213 session
214 })
215 });
216 let Some(new) = new else { return Ok(false) };
217 if month_file(dir, new.start) == path {
218 return update_in(dir, &[key.to_string()], &change).map(|n| n > 0);
219 }
220 record_in(dir, &new)?;
221 remove_in(dir, &[key.to_string()])?;
222 Ok(true)
223}
224
225pub fn remove_in(dir: &Path, keys: &[String]) -> Result<usize, String> {
227 let Ok(entries) = std::fs::read_dir(dir) else { return Ok(0) };
228 let mut removed = 0;
229 for path in entries.filter_map(Result::ok).map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "toml") && p.file_name().is_some_and(|n| n != "running.toml")) {
230 removed += crate::filelock::with_lock(&path, || -> Result<usize, String> {
231 let Some(mut month) = std::fs::read_to_string(&path).ok().and_then(|t| toml::from_str::<Month>(&t).ok()) else { return Ok(0) };
232 let before = month.sessions.len();
233 month.sessions.retain(|s| !keys.contains(&s.key()));
234 if month.sessions.len() != before {
235 write_atomically(&path, &toml::to_string(&month).map_err(|e| e.to_string())?)?;
236 }
237 Ok(before - month.sessions.len())
238 })?;
239 }
240 Ok(removed)
241}
242
243pub fn spent(sessions: &[Session], from: i64, to: i64) -> BTreeMap<String, u32> {
245 let mut out: BTreeMap<String, u32> = BTreeMap::new();
246 for s in sessions.iter().filter(|s| s.start >= from && s.start < to) {
247 let minutes = out.entry(s.task.clone()).or_default();
248 *minutes = minutes.saturating_add(s.minutes);
249 }
250 out
251}
252
253#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
255pub struct Running {
256 pub task: String,
257 pub start: i64,
259 pub planned: u32,
261 #[serde(default)]
263 pub paused_at: Option<i64>,
264 #[serde(default)]
266 pub paused: i64,
267}
268
269impl Running {
270 pub fn elapsed(&self, now: i64) -> i64 {
272 let until = self.paused_at.unwrap_or(now);
273 (until - self.start - self.paused).max(0)
274 }
275
276 pub fn remaining(&self, now: i64) -> i64 {
278 i64::from(self.planned) * 60 - self.elapsed(now)
279 }
280
281 pub fn toggle_pause(&mut self, now: i64) {
283 match self.paused_at.take() {
284 Some(since) => self.paused += now - since,
285 None => self.paused_at = Some(now),
286 }
287 }
288
289 pub fn finished(&self, now: i64, done: bool) -> Session {
291 let minutes = u32::try_from((self.elapsed(now) + 30) / 60).unwrap_or(u32::MAX).max(1);
292 Session { task: self.task.clone(), start: self.start, minutes, done, kind: Some(Kind::Measured), ..Session::default() }
293 }
294}
295
296fn running_path(dir: &Path) -> PathBuf {
297 dir.join("running.toml")
298}
299
300pub fn running_in(dir: &Path) -> Option<Running> {
302 std::fs::read_to_string(running_path(dir)).ok().and_then(|t| toml::from_str(&t).ok())
303}
304
305pub fn running() -> Option<Running> {
306 running_in(&folder())
307}
308
309pub fn keep_running_in(dir: &Path, running: Option<&Running>) -> Result<(), String> {
311 let path = running_path(dir);
312 crate::filelock::with_lock(&path, || match running {
313 Some(r) => write_atomically(&path, &toml::to_string(r).map_err(|e| e.to_string())?),
314 None => match std::fs::remove_file(&path) {
315 Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(format!("{}: {e}", path.display())),
316 _ => Ok(()),
317 },
318 })
319}
320
321pub fn keep_running(running: Option<&Running>) -> Result<(), String> {
322 keep_running_in(&folder(), running)
323}
324
325pub fn change_running_in(dir: &Path, change: impl FnOnce(&mut Running)) -> Result<bool, String> {
329 let path = running_path(dir);
330 crate::filelock::with_lock(&path, || {
331 let Some(mut running) = running_in(dir) else { return Ok(false) };
332 change(&mut running);
333 keep_running_in(dir, Some(&running)).map(|()| true)
334 })
335}
336
337pub fn change_running(change: impl FnOnce(&mut Running)) -> Result<bool, String> {
338 change_running_in(&folder(), change)
339}
340
341const OPEN_ENDED_CEILING: u32 = 180;
343
344pub fn finish_in(dir: &Path, now: i64, done: bool) -> Result<Option<Session>, String> {
348 finish_noted_in(dir, now, done, "")
349}
350
351pub fn finish_noted_in(dir: &Path, now: i64, done: bool, note: &str) -> Result<Option<Session>, String> {
353 let Some(running) = running_in(dir) else { return Ok(None) };
354 let mut session = running.finished(now, done);
355 session.note = note.to_string();
356 let ceiling = if running.planned == 0 { OPEN_ENDED_CEILING } else { running.planned.saturating_mul(2).max(running.planned.saturating_add(30)) };
357 if session.minutes > ceiling {
358 session.minutes = if running.planned == 0 { OPEN_ENDED_CEILING } else { running.planned };
359 session.kind = Some(Kind::Corrected);
361 }
362 record_in(dir, &session)?;
363 keep_running_in(dir, None)?;
364 Ok(Some(session))
365}
366
367pub fn finish(now: i64, done: bool) -> Result<Option<Session>, String> {
368 finish_in(&folder(), now, done)
369}
370
371pub fn finish_with_note(now: i64, done: bool, note: &str) -> Result<Option<Session>, String> {
373 finish_noted_in(&folder(), now, done, note)
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379
380 #[test]
381 fn a_stretch_changed_in_place_or_moved_to_its_month() {
382 let dir = std::env::temp_dir().join(format!("sioul-timelog-change-{}", std::process::id()));
383 let _ = std::fs::remove_dir_all(&dir);
384 let at = |text: &str| -> i64 { format!("{text}[Europe/Paris]").parse::<jiff::Zoned>().unwrap().timestamp().as_second() };
385 let first = Session { task: "t1".into(), start: at("2026-10-05T09:00"), minutes: 120, ..Session::default() };
386 record_in(&dir, &first).unwrap();
387 assert!(change_in(&dir, &first.key(), |s| {
389 s.minutes = 75;
390 s.note = "the draft, up to page 3".into();
391 s.task = "t2".into();
392 })
393 .unwrap());
394 let all = sessions_in(&dir);
395 assert_eq!((all.len(), all[0].minutes, all[0].task.as_str(), all[0].note.as_str()), (1, 75, "t2", "the draft, up to page 3"));
396 let key = all[0].key();
398 assert!(change_in(&dir, &key, |s| s.start = at("2026-09-30T16:00")).unwrap());
399 let all = sessions_in(&dir);
400 assert_eq!(all.len(), 1);
401 assert!(dir.join("2026-09.toml").exists());
402 assert!(!change_in(&dir, "nothing", |_| {}).unwrap(), "a stretch gone is said so");
403 let _ = std::fs::remove_dir_all(&dir);
404 }
405
406 #[test]
407 fn each_session_says_how_it_was_known() {
408 let dir = std::env::temp_dir().join(format!("sioul-timelog-kind-{}", std::process::id()));
409 let _ = std::fs::remove_dir_all(&dir);
410 let start = 1_791_100_000;
411 keep_running_in(&dir, Some(&Running { task: "t".into(), start, planned: 25, ..Running::default() })).unwrap();
413 let timed = finish_in(&dir, start + 20 * 60, true).unwrap().unwrap();
414 assert_eq!(timed.kind, Some(Kind::Measured));
415 assert!(change_in(&dir, &timed.key(), |s| s.set_minutes(15)).unwrap());
417 let read = sessions_in(&dir);
418 assert_eq!((read[0].minutes, read[0].kind), (15, Some(Kind::Corrected)));
419 assert!(change_in(&dir, &read[0].key(), |s| s.set_minutes(15)).unwrap());
421 assert_eq!(sessions_in(&dir)[0].kind, Some(Kind::Corrected));
422 let mut typed = Session { task: "u".into(), start: start + 7200, minutes: 30, kind: Some(Kind::Typed), ..Session::default() };
424 typed.set_minutes(45);
425 assert_eq!(typed.kind, Some(Kind::Typed));
426 keep_running_in(&dir, Some(&Running { task: "d".into(), start: start + 9000, planned: 25, ..Running::default() })).unwrap();
428 assert_eq!(finish_in(&dir, start + 9000 + 10 * 3600, false).unwrap().unwrap().kind, Some(Kind::Corrected));
429 let month = month_file(&dir, start);
431 let text = std::fs::read_to_string(&month).unwrap();
432 std::fs::write(&month, format!("{text}\n[[session]]\ntask = \"old\"\nstart = {}\nminutes = 10\n", start + 20_000)).unwrap();
433 let old = sessions_in(&dir).into_iter().find(|s| s.task == "old").unwrap();
434 assert_eq!((old.kind, Kind::id(old.kind), Kind::timed(old.kind)), (None, "unknown", false));
435 assert!(toml::to_string(&Month { sessions: vec![old] }).unwrap().lines().all(|l| !l.starts_with("kind")));
436 assert!(Kind::timed(Some(Kind::Corrected)) && !Kind::timed(Some(Kind::Typed)));
437 let _ = std::fs::remove_dir_all(&dir);
438 }
439
440 #[test]
441 fn time_by_hand_and_billed_once() {
442 let dir = std::env::temp_dir().join(format!("sioul-time-hand-{}", std::process::id()));
443 let _ = std::fs::remove_dir_all(&dir);
444 let start = 1_791_000_000;
445 let meeting = Session { project: "lumen".into(), start, minutes: 90, note: "Kick-off meeting".into(), ..Session::default() };
446 let coffee = Session { project: "lumen".into(), start: start + 7200, minutes: 30, unbilled: true, ..Session::default() };
447 record_in(&dir, &meeting).unwrap();
448 record_in(&dir, &coffee).unwrap();
449 assert_eq!(update_in(&dir, &[meeting.key()], |s| s.invoice = "2026-001".into()).unwrap(), 1);
450 let all = sessions_in(&dir);
451 assert_eq!((all[0].invoice.as_str(), all[1].invoice.as_str()), ("2026-001", ""));
452 let text = std::fs::read_dir(&dir).unwrap().filter_map(Result::ok).map(|e| std::fs::read_to_string(e.path()).unwrap()).collect::<String>();
453 assert!(!text.contains("task = \"\""), "a session for a project alone says so without an empty task: {text}");
454 assert_eq!(remove_in(&dir, &[coffee.key()]).unwrap(), 1);
455 assert_eq!(sessions_in(&dir).len(), 1);
456 let file = month_file(&dir, start);
458 std::fs::write(&file, "[[session]]\nstart = \"half written").unwrap();
459 assert!(record_in(&dir, &meeting).is_err());
460 assert_eq!(std::fs::read_to_string(&file).unwrap(), "[[session]]\nstart = \"half written");
461 let _ = std::fs::remove_dir_all(&dir);
462 }
463
464 #[test]
465 fn sessions_by_month_and_by_task() {
466 let dir = std::env::temp_dir().join(format!("sioul-time-{}", std::process::id()));
467 let _ = std::fs::remove_dir_all(&dir);
468 let start = 1_791_100_000; record_in(&dir, &Session { task: "a".into(), start, minutes: 25, ..Session::default() }).unwrap();
470 record_in(&dir, &Session { task: "a".into(), start: start + 3600, minutes: 5, done: true, ..Session::default() }).unwrap();
471 record_in(&dir, &Session { task: "b".into(), start: start - 40 * 86_400, minutes: 15, ..Session::default() }).unwrap();
472 let all = sessions_in(&dir);
473 assert_eq!(all.len(), 3);
474 assert_eq!(std::fs::read_dir(&dir).unwrap().filter_map(Result::ok).filter(|e| !e.file_name().to_string_lossy().starts_with('.')).count(), 2, "two months, two files");
476 let spent = spent(&all, start - 86_400, start + 86_400);
477 assert_eq!(spent.get("a"), Some(&30));
478 assert_eq!(spent.get("b"), None);
479
480 let mut running = Running { task: "c".into(), start, planned: 15, ..Running::default() };
481 running.toggle_pause(start + 300);
482 running.toggle_pause(start + 900);
483 assert_eq!((running.elapsed(start + 1200), running.remaining(start + 1200)), (600, 300));
484 keep_running_in(&dir, Some(&running)).unwrap();
485 assert_eq!(running_in(&dir), Some(running));
486 let session = finish_in(&dir, start + 1200, true).unwrap().unwrap();
487 assert_eq!((session.minutes, session.done), (10, true));
488 assert_eq!(running_in(&dir), None);
489 keep_running_in(&dir, Some(&Running { task: "d".into(), start, planned: 25, ..Running::default() })).unwrap();
491 assert_eq!(finish_in(&dir, start + 10 * 3600, false).unwrap().unwrap().minutes, 25);
492 std::fs::remove_dir_all(&dir).unwrap();
493 }
494}