Skip to main content

bestool_alertd/
state_file.rs

1use 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/// Persistent per-alert state kept across daemon restarts.
17#[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	/// Mirrors `AlertState::source_was_erroring`.
28	#[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/// On-disk shape of the state file.
37#[derive(Debug, Clone, Default, Serialize, Deserialize)]
38pub struct PersistedState {
39	pub saved_at: Option<Timestamp>,
40	pub alerts: HashMap<PathBuf, PersistedAlertState>,
41	/// Mirrors the daemon's database-down tracking.
42	///
43	/// Carried across restarts so that if the daemon goes down with the
44	/// database unreachable and the database recovers before the daemon is
45	/// back, the next healthy tick still fires a canopy clear.
46	#[serde(skip_serializing_if = "is_false", default)]
47	pub database_was_down: bool,
48	/// Files that errored during definition loading on the previous run.
49	///
50	/// Carried across restarts so a file that errored on shutdown but loads
51	/// cleanly on startup still produces a canopy clear.
52	#[serde(skip_serializing_if = "HashSet::is_empty", default)]
53	pub definition_error_files: HashSet<PathBuf>,
54}
55
56/// Resolve the default state-file path for this platform.
57///
58/// Mirrors the path-resolution pattern used by `bestool-psql`'s audit DB.
59/// Returns `None` only if every fallback fails (e.g. no `HOME`, no
60/// `LOCALAPPDATA`); in that case the caller should run without persistence.
61pub 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
104/// Read and parse the state file.
105///
106/// If the file is missing, returns an empty state — that's the first-run path.
107/// If the file is unreadable or unparsable, logs a warning, deletes the
108/// file, and returns an empty state. Persistence is best-effort; a corrupted
109/// file should not block the daemon.
110pub 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
137/// Atomically write the state to disk.
138///
139/// Writes to a tempfile in the same directory, then renames into place.
140/// Creates the parent directory if missing.
141pub 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		// Older state files won't have the new fields; defaults must apply
262		// so the daemon doesn't panic on hydration.
263		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}