Skip to main content

dead_man_switch/
timer.rs

1//! Timer implementations
2//!
3//! Timers are created using the [`Timer`] struct.
4//!
5//! There are two types of timers:
6//!
7//! 1. The [`TimerType::Warning`] timer that emits a warning to the user's
8//!    configured `From` email address upon expiration.
9//! 1. The [`TimerType::DeadMan`] timer that will trigger the message and optional
10//!    attachment to the user's configured `To` email address upon expiration.
11
12use crate::app;
13use crate::config::Config;
14use crate::error::TimerError;
15
16use chrono::Duration as ChronoDuration;
17use serde::{Deserialize, Serialize};
18use std::fs::{self, File};
19use std::io::Write;
20use std::path::PathBuf;
21use std::sync::{mpsc, OnceLock};
22use std::thread;
23use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
24use tracing::{error, trace, warn};
25
26static PERSIST_SENDER: OnceLock<mpsc::Sender<State>> = OnceLock::new();
27
28const DEFAULT_DEBOUNCE_MS: u64 = 300;
29
30/// The timer enum.
31///
32/// See [`timer`](crate::timer) module for more information.
33#[derive(Debug, Default, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
34pub enum TimerType {
35    /// The warning timer.
36    #[default]
37    Warning,
38    /// Dead Man's Switch timer.
39    DeadMan,
40}
41
42/// The state struct.
43///
44/// Holds the [`TimerType`] and last modified time.
45#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq)]
46pub struct State {
47    /// The timer type.
48    timer_type: TimerType,
49    /// The last modified time.
50    last_modified: u64,
51}
52
53/// The timer struct.
54///
55/// Holds the [`TimerType`], current start and expiration times.
56/// See [`timer`](crate::timer) module for more information.
57#[derive(Debug, Clone, PartialEq)]
58pub struct Timer {
59    /// The timer type.
60    timer_type: TimerType,
61    /// The start time.
62    start: Instant,
63    /// The duration.
64    duration: Duration,
65}
66
67impl Timer {
68    /// Create a new timer.
69    pub fn new(config: &Config) -> Result<Self, TimerError> {
70        let timer = load_or_initialize(config)?;
71
72        Ok(timer)
73    }
74
75    /// Get the type of the timer.
76    /// Returns [`TimerType`].
77    pub fn get_type(&self) -> TimerType {
78        self.timer_type
79    }
80
81    /// Get the elapsed time.
82    pub fn elapsed(&self) -> Duration {
83        Instant::now().duration_since(self.start)
84    }
85
86    /// Calculate the remaining time
87    pub fn remaining_chrono(&self) -> ChronoDuration {
88        let elapsed = self.elapsed();
89        if elapsed < self.duration {
90            let remaining = self.duration.saturating_sub(elapsed);
91            return ChronoDuration::try_seconds(remaining.as_secs() as i64)
92                .unwrap_or(ChronoDuration::zero());
93        }
94
95        ChronoDuration::zero()
96    }
97
98    /// Calculate the remaining time as a percentage
99    pub fn remaining_percent(&self) -> u16 {
100        let remaining_chrono = self.remaining_chrono();
101
102        if remaining_chrono > ChronoDuration::zero() {
103            return (remaining_chrono.num_seconds() as f64 / self.duration.as_secs() as f64 * 100.0)
104                as u16;
105        }
106
107        0
108    }
109
110    /// Update label based on the remaining time
111    pub fn label(&self) -> String {
112        let remaining_chrono = self.remaining_chrono();
113        if remaining_chrono > ChronoDuration::zero() {
114            return format_duration(remaining_chrono);
115        }
116
117        "0 second(s)".to_string()
118    }
119
120    /// Update the timer logic for switching from [`TimerType::Warning`] to
121    /// [`TimerType::DeadMan`].
122    pub fn update(&mut self, elapsed: Duration, dead_man_duration: u64) -> Result<(), TimerError> {
123        if self.timer_type == TimerType::Warning && elapsed >= self.duration {
124            self.timer_type = TimerType::DeadMan;
125            // Reset the start time for the DeadMan timer
126            self.start = Instant::now();
127            self.duration = Duration::from_secs(dead_man_duration);
128
129            let state = state_from_timer(self)?;
130            persist_state_non_blocking(state)?;
131        }
132
133        Ok(())
134    }
135
136    /// Check if the timer has expired.
137    pub fn expired(&self) -> bool {
138        self.elapsed() >= self.duration
139    }
140
141    /// Reset the timer and promote the timer type from [`TimerType::DeadMan`]
142    /// to [`TimerType::Warning`], if applicable.
143    ///
144    /// This is called when the user checks in.
145    pub fn reset(&mut self, config: &crate::config::Config) -> Result<(), TimerError> {
146        let (timer, state) = default_timer_and_state(config)?;
147        self.timer_type = timer.timer_type;
148        self.start = timer.start;
149        self.duration = timer.duration;
150
151        persist_state_non_blocking(state)?;
152
153        Ok(())
154    }
155}
156
157/// Formats a duration into a human-readable string adjusting the resolution based on the duration.
158fn format_duration(duration: ChronoDuration) -> String {
159    let days = duration.num_days();
160    let hours = duration.num_hours() % 24;
161    let minutes = duration.num_minutes() % 60;
162    let seconds = duration.num_seconds() % 60;
163
164    let mut parts = vec![];
165
166    if days > 0 {
167        parts.push(format!("{days} day(s)"));
168    }
169    if hours > 0 {
170        parts.push(format!("{hours} hour(s)"));
171    }
172    if minutes > 0 {
173        parts.push(format!("{minutes} minute(s)"));
174    }
175    if seconds > 0 || parts.is_empty() {
176        parts.push(format!("{} second(s)", seconds));
177    }
178
179    parts.join(", ")
180}
181
182/// Load or initialize the persisted state file.
183///
184/// # Errors
185///
186/// - Fails if the home directory cannot be found
187/// - Fails if the state directory cannot be created
188/// - Fails if the state file is not writeable
189///
190pub fn load_or_initialize(config: &Config) -> Result<Timer, TimerError> {
191    let file_path = file_path()?;
192
193    // try to read persisted state; if it fails, fall back to default timer
194    let (timer, state) = match fs::read_to_string(&file_path) {
195        Ok(state_str) => {
196            match toml::from_str::<State>(state_str.as_str()) {
197                Ok(state) => {
198                    // persistence file OK - setup timer based on persisted state
199                    let wall_elapsed = wall_elapsed(state.last_modified);
200                    // Build a monotonic start Instant such that
201                    // Instant::now() - start == wall_elapsed,
202                    // but guard against underflow using checked_sub.
203                    let now_mono = Instant::now();
204                    let start = match now_mono.checked_sub(wall_elapsed) {
205                        Some(s) => s,
206                        None => now_mono, // clamp to zero elapsed
207                    };
208                    let timer = Timer {
209                        timer_type: state.timer_type,
210                        start,
211                        duration: match state.timer_type {
212                            TimerType::Warning => Duration::from_secs(config.timer_warning),
213                            TimerType::DeadMan => Duration::from_secs(config.timer_dead_man),
214                        },
215                    };
216                    (timer, state)
217                }
218                Err(e) => {
219                    // fall back to default (same behaviour as "no persistence file")
220                    warn!(
221                        error = ?e,
222                        path = %file_path.display(),
223                        "persisted file parse error: using defaults"
224                    );
225                    default_timer_and_state(config)?
226                }
227            }
228        }
229        Err(_) => {
230            // fall back to default (same behaviour as "no persistence file")
231            trace!(
232                path = %file_path.display(),
233                "no persisted file found: using defaults"
234            );
235            default_timer_and_state(config)?
236        }
237    };
238
239    // set the initial state for persistence worker
240    let _ = PERSIST_SENDER.get_or_init(|| spawn_persistence_worker(Some(state.clone())));
241
242    // ensure we can write state to file (even if it's just been read)
243    // this is a chance to verify write-ability (future writes will be handled by background task)
244    persist_state_blocking(state)?;
245
246    Ok(timer)
247}
248
249fn default_timer_and_state(config: &Config) -> Result<(Timer, State), TimerError> {
250    let timer = Timer {
251        timer_type: TimerType::Warning,
252        start: Instant::now(),
253        duration: Duration::from_secs(config.timer_warning),
254    };
255    let state = state_from_timer(&timer)?;
256
257    Ok((timer, state))
258}
259
260/// Returns the name of the persisted state file.
261fn file_name() -> &'static str {
262    "state.toml"
263}
264
265/// Get the persisted state file path.
266///
267/// # Errors
268///
269/// - Fails if the home directory cannot be found
270///
271pub fn file_path() -> Result<PathBuf, TimerError> {
272    let path = app::file_path(file_name())?;
273
274    Ok(path)
275}
276
277// wall clock
278pub fn system_time_epoch() -> Result<Duration, TimerError> {
279    let now_wall = SystemTime::now()
280        .duration_since(UNIX_EPOCH)
281        .map_err(TimerError::SystemTime)?;
282
283    Ok(now_wall)
284}
285
286/// Compute wall elapsed since last modified
287///
288/// If last_modified is in the future (relative to current wall clock)
289/// clamp to zero so we don't artificially extend the timer
290fn wall_elapsed(last_modified: u64) -> Duration {
291    let persisted_last_modified = Duration::from_secs(last_modified);
292    let now_wall = system_time_epoch().unwrap_or(Duration::from_secs(0));
293
294    if now_wall > persisted_last_modified {
295        now_wall - persisted_last_modified
296    } else {
297        Duration::from_secs(0)
298    }
299}
300
301fn state_from_timer(timer: &Timer) -> Result<State, TimerError> {
302    let state = State {
303        timer_type: timer.timer_type,
304        last_modified: system_time_epoch()?.as_secs(),
305    };
306
307    Ok(state)
308}
309
310/// Blocking persistence of state.
311///
312/// - Only used at app initialisation (allows foreground verification of persistence)
313///
314pub fn persist_state_blocking(state: State) -> Result<(), TimerError> {
315    let path = file_path()?;
316    if let Err(e) = persist_state_to_path(&path, &state) {
317        error!(error = ?e, path = %path.display(), "persist new state failed");
318        return Err(e);
319    }
320
321    Ok(())
322}
323
324/// Non-blocking persistence of state.
325///
326/// - Only persists if the state has changed (debounced)
327/// - Uses a worker to offload tasks from the main thread
328///
329fn persist_state_non_blocking(state: State) -> Result<(), TimerError> {
330    let sender = PERSIST_SENDER
331        .get_or_init(|| spawn_persistence_worker(Some(state.clone())))
332        .clone();
333    if let Err(e) = sender.send(state) {
334        error!(error = ?e, "failed to enqueue persistence of state; background worker may have stopped");
335    }
336    Ok(())
337}
338
339fn spawn_persistence_worker(state: Option<State>) -> mpsc::Sender<State> {
340    let (tx, rx) = mpsc::channel::<State>();
341
342    thread::spawn(move || {
343        let mut last_written: Option<State> = state;
344        let debounce = Duration::from_millis(DEFAULT_DEBOUNCE_MS);
345
346        while let Ok(mut snapshot) = rx.recv() {
347            // Drain immediately to get the latest snapshot available now.
348            while let Ok(next) = rx.try_recv() {
349                snapshot = next;
350            }
351
352            // Debounce window: drain any additional updates arriving shortly.
353            let start = Instant::now();
354            while start.elapsed() < debounce {
355                if let Ok(next) = rx.try_recv() {
356                    snapshot = next;
357                } else {
358                    // small sleep to avoid busy-wait
359                    thread::sleep(Duration::from_millis(10));
360                }
361            }
362
363            // Skip write if identical to last written
364            if last_written.as_ref() == Some(&snapshot) {
365                trace!("skipping persist state: identical to last written");
366                continue;
367            }
368
369            if let Ok(path) = file_path() {
370                if let Err(e) = persist_state_to_path(&path, &snapshot) {
371                    error!(error = ?e, path = %path.display(), "persist new state failed");
372                } else {
373                    trace!(path = %path.display(), "persisted new state");
374                    last_written = Some(snapshot);
375                }
376            }
377        }
378    });
379
380    tx
381}
382
383fn persist_state_to_path(path: &PathBuf, state: &State) -> Result<(), TimerError> {
384    let state_str = toml::to_string(state)?;
385    write_atomic(path, state_str.as_bytes())
386}
387
388fn write_atomic(path: &PathBuf, data: &[u8]) -> Result<(), TimerError> {
389    let tmp_path = path.with_extension("tmp");
390    {
391        let mut tmp = File::create(&tmp_path)?;
392        tmp.write_all(data)?;
393        tmp.sync_all()?;
394    }
395    fs::rename(&tmp_path, path)?;
396    Ok(())
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402    use crate::config::{self, Config};
403    use std::fs::{self, File};
404    use std::io::Write;
405    use std::ops::Sub;
406    use std::path::Path;
407    use std::thread::sleep;
408
409    fn get_test_config() -> Config {
410        Config {
411            username: "user@example.com".to_string(),
412            password: "password".to_string(),
413            smtp_server: "smtp.example.com".to_string(),
414            smtp_port: 587,
415            smtp_check_timeout: Some(5),
416            message: "This is a test message".to_string(),
417            message_warning: "This is a test warning message".to_string(),
418            subject: "Test Subject".to_string(),
419            subject_warning: "Test Warning Subject".to_string(),
420            to: "recipient@example.com".to_string(),
421            from: "sender@example.com".to_string(),
422            attachment: None,
423            timer_warning: 60,
424            timer_dead_man: 120,
425            web_password: "password".to_string(),
426            cookie_exp_days: 7,
427            log_level: None,
428        }
429    }
430
431    struct TestGuard;
432    impl TestGuard {
433        fn new(s: &State) -> Self {
434            // setup before test
435
436            let file_path = file_path().expect("setup: failed file_path()");
437
438            // Ensure parent directory exists
439            if let Some(parent) = file_path.parent() {
440                fs::create_dir_all(parent).expect("setup: failed to create dir");
441            }
442            let mut file = File::create(file_path).expect("setup: failed to create file");
443            let s_str = toml::to_string(s).expect("setup: failed to convert data");
444            file.write_all(s_str.as_bytes())
445                .expect("setup: failed to write data");
446            file.sync_all()
447                .expect("setup: failed to ensure file written to disk");
448
449            TestGuard
450        }
451    }
452    impl Drop for TestGuard {
453        fn drop(&mut self) {
454            // clean-up after a test
455            let file_path = file_path().expect("teardown: failed file_path()");
456            cleanup_test_dir_parent(file_path.as_path());
457        }
458    }
459
460    // helper
461    fn cleanup_test_dir(dir: &Path) {
462        if let Some(parent) = dir.parent() {
463            let _ = fs::remove_dir_all(parent);
464        }
465    }
466
467    // helper
468    fn cleanup_test_dir_parent(dir: &Path) {
469        if let Some(parent) = dir.parent() {
470            cleanup_test_dir(parent)
471        }
472    }
473
474    // helper
475    fn load_state_from_path(path: &PathBuf) -> State {
476        let state_str = fs::read_to_string(path).expect("helper: error reading state data");
477        let state: State = toml::from_str(&state_str).expect("helper: error parsing state data");
478        state
479    }
480
481    #[test]
482    fn file_path_in_test_mode() {
483        // This test verifies that file_path() uses temp directory in test mode
484        let result = file_path();
485        assert!(result.is_ok());
486
487        let expected = format!("{}_test", app::name());
488        let result = result.unwrap();
489        assert!(result.to_string_lossy().contains(expected.as_str()));
490
491        // It should also of course contain the actual file name
492        let expected = Path::new(app::name()).join(file_name());
493        assert!(result
494            .to_string_lossy()
495            .contains(expected.to_string_lossy().as_ref()));
496
497        // Cleanup any created directories
498        cleanup_test_dir_parent(&result);
499    }
500
501    #[test]
502    fn generates_system_time_epoch() {
503        let result = system_time_epoch();
504        assert!(result.is_ok());
505
506        let expected_range_low = 1767225600; // 2026-01-01 00:00:00
507        let expected_range_high = 4102444800; // 2100-01-01 00:00:00
508        let result = result.unwrap().as_secs();
509        assert!(
510            result >= expected_range_low && result <= expected_range_high,
511            "expected: between {:?} (2026-01-01 00:00:00) and {:?} (2100-01-01 00:00:00) (got: {:?})",
512            expected_range_low,
513            expected_range_high,
514            result
515        );
516    }
517
518    #[test]
519    fn update_persisted_state_ok() {
520        // Set state for this test
521        let state = State {
522            timer_type: TimerType::Warning,
523            last_modified: 1,
524        };
525
526        let result = persist_state_blocking(state.clone());
527        assert!(result.is_ok());
528
529        let test_path = file_path().unwrap();
530        let loaded_state = load_state_from_path(&test_path);
531        // Compare against the original state instance
532        assert_eq!(loaded_state, state);
533
534        // Cleanup any created directories
535        cleanup_test_dir_parent(&test_path);
536    }
537
538    #[test]
539    fn state_guard_ok() {
540        // This test verifies that the guard is working as expected
541        // by saving a state and reading it back
542
543        // Set state for this test
544        let state = State {
545            timer_type: TimerType::DeadMan,
546            last_modified: 2,
547        };
548        let _guard = TestGuard::new(&state);
549
550        // Compare against the same state instance saved by guard
551        let test_path = file_path().unwrap();
552        let loaded_state = load_state_from_path(&test_path);
553        assert_eq!(loaded_state, state);
554    }
555
556    #[test]
557    fn load_or_initialize_with_existing_file() {
558        let config = Config::default();
559
560        // Set state for this test
561        let existing_state_1 = State {
562            timer_type: TimerType::Warning,
563            last_modified: 3,
564        };
565        let existing_state_2 = State {
566            timer_type: TimerType::DeadMan,
567            last_modified: 4,
568        };
569
570        let _guard_1 = TestGuard::new(&existing_state_1);
571
572        // With state data persisted, we should see a timer with those values
573        let timer = load_or_initialize(&config).unwrap();
574
575        // Compare loaded timer against the existing state data that was saved
576        assert_eq!(timer.timer_type, existing_state_1.timer_type);
577
578        let _guard_2 = TestGuard::new(&existing_state_2);
579
580        // With state data persisted, we should see a timer with those values
581        let timer = load_or_initialize(&config).unwrap();
582
583        // Compare loaded timer against the existing state data that was saved
584        assert_eq!(timer.timer_type, existing_state_2.timer_type);
585    }
586
587    #[test]
588    fn load_or_initialize_with_no_existing_file() {
589        // get default config so we can use its timer_warning later
590        let config = Config::default();
591
592        // With no previous data persisted, we should see a timer with defaults
593        let timer = load_or_initialize(&config).unwrap();
594
595        let expected = TimerType::Warning;
596        let result = timer.timer_type;
597        assert!(
598            result == expected,
599            "expected: '{:?}' got: '{:?}')",
600            expected,
601            result
602        );
603
604        let expected = Duration::from_secs(config.timer_warning);
605        let result = timer.duration;
606        assert!(
607            result == expected,
608            "expected: '{:?}' got: '{:?}')",
609            expected,
610            result
611        );
612
613        // Cleanup any created directories
614        let test_path = file_path().unwrap();
615        cleanup_test_dir_parent(&test_path);
616    }
617
618    #[test]
619    fn timer_remaining_chrono_with_state_in_past() {
620        let default_config = config::load_or_initialize().expect("failed to load default config");
621
622        let now_wall = system_time_epoch()
623            .expect("failed to get current time")
624            .as_secs();
625
626        // Set state for this test
627        let _guard = TestGuard::new(&State {
628            timer_type: TimerType::Warning,
629            last_modified: now_wall - 10000,
630        });
631
632        let timer = Timer::new(&default_config).expect("failed to create new timer");
633
634        let tolerance_secs = 5;
635        let expected_range_high =
636            chrono::TimeDelta::new((default_config.timer_warning - 10000) as i64, 0)
637                .expect("failed creating time delta");
638        let expected_range_low = expected_range_high
639            .sub(chrono::TimeDelta::new(tolerance_secs, 0).expect("failed creating time delta"));
640        let result = timer.remaining_chrono();
641        // result should be in the range (tolerance for slow tests)
642        assert!(
643            result >= expected_range_low && result <= expected_range_high,
644            "expected: timer.remaining_chrono() between {:?} and {:?} (got: {:?})",
645            expected_range_low,
646            expected_range_high,
647            result
648        );
649    }
650
651    #[test]
652    fn timer_remaining_chrono_with_state_in_future() {
653        let default_config = config::load_or_initialize().expect("failed to load default config");
654
655        let now_wall = system_time_epoch()
656            .expect("failed to get current time")
657            .as_secs();
658
659        // Set state for this test
660        // a system time in the future should not increase remaining chrono time
661        let _guard = TestGuard::new(&State {
662            timer_type: TimerType::Warning,
663            last_modified: now_wall + 1000,
664        });
665
666        let timer = Timer::new(&default_config).expect("failed to create new timer");
667
668        let tolerance_secs = 5;
669        let expected_range_high = chrono::TimeDelta::new((default_config.timer_warning) as i64, 0)
670            .expect("failed creating time delta");
671        let expected_range_low = expected_range_high
672            .sub(chrono::TimeDelta::new(tolerance_secs, 0).expect("failed creating time delta"));
673        // result should be in the range (tolerance for slow tests)
674        let result = timer.remaining_chrono();
675        assert!(
676            result >= expected_range_low && result <= expected_range_high,
677            "expected: timer.remaining_chrono() between {:?} and {:?} (got: {:?})",
678            expected_range_low,
679            expected_range_high,
680            result
681        );
682    }
683
684    #[test]
685    fn timer_remaining_chrono_with_state() {
686        let mut config = get_test_config();
687
688        let now_wall = system_time_epoch()
689            .expect("failed to get current time")
690            .as_secs();
691
692        // Set state for this test
693        let _guard = TestGuard::new(&State {
694            timer_type: TimerType::Warning,
695            last_modified: now_wall,
696        });
697
698        let mut timer = Timer::new(&config).expect("failed to create new timer");
699
700        config.timer_warning = 2;
701        // reset allows injection of (test) config
702        timer.reset(&config).expect("failed to reset timer");
703
704        let expected = chrono::TimeDelta::new(2, 0).expect("failed creating time delta");
705        let result = timer.remaining_chrono();
706        assert!(
707            result <= expected,
708            "expected: timer.remaining_chrono() < {:?} (got: {:?})",
709            expected,
710            result
711        );
712
713        sleep(Duration::from_secs(2));
714
715        let expected = chrono::TimeDelta::new(1, 0).expect("failed creating time delta");
716        let result = timer.remaining_chrono();
717        assert!(
718            result <= expected,
719            "expected: timer.remaining_chrono() < {:?} (got: {:?})",
720            expected,
721            result
722        );
723    }
724
725    #[test]
726    fn timer_elapsed_less_than_duration() {
727        let mut config = get_test_config();
728
729        let now_wall = system_time_epoch()
730            .expect("failed to get current time")
731            .as_secs();
732
733        // Set state for this test
734        let _guard = TestGuard::new(&State {
735            timer_type: TimerType::Warning,
736            last_modified: now_wall,
737        });
738
739        let mut timer = Timer::new(&config).expect("failed to create new timer");
740
741        config.timer_warning = 60;
742        // reset allows injection of (test) config
743        timer.reset(&config).expect("failed to reset timer");
744
745        let expected = Duration::from_secs(2);
746        let result = timer.elapsed();
747        assert!(
748            result < expected,
749            "expected: timer.elapsed() < {:?} (got: {:?})",
750            expected,
751            result
752        );
753
754        sleep(Duration::from_secs_f64(1.5));
755
756        let expected = Duration::from_secs(1);
757        let result = timer.elapsed();
758        assert!(
759            result > expected,
760            "expected: timer.elapsed() > {:?} (got: {:?})",
761            expected,
762            result
763        );
764    }
765
766    #[test]
767    fn timer_update_to_dead_man() {
768        let mut config = get_test_config();
769
770        let now_wall = system_time_epoch()
771            .expect("failed to get current time")
772            .as_secs();
773
774        // Set state for this test
775        let _guard = TestGuard::new(&State {
776            timer_type: TimerType::Warning,
777            last_modified: now_wall,
778        });
779
780        let mut timer = Timer::new(&config).expect("failed to create new timer");
781
782        config.timer_warning = 1;
783        // reset allows injection of (test) config
784        timer.reset(&config).expect("failed to reset timer");
785
786        // verify timer still in TimerType::Warning
787        let expected = TimerType::Warning;
788        let result = timer.timer_type;
789        assert!(
790            result == expected,
791            "expected: '{:?}' got: '{:?}')",
792            expected,
793            result
794        );
795
796        // verify timer still has expected timer_warning
797        let expected = Duration::from_secs(config.timer_warning);
798        let result = timer.duration;
799        assert!(
800            result == expected,
801            "expected: '{:?}' got: '{:?}')",
802            expected,
803            result
804        );
805
806        // Simulate elapsed time by directly manipulating the timer's state.
807        timer
808            .update(Duration::from_secs(2), 3600)
809            .expect("Failed to update timer");
810
811        let expected = TimerType::DeadMan;
812        let result = timer.get_type();
813        assert!(
814            result == expected,
815            "expected: '{:?}' got: '{:?}')",
816            expected,
817            result
818        );
819
820        let expected = Duration::from_secs(3600);
821        let result = timer.duration;
822        assert!(
823            result == expected,
824            "expected: '{:?}' got: '{:?}')",
825            expected,
826            result
827        );
828
829        let expected = false;
830        let result = timer.expired();
831        assert!(
832            result == expected,
833            "expected: '{:?}' got: '{:?}')",
834            expected,
835            result
836        );
837    }
838
839    #[test]
840    fn timer_expiration() {
841        let mut config = get_test_config();
842
843        let now_wall = system_time_epoch()
844            .expect("failed to get current time")
845            .as_secs();
846
847        // Set state for this test
848        let _guard = TestGuard::new(&State {
849            timer_type: TimerType::Warning,
850            last_modified: now_wall,
851        });
852
853        let mut timer = Timer::new(&config).expect("failed to create new timer");
854
855        config.timer_warning = 1;
856        // reset allows injection of (test) config
857        timer.reset(&config).expect("failed to reset timer");
858
859        // verify timer still in TimerType::Warning
860        let expected = TimerType::Warning;
861        let result = timer.timer_type;
862        assert!(
863            result == expected,
864            "expected: '{:?}' got: '{:?}')",
865            expected,
866            result
867        );
868
869        // verify timer still has expected timer_warning
870        let expected = Duration::from_secs(config.timer_warning);
871        let result = timer.duration;
872        assert!(
873            result == expected,
874            "expected: '{:?}' got: '{:?}')",
875            expected,
876            result
877        );
878
879        // Simulate elapsed time by directly manipulating the timer's state.
880        timer
881            .update(Duration::from_secs(2), 1)
882            .expect("Failed to update timer");
883
884        // Directly simulate the passage of time
885        sleep(Duration::from_secs(2));
886
887        let expected = true;
888        let result = timer.expired();
889        assert!(
890            result == expected,
891            "expected: '{:?}' got: '{:?}')",
892            expected,
893            result
894        );
895    }
896
897    #[test]
898    fn timer_remaining_percent() {
899        let mut config = get_test_config();
900
901        let now_wall = system_time_epoch()
902            .expect("failed to get current time")
903            .as_secs();
904
905        // Set state for this test
906        let _guard = TestGuard::new(&State {
907            timer_type: TimerType::Warning,
908            last_modified: now_wall,
909        });
910
911        let mut timer = Timer::new(&config).expect("failed to create new timer");
912
913        config.timer_warning = 2;
914        // reset allows injection of (test) config
915        timer.reset(&config).expect("failed to reset timer");
916
917        let expected = 0;
918        let result = timer.remaining_percent();
919        assert!(
920            result > expected,
921            "expected: timer.remaining_percent() > {:?} (got: {:?})",
922            expected,
923            result
924        );
925
926        // Directly simulate the passage of time
927        sleep(Duration::from_secs(2));
928
929        let result = timer.remaining_percent();
930        assert!(
931            result == expected,
932            "expected: timer.remaining_percent() == {:?} (got: {:?})",
933            expected,
934            result
935        );
936    }
937
938    #[test]
939    fn timer_label() {
940        let mut config = get_test_config();
941
942        let now_wall = system_time_epoch()
943            .expect("failed to get current time")
944            .as_secs();
945
946        // Set state for this test
947        let _guard = TestGuard::new(&State {
948            timer_type: TimerType::Warning,
949            last_modified: now_wall,
950        });
951
952        let mut timer = Timer::new(&config).expect("failed to create new timer");
953
954        config.timer_warning = 2;
955        // reset allows injection of (test) config
956        timer.reset(&config).expect("failed to reset timer");
957
958        let expected = "0 second(s)";
959        let result = timer.label();
960        assert!(
961            result != expected,
962            "expected: timer.label() != {:?} (got: {:?})",
963            expected,
964            result
965        );
966
967        // Directly simulate the passage of time
968        sleep(Duration::from_secs(2));
969
970        let result = timer.label();
971        assert!(
972            result == expected,
973            "expected: timer.label() == {:?} (got: {:?})",
974            expected,
975            result
976        );
977    }
978
979    #[test]
980    fn format_seconds_only() {
981        let duration = ChronoDuration::try_seconds(45).unwrap();
982        assert_eq!(format_duration(duration), "45 second(s)");
983    }
984
985    #[test]
986    fn format_minutes_and_seconds() {
987        let duration =
988            ChronoDuration::try_minutes(5).unwrap() + ChronoDuration::try_seconds(30).unwrap();
989        assert_eq!(format_duration(duration), "5 minute(s), 30 second(s)");
990    }
991
992    #[test]
993    fn format_hours_minutes_and_seconds() {
994        let duration = ChronoDuration::try_hours(2).unwrap()
995            + ChronoDuration::try_minutes(15).unwrap()
996            + ChronoDuration::try_seconds(10).unwrap();
997        assert_eq!(
998            format_duration(duration),
999            "2 hour(s), 15 minute(s), 10 second(s)"
1000        );
1001    }
1002
1003    #[test]
1004    fn format_days_hours_minutes() {
1005        let duration = ChronoDuration::try_days(1).unwrap()
1006            + ChronoDuration::try_hours(3).unwrap()
1007            + ChronoDuration::try_minutes(45).unwrap();
1008        assert_eq!(
1009            format_duration(duration),
1010            "1 day(s), 3 hour(s), 45 minute(s)"
1011        );
1012    }
1013
1014    #[test]
1015    fn format_days_only() {
1016        let duration = ChronoDuration::try_days(4).unwrap();
1017        assert_eq!(format_duration(duration), "4 day(s)");
1018    }
1019
1020    #[test]
1021    fn format_large_duration() {
1022        let duration = ChronoDuration::try_days(7).unwrap()
1023            + ChronoDuration::try_hours(23).unwrap()
1024            + ChronoDuration::try_minutes(59).unwrap()
1025            + ChronoDuration::try_seconds(59).unwrap();
1026        assert_eq!(
1027            format_duration(duration),
1028            "7 day(s), 23 hour(s), 59 minute(s), 59 second(s)"
1029        );
1030    }
1031
1032    #[test]
1033    fn reset_warning_timer_resets_start_time() {
1034        let config = get_test_config();
1035
1036        let now_wall = system_time_epoch()
1037            .expect("failed to get current time")
1038            .as_secs();
1039
1040        // Set state for this test
1041        let _guard = TestGuard::new(&State {
1042            timer_type: TimerType::Warning,
1043            last_modified: now_wall - 60,
1044        });
1045
1046        let mut timer = Timer::new(&config).expect("failed to create new timer");
1047
1048        let original_start = timer.start;
1049
1050        timer.reset(&config).expect("failed to reset timer");
1051
1052        let expected = original_start;
1053        let result = timer.start;
1054        assert!(
1055            result > expected,
1056            "expected: timer.start > {:?} (got: {:?})",
1057            expected,
1058            result
1059        );
1060
1061        let expected = Duration::from_secs(config.timer_warning);
1062        let result = timer.duration;
1063        assert!(
1064            result == expected,
1065            "expected: timer.duration == {:?} (got: {:?})",
1066            expected,
1067            result
1068        );
1069
1070        let expected = TimerType::Warning;
1071        let result = timer.get_type();
1072        assert!(
1073            result == expected,
1074            "expected: timer.get_type() == {:?} (got: {:?})",
1075            expected,
1076            result
1077        );
1078    }
1079
1080    #[test]
1081    fn reset_dead_man_timer_promotes_to_warning_and_resets() {
1082        let config = get_test_config();
1083
1084        // Set state for this test
1085        let _guard = TestGuard::new(&State {
1086            timer_type: TimerType::DeadMan,
1087            last_modified: system_time_epoch()
1088                .expect("failed to get current time")
1089                .as_secs(),
1090        });
1091
1092        let mut timer = Timer::new(&config).expect("failed to create new timer");
1093
1094        timer.reset(&config).expect("failed to reset timer");
1095
1096        let expected = Duration::from_secs(config.timer_warning);
1097        let result = timer.duration;
1098        assert!(
1099            result == expected,
1100            "expected: timer.duration == {:?} (got: {:?})",
1101            expected,
1102            result
1103        );
1104
1105        let expected = TimerType::Warning;
1106        let result = timer.get_type();
1107        assert!(
1108            result == expected,
1109            "expected: timer.get_type() == {:?} (got: {:?})",
1110            expected,
1111            result
1112        );
1113    }
1114}