Skip to main content

sioul_core/
stopped.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Where you stopped: one line left when something interrupts you (a pause to
5//! move, a meal, a nap, the night, the end of the day, a timer stopped),
6//! shown again when you come back, on the Porch, the Tasks page and the focus
7//! window, until you say it is done. Stopping then costs no fear of
8//! forgetting where you were (interruption research: a cue to resume cuts the
9//! time to get back into a task, Trafton et al. 2003; Leroy & Glomb 2018).
10//! One line, kept in `$XDG_STATE_HOME/sioul/stopped.toml`, carried to your
11//! other devices with the sharing.
12
13use serde::{Deserialize, Serialize};
14use std::path::{Path, PathBuf};
15
16/// The line, when it was left, and the task it was about ("" for none).
17#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
18pub struct Stopped {
19    pub text: String,
20    /// Unix seconds.
21    pub at: i64,
22    #[serde(default, skip_serializing_if = "String::is_empty")]
23    pub task: String,
24}
25
26impl Stopped {
27    pub fn default_path() -> PathBuf {
28        crate::config::state_dir().join("stopped.toml")
29    }
30
31    /// The line left, if there is one.
32    pub fn load(path: &Path) -> Option<Stopped> {
33        std::fs::read_to_string(path).ok().and_then(|t| toml::from_str::<Stopped>(&t).ok()).filter(|s| !s.text.trim().is_empty())
34    }
35
36    /// A line left (an empty one takes it away: done).
37    pub fn keep(path: &Path, text: &str, task: &str, at: i64) -> Result<(), String> {
38        let stopped = Stopped { text: text.trim().to_string(), at, task: task.to_string() };
39        let fail = |e: std::io::Error| format!("{}: {e}", path.display());
40        if let Some(parent) = path.parent() {
41            std::fs::create_dir_all(parent).map_err(fail)?;
42        }
43        if stopped.text.is_empty() {
44            return match std::fs::remove_file(path) {
45                Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(fail(e)),
46                _ => Ok(()),
47            };
48        }
49        // Written beside, then renamed: the sharing never reads it empty (it reads without the lock).
50        crate::filelock::with_lock(path, || crate::filelock::replace(path, toml::to_string(&stopped).map_err(|e| e.to_string())?.as_bytes()).map_err(fail))
51    }
52}
53
54#[cfg(test)]
55mod tests {
56    use super::*;
57
58    #[test]
59    fn a_line_left_then_done() {
60        let dir = std::env::temp_dir().join(format!("sioul-stopped-{}", std::process::id()));
61        let path = dir.join("stopped.toml");
62        let _ = std::fs::remove_dir_all(&dir);
63        assert_eq!(Stopped::load(&path), None);
64        Stopped::keep(&path, "  the second paragraph, after the quote ", "t1", 100).unwrap();
65        assert_eq!(Stopped::load(&path), Some(Stopped { text: "the second paragraph, after the quote".into(), at: 100, task: "t1".into() }));
66        Stopped::keep(&path, "", "", 200).unwrap();
67        assert_eq!(Stopped::load(&path), None, "done: taken away");
68        let _ = std::fs::remove_dir_all(&dir);
69    }
70}