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::{OnceLock, mpsc};
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!(
494            result
495                .to_string_lossy()
496                .contains(expected.to_string_lossy().as_ref())
497        );
498
499        // Cleanup any created directories
500        cleanup_test_dir_parent(&result);
501    }
502
503    #[test]
504    fn generates_system_time_epoch() {
505        let result = system_time_epoch();
506        assert!(result.is_ok());
507
508        let expected_range_low = 1767225600; // 2026-01-01 00:00:00
509        let expected_range_high = 4102444800; // 2100-01-01 00:00:00
510        let result = result.unwrap().as_secs();
511        assert!(
512            result >= expected_range_low && result <= expected_range_high,
513            "expected: between {:?} (2026-01-01 00:00:00) and {:?} (2100-01-01 00:00:00) (got: {:?})",
514            expected_range_low,
515            expected_range_high,
516            result
517        );
518    }
519
520    #[test]
521    fn update_persisted_state_ok() {
522        // Set state for this test
523        let state = State {
524            timer_type: TimerType::Warning,
525            last_modified: 1,
526        };
527
528        let result = persist_state_blocking(state.clone());
529        assert!(result.is_ok());
530
531        let test_path = file_path().unwrap();
532        let loaded_state = load_state_from_path(&test_path);
533        // Compare against the original state instance
534        assert_eq!(loaded_state, state);
535
536        // Cleanup any created directories
537        cleanup_test_dir_parent(&test_path);
538    }
539
540    #[test]
541    fn state_guard_ok() {
542        // This test verifies that the guard is working as expected
543        // by saving a state and reading it back
544
545        // Set state for this test
546        let state = State {
547            timer_type: TimerType::DeadMan,
548            last_modified: 2,
549        };
550        let _guard = TestGuard::new(&state);
551
552        // Compare against the same state instance saved by guard
553        let test_path = file_path().unwrap();
554        let loaded_state = load_state_from_path(&test_path);
555        assert_eq!(loaded_state, state);
556    }
557
558    #[test]
559    fn load_or_initialize_with_existing_file() {
560        let config = Config::default();
561
562        // Set state for this test
563        let existing_state_1 = State {
564            timer_type: TimerType::Warning,
565            last_modified: 3,
566        };
567        let existing_state_2 = State {
568            timer_type: TimerType::DeadMan,
569            last_modified: 4,
570        };
571
572        let _guard_1 = TestGuard::new(&existing_state_1);
573
574        // With state data persisted, we should see a timer with those values
575        let timer = load_or_initialize(&config).unwrap();
576
577        // Compare loaded timer against the existing state data that was saved
578        assert_eq!(timer.timer_type, existing_state_1.timer_type);
579
580        let _guard_2 = TestGuard::new(&existing_state_2);
581
582        // With state data persisted, we should see a timer with those values
583        let timer = load_or_initialize(&config).unwrap();
584
585        // Compare loaded timer against the existing state data that was saved
586        assert_eq!(timer.timer_type, existing_state_2.timer_type);
587    }
588
589    #[test]
590    fn load_or_initialize_with_no_existing_file() {
591        // get default config so we can use its timer_warning later
592        let config = Config::default();
593
594        // With no previous data persisted, we should see a timer with defaults
595        let timer = load_or_initialize(&config).unwrap();
596
597        let expected = TimerType::Warning;
598        let result = timer.timer_type;
599        assert!(
600            result == expected,
601            "expected: '{:?}' got: '{:?}')",
602            expected,
603            result
604        );
605
606        let expected = Duration::from_secs(config.timer_warning);
607        let result = timer.duration;
608        assert!(
609            result == expected,
610            "expected: '{:?}' got: '{:?}')",
611            expected,
612            result
613        );
614
615        // Cleanup any created directories
616        let test_path = file_path().unwrap();
617        cleanup_test_dir_parent(&test_path);
618    }
619
620    #[test]
621    fn timer_remaining_chrono_with_state_in_past() {
622        let default_config = config::load_or_initialize().expect("failed to load default config");
623
624        let now_wall = system_time_epoch()
625            .expect("failed to get current time")
626            .as_secs();
627
628        // Set state for this test
629        let _guard = TestGuard::new(&State {
630            timer_type: TimerType::Warning,
631            last_modified: now_wall - 10000,
632        });
633
634        let timer = Timer::new(&default_config).expect("failed to create new timer");
635
636        let tolerance_secs = 5;
637        let expected_range_high =
638            chrono::TimeDelta::new((default_config.timer_warning - 10000) as i64, 0)
639                .expect("failed creating time delta");
640        let expected_range_low = expected_range_high
641            .sub(chrono::TimeDelta::new(tolerance_secs, 0).expect("failed creating time delta"));
642        let result = timer.remaining_chrono();
643        // result should be in the range (tolerance for slow tests)
644        assert!(
645            result >= expected_range_low && result <= expected_range_high,
646            "expected: timer.remaining_chrono() between {:?} and {:?} (got: {:?})",
647            expected_range_low,
648            expected_range_high,
649            result
650        );
651    }
652
653    #[test]
654    fn timer_remaining_chrono_with_state_in_future() {
655        let default_config = config::load_or_initialize().expect("failed to load default config");
656
657        let now_wall = system_time_epoch()
658            .expect("failed to get current time")
659            .as_secs();
660
661        // Set state for this test
662        // a system time in the future should not increase remaining chrono time
663        let _guard = TestGuard::new(&State {
664            timer_type: TimerType::Warning,
665            last_modified: now_wall + 1000,
666        });
667
668        let timer = Timer::new(&default_config).expect("failed to create new timer");
669
670        let tolerance_secs = 5;
671        let expected_range_high = chrono::TimeDelta::new((default_config.timer_warning) as i64, 0)
672            .expect("failed creating time delta");
673        let expected_range_low = expected_range_high
674            .sub(chrono::TimeDelta::new(tolerance_secs, 0).expect("failed creating time delta"));
675        // result should be in the range (tolerance for slow tests)
676        let result = timer.remaining_chrono();
677        assert!(
678            result >= expected_range_low && result <= expected_range_high,
679            "expected: timer.remaining_chrono() between {:?} and {:?} (got: {:?})",
680            expected_range_low,
681            expected_range_high,
682            result
683        );
684    }
685
686    #[test]
687    fn timer_remaining_chrono_with_state() {
688        let mut config = get_test_config();
689
690        let now_wall = system_time_epoch()
691            .expect("failed to get current time")
692            .as_secs();
693
694        // Set state for this test
695        let _guard = TestGuard::new(&State {
696            timer_type: TimerType::Warning,
697            last_modified: now_wall,
698        });
699
700        let mut timer = Timer::new(&config).expect("failed to create new timer");
701
702        config.timer_warning = 2;
703        // reset allows injection of (test) config
704        timer.reset(&config).expect("failed to reset timer");
705
706        let expected = chrono::TimeDelta::new(2, 0).expect("failed creating time delta");
707        let result = timer.remaining_chrono();
708        assert!(
709            result <= expected,
710            "expected: timer.remaining_chrono() < {:?} (got: {:?})",
711            expected,
712            result
713        );
714
715        sleep(Duration::from_secs(2));
716
717        let expected = chrono::TimeDelta::new(1, 0).expect("failed creating time delta");
718        let result = timer.remaining_chrono();
719        assert!(
720            result <= expected,
721            "expected: timer.remaining_chrono() < {:?} (got: {:?})",
722            expected,
723            result
724        );
725    }
726
727    #[test]
728    fn timer_elapsed_less_than_duration() {
729        let mut config = get_test_config();
730
731        let now_wall = system_time_epoch()
732            .expect("failed to get current time")
733            .as_secs();
734
735        // Set state for this test
736        let _guard = TestGuard::new(&State {
737            timer_type: TimerType::Warning,
738            last_modified: now_wall,
739        });
740
741        let mut timer = Timer::new(&config).expect("failed to create new timer");
742
743        config.timer_warning = 60;
744        // reset allows injection of (test) config
745        timer.reset(&config).expect("failed to reset timer");
746
747        let expected = Duration::from_secs(2);
748        let result = timer.elapsed();
749        assert!(
750            result < expected,
751            "expected: timer.elapsed() < {:?} (got: {:?})",
752            expected,
753            result
754        );
755
756        sleep(Duration::from_secs_f64(1.5));
757
758        let expected = Duration::from_secs(1);
759        let result = timer.elapsed();
760        assert!(
761            result > expected,
762            "expected: timer.elapsed() > {:?} (got: {:?})",
763            expected,
764            result
765        );
766    }
767
768    #[test]
769    fn timer_update_to_dead_man() {
770        let mut config = get_test_config();
771
772        let now_wall = system_time_epoch()
773            .expect("failed to get current time")
774            .as_secs();
775
776        // Set state for this test
777        let _guard = TestGuard::new(&State {
778            timer_type: TimerType::Warning,
779            last_modified: now_wall,
780        });
781
782        let mut timer = Timer::new(&config).expect("failed to create new timer");
783
784        config.timer_warning = 1;
785        // reset allows injection of (test) config
786        timer.reset(&config).expect("failed to reset timer");
787
788        // verify timer still in TimerType::Warning
789        let expected = TimerType::Warning;
790        let result = timer.timer_type;
791        assert!(
792            result == expected,
793            "expected: '{:?}' got: '{:?}')",
794            expected,
795            result
796        );
797
798        // verify timer still has expected timer_warning
799        let expected = Duration::from_secs(config.timer_warning);
800        let result = timer.duration;
801        assert!(
802            result == expected,
803            "expected: '{:?}' got: '{:?}')",
804            expected,
805            result
806        );
807
808        // Simulate elapsed time by directly manipulating the timer's state.
809        timer
810            .update(Duration::from_secs(2), 3600)
811            .expect("Failed to update timer");
812
813        let expected = TimerType::DeadMan;
814        let result = timer.get_type();
815        assert!(
816            result == expected,
817            "expected: '{:?}' got: '{:?}')",
818            expected,
819            result
820        );
821
822        let expected = Duration::from_secs(3600);
823        let result = timer.duration;
824        assert!(
825            result == expected,
826            "expected: '{:?}' got: '{:?}')",
827            expected,
828            result
829        );
830
831        let expected = false;
832        let result = timer.expired();
833        assert!(
834            result == expected,
835            "expected: '{:?}' got: '{:?}')",
836            expected,
837            result
838        );
839    }
840
841    #[test]
842    fn timer_expiration() {
843        let mut config = get_test_config();
844
845        let now_wall = system_time_epoch()
846            .expect("failed to get current time")
847            .as_secs();
848
849        // Set state for this test
850        let _guard = TestGuard::new(&State {
851            timer_type: TimerType::Warning,
852            last_modified: now_wall,
853        });
854
855        let mut timer = Timer::new(&config).expect("failed to create new timer");
856
857        config.timer_warning = 1;
858        // reset allows injection of (test) config
859        timer.reset(&config).expect("failed to reset timer");
860
861        // verify timer still in TimerType::Warning
862        let expected = TimerType::Warning;
863        let result = timer.timer_type;
864        assert!(
865            result == expected,
866            "expected: '{:?}' got: '{:?}')",
867            expected,
868            result
869        );
870
871        // verify timer still has expected timer_warning
872        let expected = Duration::from_secs(config.timer_warning);
873        let result = timer.duration;
874        assert!(
875            result == expected,
876            "expected: '{:?}' got: '{:?}')",
877            expected,
878            result
879        );
880
881        // Simulate elapsed time by directly manipulating the timer's state.
882        timer
883            .update(Duration::from_secs(2), 1)
884            .expect("Failed to update timer");
885
886        // Directly simulate the passage of time
887        sleep(Duration::from_secs(2));
888
889        let expected = true;
890        let result = timer.expired();
891        assert!(
892            result == expected,
893            "expected: '{:?}' got: '{:?}')",
894            expected,
895            result
896        );
897    }
898
899    #[test]
900    fn timer_remaining_percent() {
901        let mut config = get_test_config();
902
903        let now_wall = system_time_epoch()
904            .expect("failed to get current time")
905            .as_secs();
906
907        // Set state for this test
908        let _guard = TestGuard::new(&State {
909            timer_type: TimerType::Warning,
910            last_modified: now_wall,
911        });
912
913        let mut timer = Timer::new(&config).expect("failed to create new timer");
914
915        config.timer_warning = 2;
916        // reset allows injection of (test) config
917        timer.reset(&config).expect("failed to reset timer");
918
919        let expected = 0;
920        let result = timer.remaining_percent();
921        assert!(
922            result > expected,
923            "expected: timer.remaining_percent() > {:?} (got: {:?})",
924            expected,
925            result
926        );
927
928        // Directly simulate the passage of time
929        sleep(Duration::from_secs(2));
930
931        let result = timer.remaining_percent();
932        assert!(
933            result == expected,
934            "expected: timer.remaining_percent() == {:?} (got: {:?})",
935            expected,
936            result
937        );
938    }
939
940    #[test]
941    fn timer_label() {
942        let mut config = get_test_config();
943
944        let now_wall = system_time_epoch()
945            .expect("failed to get current time")
946            .as_secs();
947
948        // Set state for this test
949        let _guard = TestGuard::new(&State {
950            timer_type: TimerType::Warning,
951            last_modified: now_wall,
952        });
953
954        let mut timer = Timer::new(&config).expect("failed to create new timer");
955
956        config.timer_warning = 2;
957        // reset allows injection of (test) config
958        timer.reset(&config).expect("failed to reset timer");
959
960        let expected = "0 second(s)";
961        let result = timer.label();
962        assert!(
963            result != expected,
964            "expected: timer.label() != {:?} (got: {:?})",
965            expected,
966            result
967        );
968
969        // Directly simulate the passage of time
970        sleep(Duration::from_secs(2));
971
972        let result = timer.label();
973        assert!(
974            result == expected,
975            "expected: timer.label() == {:?} (got: {:?})",
976            expected,
977            result
978        );
979    }
980
981    #[test]
982    fn format_seconds_only() {
983        let duration = ChronoDuration::try_seconds(45).unwrap();
984        assert_eq!(format_duration(duration), "45 second(s)");
985    }
986
987    #[test]
988    fn format_minutes_and_seconds() {
989        let duration =
990            ChronoDuration::try_minutes(5).unwrap() + ChronoDuration::try_seconds(30).unwrap();
991        assert_eq!(format_duration(duration), "5 minute(s), 30 second(s)");
992    }
993
994    #[test]
995    fn format_hours_minutes_and_seconds() {
996        let duration = ChronoDuration::try_hours(2).unwrap()
997            + ChronoDuration::try_minutes(15).unwrap()
998            + ChronoDuration::try_seconds(10).unwrap();
999        assert_eq!(
1000            format_duration(duration),
1001            "2 hour(s), 15 minute(s), 10 second(s)"
1002        );
1003    }
1004
1005    #[test]
1006    fn format_days_hours_minutes() {
1007        let duration = ChronoDuration::try_days(1).unwrap()
1008            + ChronoDuration::try_hours(3).unwrap()
1009            + ChronoDuration::try_minutes(45).unwrap();
1010        assert_eq!(
1011            format_duration(duration),
1012            "1 day(s), 3 hour(s), 45 minute(s)"
1013        );
1014    }
1015
1016    #[test]
1017    fn format_days_only() {
1018        let duration = ChronoDuration::try_days(4).unwrap();
1019        assert_eq!(format_duration(duration), "4 day(s)");
1020    }
1021
1022    #[test]
1023    fn format_large_duration() {
1024        let duration = ChronoDuration::try_days(7).unwrap()
1025            + ChronoDuration::try_hours(23).unwrap()
1026            + ChronoDuration::try_minutes(59).unwrap()
1027            + ChronoDuration::try_seconds(59).unwrap();
1028        assert_eq!(
1029            format_duration(duration),
1030            "7 day(s), 23 hour(s), 59 minute(s), 59 second(s)"
1031        );
1032    }
1033
1034    #[test]
1035    fn reset_warning_timer_resets_start_time() {
1036        let config = get_test_config();
1037
1038        let now_wall = system_time_epoch()
1039            .expect("failed to get current time")
1040            .as_secs();
1041
1042        // Set state for this test
1043        let _guard = TestGuard::new(&State {
1044            timer_type: TimerType::Warning,
1045            last_modified: now_wall - 60,
1046        });
1047
1048        let mut timer = Timer::new(&config).expect("failed to create new timer");
1049
1050        let original_start = timer.start;
1051
1052        timer.reset(&config).expect("failed to reset timer");
1053
1054        let expected = original_start;
1055        let result = timer.start;
1056        assert!(
1057            result > expected,
1058            "expected: timer.start > {:?} (got: {:?})",
1059            expected,
1060            result
1061        );
1062
1063        let expected = Duration::from_secs(config.timer_warning);
1064        let result = timer.duration;
1065        assert!(
1066            result == expected,
1067            "expected: timer.duration == {:?} (got: {:?})",
1068            expected,
1069            result
1070        );
1071
1072        let expected = TimerType::Warning;
1073        let result = timer.get_type();
1074        assert!(
1075            result == expected,
1076            "expected: timer.get_type() == {:?} (got: {:?})",
1077            expected,
1078            result
1079        );
1080    }
1081
1082    #[test]
1083    fn reset_dead_man_timer_promotes_to_warning_and_resets() {
1084        let config = get_test_config();
1085
1086        // Set state for this test
1087        let _guard = TestGuard::new(&State {
1088            timer_type: TimerType::DeadMan,
1089            last_modified: system_time_epoch()
1090                .expect("failed to get current time")
1091                .as_secs(),
1092        });
1093
1094        let mut timer = Timer::new(&config).expect("failed to create new timer");
1095
1096        timer.reset(&config).expect("failed to reset timer");
1097
1098        let expected = Duration::from_secs(config.timer_warning);
1099        let result = timer.duration;
1100        assert!(
1101            result == expected,
1102            "expected: timer.duration == {:?} (got: {:?})",
1103            expected,
1104            result
1105        );
1106
1107        let expected = TimerType::Warning;
1108        let result = timer.get_type();
1109        assert!(
1110            result == expected,
1111            "expected: timer.get_type() == {:?} (got: {:?})",
1112            expected,
1113            result
1114        );
1115    }
1116}