Skip to main content

starla_common/
pause.rs

1//! Pause state shared between the probe and the tray.
2//!
3//! Stored as a single-line file at `paths::paused_until_path()`:
4//! - absent file → not paused
5//! - file containing `indefinite` → paused with no end time
6//! - file containing an RFC 3339 timestamp → paused until that instant
7//!
8//! Both the probe (which honours the pause in its scheduler) and the
9//! tray (which writes the file when the user picks a duration) read and
10//! write through this module so the format stays in sync.
11
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case", tag = "kind", content = "until")]
17pub enum PauseState {
18    Indefinite,
19    Until(DateTime<Utc>),
20}
21
22impl PauseState {
23    /// True if the pause is still in effect at `now`.
24    pub fn is_active(&self, now: DateTime<Utc>) -> bool {
25        match self {
26            PauseState::Indefinite => true,
27            PauseState::Until(t) => *t > now,
28        }
29    }
30}
31
32/// Read the current pause state from disk. Returns `None` when the file
33/// is missing, empty, or the timestamp has already elapsed (treated as
34/// "no longer paused"; callers may delete the stale file).
35pub fn read_pause_state() -> Option<PauseState> {
36    let path = crate::paths::paused_until_path();
37    let contents = std::fs::read_to_string(&path).ok()?;
38    parse_pause_state(contents.trim())
39}
40
41fn parse_pause_state(s: &str) -> Option<PauseState> {
42    if s.is_empty() {
43        return None;
44    }
45    if s.eq_ignore_ascii_case("indefinite") {
46        return Some(PauseState::Indefinite);
47    }
48    DateTime::parse_from_rfc3339(s)
49        .ok()
50        .map(|t| PauseState::Until(t.with_timezone(&Utc)))
51}
52
53/// Write the pause state to disk, or remove the file when `None`.
54pub fn write_pause_state(state: Option<PauseState>) -> std::io::Result<()> {
55    let path = crate::paths::paused_until_path();
56    match state {
57        None => match std::fs::remove_file(&path) {
58            Ok(()) => Ok(()),
59            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
60            Err(e) => Err(e),
61        },
62        Some(PauseState::Indefinite) => {
63            crate::paths::ensure_state_dir()?;
64            std::fs::write(&path, "indefinite\n")
65        }
66        Some(PauseState::Until(t)) => {
67            crate::paths::ensure_state_dir()?;
68            std::fs::write(&path, format!("{}\n", t.to_rfc3339()))
69        }
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    #[test]
78    fn parses_indefinite() {
79        assert_eq!(
80            parse_pause_state("indefinite"),
81            Some(PauseState::Indefinite)
82        );
83        assert_eq!(
84            parse_pause_state("INDEFINITE"),
85            Some(PauseState::Indefinite)
86        );
87    }
88
89    #[test]
90    fn parses_rfc3339() {
91        let s = "2030-01-01T00:00:00Z";
92        match parse_pause_state(s) {
93            Some(PauseState::Until(_)) => {}
94            other => panic!("expected Until, got {:?}", other),
95        }
96    }
97
98    #[test]
99    fn rejects_garbage() {
100        assert_eq!(parse_pause_state(""), None);
101        assert_eq!(parse_pause_state("nope"), None);
102    }
103
104    #[test]
105    fn is_active_indefinite() {
106        assert!(PauseState::Indefinite.is_active(Utc::now()));
107    }
108
109    #[test]
110    fn is_active_until() {
111        let past = Utc::now() - chrono::Duration::hours(1);
112        let future = Utc::now() + chrono::Duration::hours(1);
113        assert!(!PauseState::Until(past).is_active(Utc::now()));
114        assert!(PauseState::Until(future).is_active(Utc::now()));
115    }
116}