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 std::time::{Duration, Instant};
13
14use chrono::Duration as ChronoDuration;
15
16/// The timer enum.
17///
18/// See [`timer`](crate::timer) module for more information.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum TimerType {
21    /// The warning timer.
22    Warning,
23    /// Dead Man's Switch timer.
24    DeadMan,
25}
26
27/// The timer struct.
28///
29/// Holds the [`TimerType`], current the duration, and the expiration time.
30/// See [`timer`](crate::timer) module for more information.
31#[derive(Debug, Clone, PartialEq)]
32pub struct Timer {
33    /// The timer type.
34    timer_type: TimerType,
35    /// The start time.
36    start: Instant,
37    /// The duration.
38    duration: Duration,
39}
40
41impl Timer {
42    /// Create a new timer.
43    pub fn new(timer_type: TimerType, duration: Duration) -> Self {
44        Timer {
45            timer_type,
46            start: Instant::now(),
47            duration,
48        }
49    }
50
51    /// Get the type of the timer.
52    /// Returns [`TimerType`].
53    pub fn get_type(&self) -> TimerType {
54        self.timer_type
55    }
56
57    /// Get the elapsed time.
58    pub fn elapsed(&self) -> Duration {
59        Instant::now().duration_since(self.start)
60    }
61
62    /// Calculate the remaining time as a percentage
63    pub fn remaining_percent(&self) -> u16 {
64        let elapsed = self.start.elapsed().as_secs();
65        let total = self.duration.as_secs();
66        if elapsed >= total {
67            return 0;
68        }
69        let remaining = total.saturating_sub(elapsed);
70        (remaining as f64 / total as f64 * 100.0) as u16
71    }
72
73    /// Update label based on the remaining time
74    pub fn label(&self) -> String {
75        let elapsed = self.start.elapsed();
76        if elapsed >= self.duration {
77            return "0 second(s)".to_string();
78        }
79
80        let remaining = self
81            .duration
82            .checked_sub(elapsed)
83            .unwrap_or(Duration::from_secs(0));
84        let remaining_chrono = ChronoDuration::try_seconds(remaining.as_secs() as i64)
85            .unwrap_or(ChronoDuration::zero());
86        format_duration(remaining_chrono)
87    }
88
89    /// Update the timer logic for switching from [`TimerType::Warning`] to
90    /// [`TimerType::DeadMan`].
91    pub fn update(&mut self, elapsed: Duration, dead_man_duration: u64) {
92        if self.timer_type == TimerType::Warning && elapsed >= self.duration {
93            self.timer_type = TimerType::DeadMan;
94            // Reset the start time for the DeadMan timer
95            self.start = Instant::now();
96            self.duration = Duration::from_secs(dead_man_duration);
97        }
98    }
99
100    /// Check if the timer has expired.
101    pub fn expired(&self) -> bool {
102        self.start.elapsed() >= self.duration
103    }
104
105    /// Reset the timer and promotes the timer type from [`TimerType::DeadMan`]
106    /// to [`TimerType::Warning`], if applicable.
107    ///
108    /// This is called when the user checks in.
109    pub fn reset(&mut self, config: &crate::config::Config) {
110        match self.get_type() {
111            TimerType::Warning => {
112                self.start = Instant::now();
113            }
114            TimerType::DeadMan => {
115                self.timer_type = TimerType::Warning;
116                self.start = Instant::now();
117                self.duration = Duration::from_secs(config.timer_warning);
118            }
119        }
120    }
121}
122
123/// Formats a duration into a human-readable string adjusting the resolution based on the duration.
124fn format_duration(duration: ChronoDuration) -> String {
125    let days = duration.num_days();
126    let hours = duration.num_hours() % 24;
127    let minutes = duration.num_minutes() % 60;
128    let seconds = duration.num_seconds() % 60;
129
130    let mut parts = vec![];
131
132    if days > 0 {
133        parts.push(format!("{days} day(s)"));
134    }
135    if hours > 0 {
136        parts.push(format!("{hours} hour(s)"));
137    }
138    if minutes > 0 {
139        parts.push(format!("{minutes} minute(s)"));
140    }
141    if seconds > 0 || parts.is_empty() {
142        parts.push(format!("{} second(s)", seconds + 1));
143    }
144
145    parts.join(", ")
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::config::Config;
152    use std::thread::sleep;
153
154    fn get_test_config() -> Config {
155        Config {
156            username: "user@example.com".to_string(),
157            password: "password".to_string(),
158            smtp_server: "smtp.example.com".to_string(),
159            smtp_port: 587,
160            message: "This is a test message".to_string(),
161            message_warning: "This is a test warning message".to_string(),
162            subject: "Test Subject".to_string(),
163            subject_warning: "Test Warning Subject".to_string(),
164            to: "recipient@example.com".to_string(),
165            from: "sender@example.com".to_string(),
166            attachment: None,
167            timer_warning: 60,
168            timer_dead_man: 120,
169            web_password: "password".to_string(),
170            cookie_exp_days: 7,
171            log_level: None,
172        }
173    }
174
175    #[test]
176    fn timer_creation() {
177        let warning_timer = Timer::new(TimerType::Warning, Duration::from_secs(60));
178        assert_eq!(warning_timer.get_type(), TimerType::Warning);
179        assert!(warning_timer.duration == Duration::from_secs(60));
180    }
181
182    #[test]
183    fn timer_elapsed_less_than_duration() {
184        let timer = Timer::new(TimerType::Warning, Duration::from_secs(60));
185        assert!(timer.elapsed() < Duration::from_secs(60));
186    }
187
188    #[test]
189    fn timer_update_to_dead_man() {
190        let mut timer = Timer::new(TimerType::Warning, Duration::from_secs(1));
191        // Simulate elapsed time by directly manipulating the timer's state.
192        timer.update(Duration::from_secs(2), 3600);
193        assert_eq!(timer.get_type(), TimerType::DeadMan);
194        assert_eq!(timer.duration, Duration::from_secs(3600));
195        assert!(!timer.expired());
196    }
197
198    #[test]
199    fn timer_expiration() {
200        let timer = Timer::new(TimerType::Warning, Duration::from_secs(1));
201        // Directly simulate the passage of time
202        sleep(Duration::from_secs(2));
203        assert!(timer.expired());
204    }
205
206    #[test]
207    fn format_seconds_only() {
208        let duration = ChronoDuration::try_seconds(45).unwrap();
209        assert_eq!(format_duration(duration), "46 second(s)");
210    }
211
212    #[test]
213    fn format_minutes_and_seconds() {
214        let duration =
215            ChronoDuration::try_minutes(5).unwrap() + ChronoDuration::try_seconds(30).unwrap();
216        assert_eq!(format_duration(duration), "5 minute(s), 31 second(s)");
217    }
218
219    #[test]
220    fn format_hours_minutes_and_seconds() {
221        let duration = ChronoDuration::try_hours(2).unwrap()
222            + ChronoDuration::try_minutes(15).unwrap()
223            + ChronoDuration::try_seconds(10).unwrap();
224        assert_eq!(
225            format_duration(duration),
226            "2 hour(s), 15 minute(s), 11 second(s)"
227        );
228    }
229
230    #[test]
231    fn format_days_hours_minutes() {
232        let duration = ChronoDuration::try_days(1).unwrap()
233            + ChronoDuration::try_hours(3).unwrap()
234            + ChronoDuration::try_minutes(45).unwrap();
235        assert_eq!(
236            format_duration(duration),
237            "1 day(s), 3 hour(s), 45 minute(s)"
238        );
239    }
240
241    #[test]
242    fn format_days_only() {
243        let duration = ChronoDuration::try_days(4).unwrap();
244        assert_eq!(format_duration(duration), "4 day(s)");
245    }
246
247    #[test]
248    fn format_large_duration() {
249        let duration = ChronoDuration::try_days(7).unwrap()
250            + ChronoDuration::try_hours(23).unwrap()
251            + ChronoDuration::try_minutes(59).unwrap()
252            + ChronoDuration::try_seconds(59).unwrap();
253        assert_eq!(
254            format_duration(duration),
255            "7 day(s), 23 hour(s), 59 minute(s), 60 second(s)"
256        );
257    }
258
259    #[test]
260    fn reset_warning_timer_resets_start_time() {
261        let config = get_test_config();
262
263        let mut timer = Timer::new(
264            TimerType::Warning,
265            Duration::from_secs(config.timer_warning),
266        );
267        let original_start = timer.start;
268        // Simulate time passing
269        sleep(Duration::from_millis(100));
270        timer.reset(&config);
271        assert!(timer.start > original_start);
272        assert_eq!(timer.duration, Duration::from_secs(config.timer_warning));
273        assert_eq!(timer.get_type(), TimerType::Warning);
274    }
275
276    #[test]
277    fn reset_dead_man_timer_promotes_to_warning_and_resets() {
278        let config = get_test_config();
279
280        let mut timer = Timer::new(
281            TimerType::DeadMan,
282            Duration::from_secs(config.timer_dead_man),
283        );
284        // Simulate time passing
285        sleep(Duration::from_millis(100));
286        timer.reset(&config);
287        assert_eq!(timer.get_type(), TimerType::Warning);
288        assert_eq!(timer.duration, Duration::from_secs(config.timer_warning));
289    }
290}