bestool_alertd/
state_file.rs1use std::{
2 collections::{HashMap, HashSet},
3 io::Write,
4 path::{Path, PathBuf},
5};
6
7use jiff::Timestamp;
8use miette::{IntoDiagnostic, Result, WrapErr};
9use serde::{Deserialize, Serialize};
10use tempfile::NamedTempFile;
11use tracing::{debug, warn};
12
13const STATE_FILE_NAME: &str = "state.json";
14const APP_DIR: &str = "bestool-alertd";
15
16#[derive(Debug, Clone, Default, Serialize, Deserialize)]
18pub struct PersistedAlertState {
19 #[serde(skip_serializing_if = "Option::is_none", default)]
20 pub triggered_at: Option<Timestamp>,
21 #[serde(skip_serializing_if = "Option::is_none", default)]
22 pub last_sent_at: Option<Timestamp>,
23 #[serde(skip_serializing_if = "Option::is_none", default)]
24 pub last_output: Option<String>,
25 #[serde(skip_serializing_if = "Option::is_none", default)]
26 pub paused_until: Option<Timestamp>,
27 #[serde(skip_serializing_if = "is_false", default)]
29 pub source_was_erroring: bool,
30}
31
32fn is_false(b: &bool) -> bool {
33 !*b
34}
35
36#[derive(Debug, Clone, Default, Serialize, Deserialize)]
38pub struct PersistedState {
39 pub saved_at: Option<Timestamp>,
40 pub alerts: HashMap<PathBuf, PersistedAlertState>,
41 #[serde(skip_serializing_if = "is_false", default)]
47 pub database_was_down: bool,
48 #[serde(skip_serializing_if = "HashSet::is_empty", default)]
53 pub definition_error_files: HashSet<PathBuf>,
54}
55
56pub fn default_state_file_path() -> Option<PathBuf> {
62 let base = state_base_dir()?;
63 Some(base.join(APP_DIR).join(STATE_FILE_NAME))
64}
65
66#[cfg(not(any(target_os = "macos", target_os = "windows")))]
67fn state_base_dir() -> Option<PathBuf> {
68 if let Some(dir) = dirs::state_dir() {
69 return Some(dir);
70 }
71 if let Some(dir) = std::env::var_os("XDG_STATE_HOME") {
72 return Some(PathBuf::from(dir));
73 }
74 if let Some(home) = std::env::var_os("HOME") {
75 return Some(PathBuf::from(home).join(".local").join("state"));
76 }
77 None
78}
79
80#[cfg(any(target_os = "macos", target_os = "windows"))]
81fn state_base_dir() -> Option<PathBuf> {
82 if let Some(dir) = dirs::data_local_dir() {
83 return Some(dir);
84 }
85 #[cfg(target_os = "macos")]
86 {
87 if let Some(home) = std::env::var_os("HOME") {
88 return Some(
89 PathBuf::from(home)
90 .join("Library")
91 .join("Application Support"),
92 );
93 }
94 }
95 #[cfg(target_os = "windows")]
96 {
97 if let Some(localappdata) = std::env::var_os("LOCALAPPDATA") {
98 return Some(PathBuf::from(localappdata));
99 }
100 }
101 None
102}
103
104pub fn read(path: &Path) -> PersistedState {
111 let content = match std::fs::read_to_string(path) {
112 Ok(c) => c,
113 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
114 debug!(?path, "state file missing, starting fresh");
115 return PersistedState::default();
116 }
117 Err(err) => {
118 warn!(?path, "failed to read state file ({err}); discarding");
119 let _ = std::fs::remove_file(path);
120 return PersistedState::default();
121 }
122 };
123
124 match serde_json::from_str::<PersistedState>(&content) {
125 Ok(state) => {
126 debug!(?path, alerts = state.alerts.len(), "loaded state file");
127 state
128 }
129 Err(err) => {
130 warn!(?path, "failed to parse state file ({err}); discarding");
131 let _ = std::fs::remove_file(path);
132 PersistedState::default()
133 }
134 }
135}
136
137pub fn write(path: &Path, state: &PersistedState) -> Result<()> {
142 let parent = path
143 .parent()
144 .ok_or_else(|| miette::miette!("state file path has no parent directory: {path:?}"))?;
145
146 std::fs::create_dir_all(parent)
147 .into_diagnostic()
148 .wrap_err_with(|| format!("creating state directory {parent:?}"))?;
149
150 let mut tmp = NamedTempFile::new_in(parent)
151 .into_diagnostic()
152 .wrap_err_with(|| format!("creating tempfile in {parent:?}"))?;
153
154 let json = serde_json::to_vec_pretty(state)
155 .into_diagnostic()
156 .wrap_err("serialising state")?;
157
158 tmp.write_all(&json)
159 .into_diagnostic()
160 .wrap_err("writing state tempfile")?;
161
162 tmp.as_file()
163 .sync_all()
164 .into_diagnostic()
165 .wrap_err("fsyncing state tempfile")?;
166
167 tmp.persist(path)
168 .map_err(|err| miette::miette!("renaming state tempfile into place: {err}"))?;
169
170 Ok(())
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176 use tempfile::TempDir;
177
178 #[test]
179 fn default_path_resolves_on_test_host() {
180 assert!(
181 default_state_file_path().is_some(),
182 "every test host should have a resolvable state dir"
183 );
184 }
185
186 #[test]
187 fn read_missing_file_returns_empty() {
188 let tmp = TempDir::new().unwrap();
189 let path = tmp.path().join("missing.json");
190 let state = read(&path);
191 assert!(state.alerts.is_empty());
192 }
193
194 #[test]
195 fn read_corrupt_file_returns_empty_and_deletes() {
196 let tmp = TempDir::new().unwrap();
197 let path = tmp.path().join("state.json");
198 std::fs::write(&path, "{this is not json").unwrap();
199 let state = read(&path);
200 assert!(state.alerts.is_empty());
201 assert!(!path.exists(), "corrupt state file should be deleted");
202 }
203
204 #[test]
205 fn write_then_read_round_trips() {
206 let tmp = TempDir::new().unwrap();
207 let path = tmp.path().join("subdir").join("state.json");
208
209 let mut alerts = HashMap::new();
210 alerts.insert(
211 PathBuf::from("/etc/alerts/disk-full.yml"),
212 PersistedAlertState {
213 triggered_at: Some("2026-05-13T15:00:00Z".parse().unwrap()),
214 last_sent_at: Some("2026-05-13T15:00:00Z".parse().unwrap()),
215 last_output: Some("rows=[{...}]".into()),
216 paused_until: None,
217 source_was_erroring: false,
218 },
219 );
220 let state = PersistedState {
221 saved_at: Some("2026-05-13T15:00:01Z".parse().unwrap()),
222 alerts,
223 ..Default::default()
224 };
225
226 write(&path, &state).expect("write should succeed");
227 assert!(path.exists(), "parent dir should be auto-created");
228
229 let loaded = read(&path);
230 assert_eq!(loaded.alerts.len(), 1);
231 let entry = &loaded.alerts[&PathBuf::from("/etc/alerts/disk-full.yml")];
232 assert!(entry.triggered_at.is_some());
233 assert_eq!(entry.last_output.as_deref(), Some("rows=[{...}]"));
234 assert!(entry.paused_until.is_none());
235 }
236
237 #[test]
238 fn daemon_level_fields_round_trip() {
239 let tmp = TempDir::new().unwrap();
240 let path = tmp.path().join("state.json");
241
242 let mut definition_error_files = HashSet::new();
243 definition_error_files.insert(PathBuf::from("/etc/alerts/broken.yml"));
244 definition_error_files.insert(PathBuf::from("/etc/alerts/also-broken.yml"));
245
246 let state = PersistedState {
247 saved_at: Some("2026-05-13T15:00:01Z".parse().unwrap()),
248 alerts: HashMap::new(),
249 database_was_down: true,
250 definition_error_files: definition_error_files.clone(),
251 };
252 write(&path, &state).unwrap();
253
254 let loaded = read(&path);
255 assert!(loaded.database_was_down);
256 assert_eq!(loaded.definition_error_files, definition_error_files);
257 }
258
259 #[test]
260 fn daemon_level_fields_default_when_missing() {
261 let tmp = TempDir::new().unwrap();
264 let path = tmp.path().join("state.json");
265 std::fs::write(&path, r#"{"saved_at":null,"alerts":{}}"#).unwrap();
266 let loaded = read(&path);
267 assert!(!loaded.database_was_down);
268 assert!(loaded.definition_error_files.is_empty());
269 }
270
271 #[test]
272 fn source_was_erroring_round_trips() {
273 let tmp = TempDir::new().unwrap();
274 let path = tmp.path().join("state.json");
275
276 let mut alerts = HashMap::new();
277 alerts.insert(
278 PathBuf::from("/etc/alerts/flaky.yml"),
279 PersistedAlertState {
280 source_was_erroring: true,
281 ..Default::default()
282 },
283 );
284 let state = PersistedState {
285 alerts,
286 ..Default::default()
287 };
288 write(&path, &state).unwrap();
289
290 let loaded = read(&path);
291 let entry = &loaded.alerts[&PathBuf::from("/etc/alerts/flaky.yml")];
292 assert!(entry.source_was_erroring);
293 }
294
295 #[test]
296 fn write_overwrites_existing_atomically() {
297 let tmp = TempDir::new().unwrap();
298 let path = tmp.path().join("state.json");
299
300 let first = PersistedState::default();
301 write(&path, &first).unwrap();
302
303 let mut alerts = HashMap::new();
304 alerts.insert(
305 PathBuf::from("a.yml"),
306 PersistedAlertState {
307 triggered_at: Some("2026-05-13T15:00:00Z".parse().unwrap()),
308 ..Default::default()
309 },
310 );
311 let second = PersistedState {
312 saved_at: None,
313 alerts,
314 ..Default::default()
315 };
316 write(&path, &second).unwrap();
317
318 let loaded = read(&path);
319 assert_eq!(loaded.alerts.len(), 1);
320 }
321}