use crate::app;
use crate::config::Config;
use crate::error::TimerError;
use chrono::Duration as ChronoDuration;
use serde::{Deserialize, Serialize};
use std::fs::{self, File};
use std::io::Write;
use std::path::PathBuf;
use std::sync::{OnceLock, mpsc};
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tracing::{error, trace, warn};
static PERSIST_SENDER: OnceLock<mpsc::Sender<State>> = OnceLock::new();
const DEFAULT_DEBOUNCE_MS: u64 = 300;
#[derive(Debug, Default, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
pub enum TimerType {
#[default]
Warning,
DeadMan,
}
#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq)]
pub struct State {
timer_type: TimerType,
last_modified: u64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Timer {
timer_type: TimerType,
start: Instant,
duration: Duration,
}
impl Timer {
pub fn new(config: &Config) -> Result<Self, TimerError> {
let timer = load_or_initialize(config)?;
Ok(timer)
}
pub fn get_type(&self) -> TimerType {
self.timer_type
}
pub fn elapsed(&self) -> Duration {
Instant::now().duration_since(self.start)
}
pub fn remaining_chrono(&self) -> ChronoDuration {
let elapsed = self.elapsed();
if elapsed < self.duration {
let remaining = self.duration.saturating_sub(elapsed);
return ChronoDuration::try_seconds(remaining.as_secs() as i64)
.unwrap_or(ChronoDuration::zero());
}
ChronoDuration::zero()
}
pub fn remaining_percent(&self) -> u16 {
let remaining_chrono = self.remaining_chrono();
if remaining_chrono > ChronoDuration::zero() {
return (remaining_chrono.num_seconds() as f64 / self.duration.as_secs() as f64 * 100.0)
as u16;
}
0
}
pub fn label(&self) -> String {
let remaining_chrono = self.remaining_chrono();
if remaining_chrono > ChronoDuration::zero() {
return format_duration(remaining_chrono);
}
"0 second(s)".to_string()
}
pub fn update(&mut self, elapsed: Duration, dead_man_duration: u64) -> Result<(), TimerError> {
if self.timer_type == TimerType::Warning && elapsed >= self.duration {
self.timer_type = TimerType::DeadMan;
self.start = Instant::now();
self.duration = Duration::from_secs(dead_man_duration);
let state = state_from_timer(self)?;
persist_state_non_blocking(state)?;
}
Ok(())
}
pub fn expired(&self) -> bool {
self.elapsed() >= self.duration
}
pub fn reset(&mut self, config: &crate::config::Config) -> Result<(), TimerError> {
let (timer, state) = default_timer_and_state(config)?;
self.timer_type = timer.timer_type;
self.start = timer.start;
self.duration = timer.duration;
persist_state_non_blocking(state)?;
Ok(())
}
}
fn format_duration(duration: ChronoDuration) -> String {
let days = duration.num_days();
let hours = duration.num_hours() % 24;
let minutes = duration.num_minutes() % 60;
let seconds = duration.num_seconds() % 60;
let mut parts = vec![];
if days > 0 {
parts.push(format!("{days} day(s)"));
}
if hours > 0 {
parts.push(format!("{hours} hour(s)"));
}
if minutes > 0 {
parts.push(format!("{minutes} minute(s)"));
}
if seconds > 0 || parts.is_empty() {
parts.push(format!("{} second(s)", seconds));
}
parts.join(", ")
}
pub fn load_or_initialize(config: &Config) -> Result<Timer, TimerError> {
let file_path = file_path()?;
let (timer, state) = match fs::read_to_string(&file_path) {
Ok(state_str) => {
match toml::from_str::<State>(state_str.as_str()) {
Ok(state) => {
let wall_elapsed = wall_elapsed(state.last_modified);
let now_mono = Instant::now();
let start = match now_mono.checked_sub(wall_elapsed) {
Some(s) => s,
None => now_mono, };
let timer = Timer {
timer_type: state.timer_type,
start,
duration: match state.timer_type {
TimerType::Warning => Duration::from_secs(config.timer_warning),
TimerType::DeadMan => Duration::from_secs(config.timer_dead_man),
},
};
(timer, state)
}
Err(e) => {
warn!(
error = ?e,
path = %file_path.display(),
"persisted file parse error: using defaults"
);
default_timer_and_state(config)?
}
}
}
Err(_) => {
trace!(
path = %file_path.display(),
"no persisted file found: using defaults"
);
default_timer_and_state(config)?
}
};
let _ = PERSIST_SENDER.get_or_init(|| spawn_persistence_worker(Some(state.clone())));
persist_state_blocking(state)?;
Ok(timer)
}
fn default_timer_and_state(config: &Config) -> Result<(Timer, State), TimerError> {
let timer = Timer {
timer_type: TimerType::Warning,
start: Instant::now(),
duration: Duration::from_secs(config.timer_warning),
};
let state = state_from_timer(&timer)?;
Ok((timer, state))
}
fn file_name() -> &'static str {
"state.toml"
}
pub fn file_path() -> Result<PathBuf, TimerError> {
let path = app::file_path(file_name())?;
Ok(path)
}
pub fn system_time_epoch() -> Result<Duration, TimerError> {
let now_wall = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(TimerError::SystemTime)?;
Ok(now_wall)
}
fn wall_elapsed(last_modified: u64) -> Duration {
let persisted_last_modified = Duration::from_secs(last_modified);
let now_wall = system_time_epoch().unwrap_or(Duration::from_secs(0));
if now_wall > persisted_last_modified {
now_wall - persisted_last_modified
} else {
Duration::from_secs(0)
}
}
fn state_from_timer(timer: &Timer) -> Result<State, TimerError> {
let state = State {
timer_type: timer.timer_type,
last_modified: system_time_epoch()?.as_secs(),
};
Ok(state)
}
pub fn persist_state_blocking(state: State) -> Result<(), TimerError> {
let path = file_path()?;
if let Err(e) = persist_state_to_path(&path, &state) {
error!(error = ?e, path = %path.display(), "persist new state failed");
return Err(e);
}
Ok(())
}
fn persist_state_non_blocking(state: State) -> Result<(), TimerError> {
let sender = PERSIST_SENDER
.get_or_init(|| spawn_persistence_worker(Some(state.clone())))
.clone();
if let Err(e) = sender.send(state) {
error!(error = ?e, "failed to enqueue persistence of state; background worker may have stopped");
}
Ok(())
}
fn spawn_persistence_worker(state: Option<State>) -> mpsc::Sender<State> {
let (tx, rx) = mpsc::channel::<State>();
thread::spawn(move || {
let mut last_written: Option<State> = state;
let debounce = Duration::from_millis(DEFAULT_DEBOUNCE_MS);
while let Ok(mut snapshot) = rx.recv() {
while let Ok(next) = rx.try_recv() {
snapshot = next;
}
let start = Instant::now();
while start.elapsed() < debounce {
if let Ok(next) = rx.try_recv() {
snapshot = next;
} else {
thread::sleep(Duration::from_millis(10));
}
}
if last_written.as_ref() == Some(&snapshot) {
trace!("skipping persist state: identical to last written");
continue;
}
if let Ok(path) = file_path() {
if let Err(e) = persist_state_to_path(&path, &snapshot) {
error!(error = ?e, path = %path.display(), "persist new state failed");
} else {
trace!(path = %path.display(), "persisted new state");
last_written = Some(snapshot);
}
}
}
});
tx
}
fn persist_state_to_path(path: &PathBuf, state: &State) -> Result<(), TimerError> {
let state_str = toml::to_string(state)?;
write_atomic(path, state_str.as_bytes())
}
fn write_atomic(path: &PathBuf, data: &[u8]) -> Result<(), TimerError> {
let tmp_path = path.with_extension("tmp");
{
let mut tmp = File::create(&tmp_path)?;
tmp.write_all(data)?;
tmp.sync_all()?;
}
fs::rename(&tmp_path, path)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{self, Config};
use std::fs::{self, File};
use std::io::Write;
use std::ops::Sub;
use std::path::Path;
use std::thread::sleep;
fn get_test_config() -> Config {
Config {
username: "user@example.com".to_string(),
password: "password".to_string(),
smtp_server: "smtp.example.com".to_string(),
smtp_port: 587,
smtp_check_timeout: Some(5),
message: "This is a test message".to_string(),
message_warning: "This is a test warning message".to_string(),
subject: "Test Subject".to_string(),
subject_warning: "Test Warning Subject".to_string(),
to: "recipient@example.com".to_string(),
from: "sender@example.com".to_string(),
attachment: None,
timer_warning: 60,
timer_dead_man: 120,
web_password: "password".to_string(),
cookie_exp_days: 7,
log_level: None,
}
}
struct TestGuard;
impl TestGuard {
fn new(s: &State) -> Self {
let file_path = file_path().expect("setup: failed file_path()");
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent).expect("setup: failed to create dir");
}
let mut file = File::create(file_path).expect("setup: failed to create file");
let s_str = toml::to_string(s).expect("setup: failed to convert data");
file.write_all(s_str.as_bytes())
.expect("setup: failed to write data");
file.sync_all()
.expect("setup: failed to ensure file written to disk");
TestGuard
}
}
impl Drop for TestGuard {
fn drop(&mut self) {
let file_path = file_path().expect("teardown: failed file_path()");
cleanup_test_dir_parent(file_path.as_path());
}
}
fn cleanup_test_dir(dir: &Path) {
if let Some(parent) = dir.parent() {
let _ = fs::remove_dir_all(parent);
}
}
fn cleanup_test_dir_parent(dir: &Path) {
if let Some(parent) = dir.parent() {
cleanup_test_dir(parent)
}
}
fn load_state_from_path(path: &PathBuf) -> State {
let state_str = fs::read_to_string(path).expect("helper: error reading state data");
let state: State = toml::from_str(&state_str).expect("helper: error parsing state data");
state
}
#[test]
fn file_path_in_test_mode() {
let result = file_path();
assert!(result.is_ok());
let expected = format!("{}_test", app::name());
let result = result.unwrap();
assert!(result.to_string_lossy().contains(expected.as_str()));
let expected = Path::new(app::name()).join(file_name());
assert!(
result
.to_string_lossy()
.contains(expected.to_string_lossy().as_ref())
);
cleanup_test_dir_parent(&result);
}
#[test]
fn generates_system_time_epoch() {
let result = system_time_epoch();
assert!(result.is_ok());
let expected_range_low = 1767225600; let expected_range_high = 4102444800; let result = result.unwrap().as_secs();
assert!(
result >= expected_range_low && result <= expected_range_high,
"expected: between {:?} (2026-01-01 00:00:00) and {:?} (2100-01-01 00:00:00) (got: {:?})",
expected_range_low,
expected_range_high,
result
);
}
#[test]
fn update_persisted_state_ok() {
let state = State {
timer_type: TimerType::Warning,
last_modified: 1,
};
let result = persist_state_blocking(state.clone());
assert!(result.is_ok());
let test_path = file_path().unwrap();
let loaded_state = load_state_from_path(&test_path);
assert_eq!(loaded_state, state);
cleanup_test_dir_parent(&test_path);
}
#[test]
fn state_guard_ok() {
let state = State {
timer_type: TimerType::DeadMan,
last_modified: 2,
};
let _guard = TestGuard::new(&state);
let test_path = file_path().unwrap();
let loaded_state = load_state_from_path(&test_path);
assert_eq!(loaded_state, state);
}
#[test]
fn load_or_initialize_with_existing_file() {
let config = Config::default();
let existing_state_1 = State {
timer_type: TimerType::Warning,
last_modified: 3,
};
let existing_state_2 = State {
timer_type: TimerType::DeadMan,
last_modified: 4,
};
let _guard_1 = TestGuard::new(&existing_state_1);
let timer = load_or_initialize(&config).unwrap();
assert_eq!(timer.timer_type, existing_state_1.timer_type);
let _guard_2 = TestGuard::new(&existing_state_2);
let timer = load_or_initialize(&config).unwrap();
assert_eq!(timer.timer_type, existing_state_2.timer_type);
}
#[test]
fn load_or_initialize_with_no_existing_file() {
let config = Config::default();
let timer = load_or_initialize(&config).unwrap();
let expected = TimerType::Warning;
let result = timer.timer_type;
assert!(
result == expected,
"expected: '{:?}' got: '{:?}')",
expected,
result
);
let expected = Duration::from_secs(config.timer_warning);
let result = timer.duration;
assert!(
result == expected,
"expected: '{:?}' got: '{:?}')",
expected,
result
);
let test_path = file_path().unwrap();
cleanup_test_dir_parent(&test_path);
}
#[test]
fn timer_remaining_chrono_with_state_in_past() {
let default_config = config::load_or_initialize().expect("failed to load default config");
let now_wall = system_time_epoch()
.expect("failed to get current time")
.as_secs();
let _guard = TestGuard::new(&State {
timer_type: TimerType::Warning,
last_modified: now_wall - 10000,
});
let timer = Timer::new(&default_config).expect("failed to create new timer");
let tolerance_secs = 5;
let expected_range_high =
chrono::TimeDelta::new((default_config.timer_warning - 10000) as i64, 0)
.expect("failed creating time delta");
let expected_range_low = expected_range_high
.sub(chrono::TimeDelta::new(tolerance_secs, 0).expect("failed creating time delta"));
let result = timer.remaining_chrono();
assert!(
result >= expected_range_low && result <= expected_range_high,
"expected: timer.remaining_chrono() between {:?} and {:?} (got: {:?})",
expected_range_low,
expected_range_high,
result
);
}
#[test]
fn timer_remaining_chrono_with_state_in_future() {
let default_config = config::load_or_initialize().expect("failed to load default config");
let now_wall = system_time_epoch()
.expect("failed to get current time")
.as_secs();
let _guard = TestGuard::new(&State {
timer_type: TimerType::Warning,
last_modified: now_wall + 1000,
});
let timer = Timer::new(&default_config).expect("failed to create new timer");
let tolerance_secs = 5;
let expected_range_high = chrono::TimeDelta::new((default_config.timer_warning) as i64, 0)
.expect("failed creating time delta");
let expected_range_low = expected_range_high
.sub(chrono::TimeDelta::new(tolerance_secs, 0).expect("failed creating time delta"));
let result = timer.remaining_chrono();
assert!(
result >= expected_range_low && result <= expected_range_high,
"expected: timer.remaining_chrono() between {:?} and {:?} (got: {:?})",
expected_range_low,
expected_range_high,
result
);
}
#[test]
fn timer_remaining_chrono_with_state() {
let mut config = get_test_config();
let now_wall = system_time_epoch()
.expect("failed to get current time")
.as_secs();
let _guard = TestGuard::new(&State {
timer_type: TimerType::Warning,
last_modified: now_wall,
});
let mut timer = Timer::new(&config).expect("failed to create new timer");
config.timer_warning = 2;
timer.reset(&config).expect("failed to reset timer");
let expected = chrono::TimeDelta::new(2, 0).expect("failed creating time delta");
let result = timer.remaining_chrono();
assert!(
result <= expected,
"expected: timer.remaining_chrono() < {:?} (got: {:?})",
expected,
result
);
sleep(Duration::from_secs(2));
let expected = chrono::TimeDelta::new(1, 0).expect("failed creating time delta");
let result = timer.remaining_chrono();
assert!(
result <= expected,
"expected: timer.remaining_chrono() < {:?} (got: {:?})",
expected,
result
);
}
#[test]
fn timer_elapsed_less_than_duration() {
let mut config = get_test_config();
let now_wall = system_time_epoch()
.expect("failed to get current time")
.as_secs();
let _guard = TestGuard::new(&State {
timer_type: TimerType::Warning,
last_modified: now_wall,
});
let mut timer = Timer::new(&config).expect("failed to create new timer");
config.timer_warning = 60;
timer.reset(&config).expect("failed to reset timer");
let expected = Duration::from_secs(2);
let result = timer.elapsed();
assert!(
result < expected,
"expected: timer.elapsed() < {:?} (got: {:?})",
expected,
result
);
sleep(Duration::from_secs_f64(1.5));
let expected = Duration::from_secs(1);
let result = timer.elapsed();
assert!(
result > expected,
"expected: timer.elapsed() > {:?} (got: {:?})",
expected,
result
);
}
#[test]
fn timer_update_to_dead_man() {
let mut config = get_test_config();
let now_wall = system_time_epoch()
.expect("failed to get current time")
.as_secs();
let _guard = TestGuard::new(&State {
timer_type: TimerType::Warning,
last_modified: now_wall,
});
let mut timer = Timer::new(&config).expect("failed to create new timer");
config.timer_warning = 1;
timer.reset(&config).expect("failed to reset timer");
let expected = TimerType::Warning;
let result = timer.timer_type;
assert!(
result == expected,
"expected: '{:?}' got: '{:?}')",
expected,
result
);
let expected = Duration::from_secs(config.timer_warning);
let result = timer.duration;
assert!(
result == expected,
"expected: '{:?}' got: '{:?}')",
expected,
result
);
timer
.update(Duration::from_secs(2), 3600)
.expect("Failed to update timer");
let expected = TimerType::DeadMan;
let result = timer.get_type();
assert!(
result == expected,
"expected: '{:?}' got: '{:?}')",
expected,
result
);
let expected = Duration::from_secs(3600);
let result = timer.duration;
assert!(
result == expected,
"expected: '{:?}' got: '{:?}')",
expected,
result
);
let expected = false;
let result = timer.expired();
assert!(
result == expected,
"expected: '{:?}' got: '{:?}')",
expected,
result
);
}
#[test]
fn timer_expiration() {
let mut config = get_test_config();
let now_wall = system_time_epoch()
.expect("failed to get current time")
.as_secs();
let _guard = TestGuard::new(&State {
timer_type: TimerType::Warning,
last_modified: now_wall,
});
let mut timer = Timer::new(&config).expect("failed to create new timer");
config.timer_warning = 1;
timer.reset(&config).expect("failed to reset timer");
let expected = TimerType::Warning;
let result = timer.timer_type;
assert!(
result == expected,
"expected: '{:?}' got: '{:?}')",
expected,
result
);
let expected = Duration::from_secs(config.timer_warning);
let result = timer.duration;
assert!(
result == expected,
"expected: '{:?}' got: '{:?}')",
expected,
result
);
timer
.update(Duration::from_secs(2), 1)
.expect("Failed to update timer");
sleep(Duration::from_secs(2));
let expected = true;
let result = timer.expired();
assert!(
result == expected,
"expected: '{:?}' got: '{:?}')",
expected,
result
);
}
#[test]
fn timer_remaining_percent() {
let mut config = get_test_config();
let now_wall = system_time_epoch()
.expect("failed to get current time")
.as_secs();
let _guard = TestGuard::new(&State {
timer_type: TimerType::Warning,
last_modified: now_wall,
});
let mut timer = Timer::new(&config).expect("failed to create new timer");
config.timer_warning = 2;
timer.reset(&config).expect("failed to reset timer");
let expected = 0;
let result = timer.remaining_percent();
assert!(
result > expected,
"expected: timer.remaining_percent() > {:?} (got: {:?})",
expected,
result
);
sleep(Duration::from_secs(2));
let result = timer.remaining_percent();
assert!(
result == expected,
"expected: timer.remaining_percent() == {:?} (got: {:?})",
expected,
result
);
}
#[test]
fn timer_label() {
let mut config = get_test_config();
let now_wall = system_time_epoch()
.expect("failed to get current time")
.as_secs();
let _guard = TestGuard::new(&State {
timer_type: TimerType::Warning,
last_modified: now_wall,
});
let mut timer = Timer::new(&config).expect("failed to create new timer");
config.timer_warning = 2;
timer.reset(&config).expect("failed to reset timer");
let expected = "0 second(s)";
let result = timer.label();
assert!(
result != expected,
"expected: timer.label() != {:?} (got: {:?})",
expected,
result
);
sleep(Duration::from_secs(2));
let result = timer.label();
assert!(
result == expected,
"expected: timer.label() == {:?} (got: {:?})",
expected,
result
);
}
#[test]
fn format_seconds_only() {
let duration = ChronoDuration::try_seconds(45).unwrap();
assert_eq!(format_duration(duration), "45 second(s)");
}
#[test]
fn format_minutes_and_seconds() {
let duration =
ChronoDuration::try_minutes(5).unwrap() + ChronoDuration::try_seconds(30).unwrap();
assert_eq!(format_duration(duration), "5 minute(s), 30 second(s)");
}
#[test]
fn format_hours_minutes_and_seconds() {
let duration = ChronoDuration::try_hours(2).unwrap()
+ ChronoDuration::try_minutes(15).unwrap()
+ ChronoDuration::try_seconds(10).unwrap();
assert_eq!(
format_duration(duration),
"2 hour(s), 15 minute(s), 10 second(s)"
);
}
#[test]
fn format_days_hours_minutes() {
let duration = ChronoDuration::try_days(1).unwrap()
+ ChronoDuration::try_hours(3).unwrap()
+ ChronoDuration::try_minutes(45).unwrap();
assert_eq!(
format_duration(duration),
"1 day(s), 3 hour(s), 45 minute(s)"
);
}
#[test]
fn format_days_only() {
let duration = ChronoDuration::try_days(4).unwrap();
assert_eq!(format_duration(duration), "4 day(s)");
}
#[test]
fn format_large_duration() {
let duration = ChronoDuration::try_days(7).unwrap()
+ ChronoDuration::try_hours(23).unwrap()
+ ChronoDuration::try_minutes(59).unwrap()
+ ChronoDuration::try_seconds(59).unwrap();
assert_eq!(
format_duration(duration),
"7 day(s), 23 hour(s), 59 minute(s), 59 second(s)"
);
}
#[test]
fn reset_warning_timer_resets_start_time() {
let config = get_test_config();
let now_wall = system_time_epoch()
.expect("failed to get current time")
.as_secs();
let _guard = TestGuard::new(&State {
timer_type: TimerType::Warning,
last_modified: now_wall - 60,
});
let mut timer = Timer::new(&config).expect("failed to create new timer");
let original_start = timer.start;
timer.reset(&config).expect("failed to reset timer");
let expected = original_start;
let result = timer.start;
assert!(
result > expected,
"expected: timer.start > {:?} (got: {:?})",
expected,
result
);
let expected = Duration::from_secs(config.timer_warning);
let result = timer.duration;
assert!(
result == expected,
"expected: timer.duration == {:?} (got: {:?})",
expected,
result
);
let expected = TimerType::Warning;
let result = timer.get_type();
assert!(
result == expected,
"expected: timer.get_type() == {:?} (got: {:?})",
expected,
result
);
}
#[test]
fn reset_dead_man_timer_promotes_to_warning_and_resets() {
let config = get_test_config();
let _guard = TestGuard::new(&State {
timer_type: TimerType::DeadMan,
last_modified: system_time_epoch()
.expect("failed to get current time")
.as_secs(),
});
let mut timer = Timer::new(&config).expect("failed to create new timer");
timer.reset(&config).expect("failed to reset timer");
let expected = Duration::from_secs(config.timer_warning);
let result = timer.duration;
assert!(
result == expected,
"expected: timer.duration == {:?} (got: {:?})",
expected,
result
);
let expected = TimerType::Warning;
let result = timer.get_type();
assert!(
result == expected,
"expected: timer.get_type() == {:?} (got: {:?})",
expected,
result
);
}
}