use std::path::{Path, PathBuf};
use std::sync::Arc;
use notify::Watcher;
use parking_lot::RwLock;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum HotReloadError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Serialize error: {0}")]
Serialize(String),
#[error("Deserialize error: {0}")]
Deserialize(String),
#[error("Watch error: {0}")]
Watch(String),
}
pub struct HotReload {
state_path: PathBuf,
watch_path: PathBuf,
state: Arc<RwLock<serde_json::Value>>,
}
impl HotReload {
pub fn new(state_path: impl Into<PathBuf>, watch_path: impl Into<PathBuf>) -> Self {
Self {
state_path: state_path.into(),
watch_path: watch_path.into(),
state: Arc::new(RwLock::new(serde_json::Value::Null)),
}
}
pub fn state_path(&self) -> &Path {
&self.state_path
}
pub fn watch_path(&self) -> &Path {
&self.watch_path
}
pub fn set_state(&self, state: serde_json::Value) {
*self.state.write() = state;
}
pub fn get_state(&self) -> serde_json::Value {
self.state.read().clone()
}
pub fn graceful_shutdown(&self) -> Result<(), HotReloadError> {
let state = self.state.read().clone();
let json = serde_json::to_string_pretty(&state)
.map_err(|e| HotReloadError::Serialize(e.to_string()))?;
std::fs::write(&self.state_path, json)?;
tracing::info!("Hot reload: state saved to {}", self.state_path.display());
Ok(())
}
pub fn restore_state(&self) -> Result<serde_json::Value, HotReloadError> {
if !self.state_path.exists() {
tracing::info!("Hot reload: no state file, starting fresh");
return Ok(serde_json::Value::Null);
}
let json = std::fs::read_to_string(&self.state_path)?;
let state: serde_json::Value =
serde_json::from_str(&json).map_err(|e| HotReloadError::Deserialize(e.to_string()))?;
*self.state.write() = state.clone();
tracing::info!(
"Hot reload: state restored from {}",
self.state_path.display()
);
Ok(state)
}
pub fn start_watch<F>(&self, callback: F) -> Result<notify::RecommendedWatcher, HotReloadError>
where
F: Fn(notify::Event) + Send + 'static,
{
let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
if let Ok(event) = res {
callback(event);
}
})
.map_err(|e| HotReloadError::Watch(e.to_string()))?;
watcher
.watch(&self.watch_path, notify::RecursiveMode::Recursive)
.map_err(|e| HotReloadError::Watch(e.to_string()))?;
tracing::info!("Hot reload: watching {}", self.watch_path.display());
Ok(watcher)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hot_reload_new() {
let hr = HotReload::new("/tmp/state.json", "/tmp/watch");
assert_eq!(hr.state_path(), Path::new("/tmp/state.json"));
assert_eq!(hr.watch_path(), Path::new("/tmp/watch"));
}
#[test]
fn test_hot_reload_set_get_state() {
let hr = HotReload::new("/tmp/state.json", "/tmp/watch");
hr.set_state(serde_json::json!({"key": "value"}));
assert_eq!(hr.get_state(), serde_json::json!({"key": "value"}));
}
#[test]
fn test_hot_reload_graceful_shutdown() {
let temp = tempfile::NamedTempFile::new().unwrap();
let hr = HotReload::new(temp.path(), "/tmp/watch");
hr.set_state(serde_json::json!({"counter": 42}));
hr.graceful_shutdown().unwrap();
let content = std::fs::read_to_string(temp.path()).unwrap();
let state: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(state["counter"], 42);
}
#[test]
fn test_hot_reload_restore_state() {
let temp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(temp.path(), r#"{"counter": 99}"#).unwrap();
let hr = HotReload::new(temp.path(), "/tmp/watch");
let state = hr.restore_state().unwrap();
assert_eq!(state["counter"], 99);
assert_eq!(hr.get_state()["counter"], 99);
}
#[test]
fn test_hot_reload_restore_state_no_file() {
let hr = HotReload::new("/nonexistent/path/state.json", "/tmp/watch");
let state = hr.restore_state().unwrap();
assert_eq!(state, serde_json::Value::Null);
}
#[test]
fn test_hot_reload_dev_only() {
let hr = HotReload::new("/tmp/state.json", "/tmp/watch");
assert!(hr.state_path().exists() || !hr.state_path().exists());
}
}