entracte_lib/scheduler/
pause.rs1use std::path::Path;
2use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
3
4use log::error;
5use serde::Serialize;
6
7use crate::pause_store::{self, PauseSnapshot};
8
9pub trait Clock {
15 fn instant_now(&self) -> Instant;
16 fn system_now(&self) -> SystemTime;
17}
18
19pub struct SystemClock;
21
22impl Clock for SystemClock {
23 fn instant_now(&self) -> Instant {
24 Instant::now()
25 }
26 fn system_now(&self) -> SystemTime {
27 SystemTime::now()
28 }
29}
30
31#[derive(Debug, Clone)]
36pub enum PauseState {
37 Running,
38 PausedUntil(Option<Instant>),
39}
40
41#[derive(Debug, Clone, Serialize)]
44pub struct PauseInfo {
45 pub paused: bool,
46 pub remaining_secs: Option<u64>,
47}
48
49pub(super) fn now_epoch_secs() -> u64 {
50 now_epoch_secs_with(&SystemClock)
51}
52
53fn now_epoch_secs_with<C: Clock>(clock: &C) -> u64 {
54 clock
55 .system_now()
56 .duration_since(UNIX_EPOCH)
57 .map(|d| d.as_secs())
58 .unwrap_or(0)
59}
60
61fn snapshot_from_state_with<C: Clock>(state: &PauseState, clock: &C) -> PauseSnapshot {
62 match state {
63 PauseState::Running => PauseSnapshot {
64 paused: false,
65 until_epoch_secs: None,
66 },
67 PauseState::PausedUntil(None) => PauseSnapshot {
68 paused: true,
69 until_epoch_secs: None,
70 },
71 PauseState::PausedUntil(Some(deadline)) => {
72 let now = clock.instant_now();
73 let remaining = deadline.saturating_duration_since(now);
74 PauseSnapshot {
75 paused: true,
76 until_epoch_secs: Some(now_epoch_secs_with(clock) + remaining.as_secs()),
77 }
78 }
79 }
80}
81
82fn snapshot_from_state(state: &PauseState) -> PauseSnapshot {
83 snapshot_from_state_with(state, &SystemClock)
84}
85
86pub fn restore_pause_state(path: &Path) -> PauseState {
90 restore_pause_state_with(path, &SystemClock)
91}
92
93fn restore_pause_state_with<C: Clock>(path: &Path, clock: &C) -> PauseState {
94 let snap = pause_store::load(path);
95 if !snap.paused {
96 return PauseState::Running;
97 }
98 match snap.until_epoch_secs {
99 None => PauseState::PausedUntil(None),
100 Some(deadline_epoch) => {
101 let now_epoch = now_epoch_secs_with(clock);
102 if deadline_epoch > now_epoch {
103 let remaining = Duration::from_secs(deadline_epoch - now_epoch);
104 PauseState::PausedUntil(Some(clock.instant_now() + remaining))
105 } else {
106 let cleared = PauseSnapshot::default();
107 if let Err(e) = pause_store::save(path, &cleared) {
108 error!("pause_store: failed to save {}: {e}", path.display());
109 }
110 PauseState::Running
111 }
112 }
113 }
114}
115
116pub fn persist_pause(path: &Path, state: &PauseState) {
120 let snap = snapshot_from_state(state);
121 if let Err(e) = pause_store::save(path, &snap) {
122 error!("pause_store: failed to save {}: {e}", path.display());
123 }
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129 use crate::test_support::{temp_dir, TempDir};
130
131 struct FakeClock {
135 instant: Instant,
136 system: SystemTime,
137 }
138
139 impl FakeClock {
140 fn now() -> Self {
141 Self {
142 instant: Instant::now(),
143 system: SystemTime::now(),
144 }
145 }
146 }
147
148 impl Clock for FakeClock {
149 fn instant_now(&self) -> Instant {
150 self.instant
151 }
152 fn system_now(&self) -> SystemTime {
153 self.system
154 }
155 }
156
157 fn temp_file() -> (TempDir, std::path::PathBuf) {
158 let dir = temp_dir();
159 let path = dir.path().join("pause.json");
160 (dir, path)
161 }
162
163 #[test]
164 fn restore_returns_running_when_file_missing() {
165 let dir = temp_dir();
166 let path = dir.path().join("does-not-exist.json");
167 assert!(matches!(restore_pause_state(&path), PauseState::Running));
168 }
169
170 #[test]
171 fn restore_returns_running_when_snapshot_not_paused() {
172 let (_dir, path) = temp_file();
173 pause_store::save(
174 &path,
175 &pause_store::PauseSnapshot {
176 paused: false,
177 until_epoch_secs: None,
178 },
179 )
180 .unwrap();
181 assert!(matches!(restore_pause_state(&path), PauseState::Running));
182 }
183
184 #[test]
185 fn restore_returns_indefinite_pause_when_until_is_none() {
186 let (_dir, path) = temp_file();
187 pause_store::save(
188 &path,
189 &pause_store::PauseSnapshot {
190 paused: true,
191 until_epoch_secs: None,
192 },
193 )
194 .unwrap();
195 assert!(matches!(
196 restore_pause_state(&path),
197 PauseState::PausedUntil(None)
198 ));
199 }
200
201 #[test]
202 fn restore_reconstructs_future_deadline_as_paused_until() {
203 let (_dir, path) = temp_file();
206 let clock = FakeClock::now();
207 let now_epoch = now_epoch_secs_with(&clock);
208 pause_store::save(
209 &path,
210 &pause_store::PauseSnapshot {
211 paused: true,
212 until_epoch_secs: Some(now_epoch + 3_600),
213 },
214 )
215 .unwrap();
216 let state = restore_pause_state_with(&path, &clock);
217 match state {
218 PauseState::PausedUntil(Some(deadline)) => {
219 let remaining = deadline.saturating_duration_since(clock.instant_now());
220 assert_eq!(remaining.as_secs(), 3_600);
221 }
222 other => panic!("expected PausedUntil(Some), got {other:?}"),
223 }
224 }
225
226 #[test]
227 fn restore_clears_expired_deadline_and_returns_running() {
228 let (_dir, path) = temp_file();
234 let clock = FakeClock::now();
235 let now_epoch = now_epoch_secs_with(&clock);
236 pause_store::save(
237 &path,
238 &pause_store::PauseSnapshot {
239 paused: true,
240 until_epoch_secs: Some(now_epoch.saturating_sub(3_600)),
241 },
242 )
243 .unwrap();
244 let state = restore_pause_state_with(&path, &clock);
245 assert!(
246 matches!(state, PauseState::Running),
247 "expired deadline must auto-resume"
248 );
249 let reloaded = pause_store::load(&path);
251 assert!(!reloaded.paused, "expired snapshot must be cleared on disk");
252 assert!(reloaded.until_epoch_secs.is_none());
253 }
254
255 #[test]
256 fn snapshot_from_running_state_is_unpaused() {
257 let snap = snapshot_from_state(&PauseState::Running);
258 assert!(!snap.paused);
259 assert!(snap.until_epoch_secs.is_none());
260 }
261
262 #[test]
263 fn snapshot_from_indefinite_paused_state() {
264 let snap = snapshot_from_state(&PauseState::PausedUntil(None));
265 assert!(snap.paused);
266 assert!(snap.until_epoch_secs.is_none());
267 }
268
269 #[test]
270 fn snapshot_from_timed_pause_uses_absolute_epoch() {
271 let clock = FakeClock::now();
275 let deadline = clock.instant_now() + Duration::from_secs(120);
276 let snap = snapshot_from_state_with(&PauseState::PausedUntil(Some(deadline)), &clock);
277 assert!(snap.paused);
278 let expected = now_epoch_secs_with(&clock) + 120;
279 assert_eq!(snap.until_epoch_secs, Some(expected));
280 }
281}