use std::{
collections::{HashMap, HashSet},
io::Write,
path::{Path, PathBuf},
};
use jiff::Timestamp;
use miette::{IntoDiagnostic, Result, WrapErr};
use serde::{Deserialize, Serialize};
use tempfile::NamedTempFile;
use tracing::{debug, warn};
const STATE_FILE_NAME: &str = "state.json";
const APP_DIR: &str = "bestool-alertd";
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PersistedAlertState {
#[serde(skip_serializing_if = "Option::is_none", default)]
pub triggered_at: Option<Timestamp>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub last_sent_at: Option<Timestamp>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub last_output: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub paused_until: Option<Timestamp>,
#[serde(skip_serializing_if = "is_false", default)]
pub source_was_erroring: bool,
}
fn is_false(b: &bool) -> bool {
!*b
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PersistedState {
pub saved_at: Option<Timestamp>,
pub alerts: HashMap<PathBuf, PersistedAlertState>,
#[serde(skip_serializing_if = "is_false", default)]
pub database_was_down: bool,
#[serde(skip_serializing_if = "HashSet::is_empty", default)]
pub definition_error_files: HashSet<PathBuf>,
}
pub fn default_state_file_path() -> Option<PathBuf> {
let base = state_base_dir()?;
Some(base.join(APP_DIR).join(STATE_FILE_NAME))
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
fn state_base_dir() -> Option<PathBuf> {
if let Some(dir) = dirs::state_dir() {
return Some(dir);
}
if let Some(dir) = std::env::var_os("XDG_STATE_HOME") {
return Some(PathBuf::from(dir));
}
if let Some(home) = std::env::var_os("HOME") {
return Some(PathBuf::from(home).join(".local").join("state"));
}
None
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn state_base_dir() -> Option<PathBuf> {
if let Some(dir) = dirs::data_local_dir() {
return Some(dir);
}
#[cfg(target_os = "macos")]
{
if let Some(home) = std::env::var_os("HOME") {
return Some(
PathBuf::from(home)
.join("Library")
.join("Application Support"),
);
}
}
#[cfg(target_os = "windows")]
{
if let Some(localappdata) = std::env::var_os("LOCALAPPDATA") {
return Some(PathBuf::from(localappdata));
}
}
None
}
pub fn read(path: &Path) -> PersistedState {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
debug!(?path, "state file missing, starting fresh");
return PersistedState::default();
}
Err(err) => {
warn!(?path, "failed to read state file ({err}); discarding");
let _ = std::fs::remove_file(path);
return PersistedState::default();
}
};
match serde_json::from_str::<PersistedState>(&content) {
Ok(state) => {
debug!(?path, alerts = state.alerts.len(), "loaded state file");
state
}
Err(err) => {
warn!(?path, "failed to parse state file ({err}); discarding");
let _ = std::fs::remove_file(path);
PersistedState::default()
}
}
}
pub fn write(path: &Path, state: &PersistedState) -> Result<()> {
let parent = path
.parent()
.ok_or_else(|| miette::miette!("state file path has no parent directory: {path:?}"))?;
std::fs::create_dir_all(parent)
.into_diagnostic()
.wrap_err_with(|| format!("creating state directory {parent:?}"))?;
let mut tmp = NamedTempFile::new_in(parent)
.into_diagnostic()
.wrap_err_with(|| format!("creating tempfile in {parent:?}"))?;
let json = serde_json::to_vec_pretty(state)
.into_diagnostic()
.wrap_err("serialising state")?;
tmp.write_all(&json)
.into_diagnostic()
.wrap_err("writing state tempfile")?;
tmp.as_file()
.sync_all()
.into_diagnostic()
.wrap_err("fsyncing state tempfile")?;
tmp.persist(path)
.map_err(|err| miette::miette!("renaming state tempfile into place: {err}"))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn default_path_resolves_on_test_host() {
assert!(
default_state_file_path().is_some(),
"every test host should have a resolvable state dir"
);
}
#[test]
fn read_missing_file_returns_empty() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("missing.json");
let state = read(&path);
assert!(state.alerts.is_empty());
}
#[test]
fn read_corrupt_file_returns_empty_and_deletes() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("state.json");
std::fs::write(&path, "{this is not json").unwrap();
let state = read(&path);
assert!(state.alerts.is_empty());
assert!(!path.exists(), "corrupt state file should be deleted");
}
#[test]
fn write_then_read_round_trips() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("subdir").join("state.json");
let mut alerts = HashMap::new();
alerts.insert(
PathBuf::from("/etc/alerts/disk-full.yml"),
PersistedAlertState {
triggered_at: Some("2026-05-13T15:00:00Z".parse().unwrap()),
last_sent_at: Some("2026-05-13T15:00:00Z".parse().unwrap()),
last_output: Some("rows=[{...}]".into()),
paused_until: None,
source_was_erroring: false,
},
);
let state = PersistedState {
saved_at: Some("2026-05-13T15:00:01Z".parse().unwrap()),
alerts,
..Default::default()
};
write(&path, &state).expect("write should succeed");
assert!(path.exists(), "parent dir should be auto-created");
let loaded = read(&path);
assert_eq!(loaded.alerts.len(), 1);
let entry = &loaded.alerts[&PathBuf::from("/etc/alerts/disk-full.yml")];
assert!(entry.triggered_at.is_some());
assert_eq!(entry.last_output.as_deref(), Some("rows=[{...}]"));
assert!(entry.paused_until.is_none());
}
#[test]
fn daemon_level_fields_round_trip() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("state.json");
let mut definition_error_files = HashSet::new();
definition_error_files.insert(PathBuf::from("/etc/alerts/broken.yml"));
definition_error_files.insert(PathBuf::from("/etc/alerts/also-broken.yml"));
let state = PersistedState {
saved_at: Some("2026-05-13T15:00:01Z".parse().unwrap()),
alerts: HashMap::new(),
database_was_down: true,
definition_error_files: definition_error_files.clone(),
};
write(&path, &state).unwrap();
let loaded = read(&path);
assert!(loaded.database_was_down);
assert_eq!(loaded.definition_error_files, definition_error_files);
}
#[test]
fn daemon_level_fields_default_when_missing() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("state.json");
std::fs::write(&path, r#"{"saved_at":null,"alerts":{}}"#).unwrap();
let loaded = read(&path);
assert!(!loaded.database_was_down);
assert!(loaded.definition_error_files.is_empty());
}
#[test]
fn source_was_erroring_round_trips() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("state.json");
let mut alerts = HashMap::new();
alerts.insert(
PathBuf::from("/etc/alerts/flaky.yml"),
PersistedAlertState {
source_was_erroring: true,
..Default::default()
},
);
let state = PersistedState {
alerts,
..Default::default()
};
write(&path, &state).unwrap();
let loaded = read(&path);
let entry = &loaded.alerts[&PathBuf::from("/etc/alerts/flaky.yml")];
assert!(entry.source_was_erroring);
}
#[test]
fn write_overwrites_existing_atomically() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("state.json");
let first = PersistedState::default();
write(&path, &first).unwrap();
let mut alerts = HashMap::new();
alerts.insert(
PathBuf::from("a.yml"),
PersistedAlertState {
triggered_at: Some("2026-05-13T15:00:00Z".parse().unwrap()),
..Default::default()
},
);
let second = PersistedState {
saved_at: None,
alerts,
..Default::default()
};
write(&path, &second).unwrap();
let loaded = read(&path);
assert_eq!(loaded.alerts.len(), 1);
}
}