use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{self, Receiver, Sender};
use std::thread::{self, JoinHandle};
use std::time::{Duration, SystemTime};
const POLL_INTERVAL: Duration = Duration::from_millis(200);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum SourceKind {
Html,
Css,
Js,
}
pub(super) type SourceWatches<'a> = Vec<(SourceKind, &'a str)>;
#[derive(Debug)]
pub(super) enum HotReloadEvent {
Html(String),
Css(String),
Js(String),
}
impl HotReloadEvent {
fn from_source(kind: SourceKind, content: String) -> Self {
match kind {
SourceKind::Html => Self::Html(content),
SourceKind::Css => Self::Css(content),
SourceKind::Js => Self::Js(content),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct FileSignature {
modified: Option<SystemTime>,
len: u64,
}
impl FileSignature {
fn new(path: &Path) -> std::io::Result<Self> {
let metadata = fs::metadata(path)?;
Ok(Self {
modified: metadata.modified().ok(),
len: metadata.len(),
})
}
}
struct WatchState {
kind: SourceKind,
path: PathBuf,
signature: Option<FileSignature>,
content: Option<String>,
pending: Option<String>,
}
impl WatchState {
fn new(kind: SourceKind, path: &str) -> Self {
let path = PathBuf::from(path);
Self {
kind,
signature: FileSignature::new(&path).ok(),
content: read(&path).ok(),
path,
pending: None,
}
}
fn poll(&mut self, tx: &Sender<HotReloadEvent>) -> bool {
let signature = FileSignature::new(&self.path).ok();
if signature == self.signature {
if let Some(content) = self.pending.take()
&& tx
.send(HotReloadEvent::from_source(self.kind, content))
.is_err()
{
return false;
}
return true;
}
let content = match read(&self.path) {
Ok(content) => Some(content),
Err(e) => {
log::warn!("Cannot read hot-reload source {}: {e}", self.path.display());
None
}
};
self.pending = if content.as_ref() == self.content.as_ref() {
None
} else {
content.clone()
};
self.content = content;
self.signature = signature;
true
}
}
pub(super) struct HotReloadWatcher {
rx: Receiver<HotReloadEvent>,
#[cfg_attr(not(test), allow(dead_code))]
ready_rx: Receiver<()>,
stop: Arc<AtomicBool>,
handle: Option<JoinHandle<()>>,
}
impl HotReloadWatcher {
pub(super) fn receiver(&self) -> &Receiver<HotReloadEvent> {
&self.rx
}
#[cfg(test)]
pub(super) fn ready_receiver(&self) -> &Receiver<()> {
&self.ready_rx
}
}
impl Drop for HotReloadWatcher {
fn drop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
if let Some(handle) = self.handle.take()
&& let Err(payload) = handle.join()
{
log::error!("Hot-reload watcher panicked: {payload:?}");
}
}
}
pub(super) fn spawn(watches: SourceWatches<'_>) -> HotReloadWatcher {
let (tx, rx) = mpsc::channel();
let (ready_tx, ready_rx) = mpsc::channel();
let stop = Arc::new(AtomicBool::new(false));
let thread_stop = Arc::clone(&stop);
let watches = watches
.into_iter()
.map(|(kind, path)| (kind, path.to_owned()))
.collect::<Vec<_>>();
let handle = thread::Builder::new()
.name("htui-hot-reload".to_string())
.spawn(move || {
let mut states = watches
.into_iter()
.map(|(kind, path)| WatchState::new(kind, &path))
.collect::<Vec<_>>();
let _ = ready_tx.send(());
while !thread_stop.load(Ordering::Relaxed) {
thread::sleep(POLL_INTERVAL);
if thread_stop.load(Ordering::Relaxed) {
break;
}
if !states.iter_mut().all(|state| state.poll(&tx)) {
break;
}
}
});
let handle = match handle {
Ok(handle) => Some(handle),
Err(e) => {
log::error!("Failed to start hot-reload watcher: {e}");
None
}
};
HotReloadWatcher {
rx,
ready_rx,
stop,
handle,
}
}
fn read(path: &Path) -> std::io::Result<String> {
fs::read_to_string(path)
}
#[cfg(test)]
mod tests {
use super::{HotReloadEvent, POLL_INTERVAL, SourceKind, spawn};
use std::fs;
use std::time::{Duration, SystemTime};
#[test]
fn watcher_sends_only_stable_changed_contents() {
let unique = format!(
"htui-hot-reload-{}-{:?}.css",
std::process::id(),
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("system time is after the Unix epoch")
);
let path = std::env::temp_dir().join(unique);
fs::write(&path, "p { color: red; }").expect("write initial fixture");
let watcher = spawn(vec![(
SourceKind::Css,
path.to_str().expect("temp path is UTF-8"),
)]);
let rx = watcher.receiver();
watcher
.ready_receiver()
.recv_timeout(Duration::from_secs(2))
.expect("watcher establishes its initial baseline");
assert!(
rx.recv_timeout(POLL_INTERVAL * 2).is_err(),
"startup must not emit an immediate reload"
);
fs::write(&path, "p { color: blue; }").expect("write changed fixture");
let changed = rx
.recv_timeout(Duration::from_secs(2))
.expect("stable change reaches the watcher channel");
assert!(matches!(changed, HotReloadEvent::Css(css) if css.contains("blue")));
let _ = fs::remove_file(path);
drop(watcher);
}
#[test]
fn metadata_change_with_identical_content_does_not_emit() -> std::io::Result<()> {
let unique = format!(
"htui-hot-reload-metadata-{}-{:?}.css",
std::process::id(),
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("system time is after the Unix epoch")
);
let path = std::env::temp_dir().join(unique);
let css = "p { color: teal; }";
fs::write(&path, css).expect("write initial fixture");
let watcher = spawn(vec![(
SourceKind::Css,
path.to_str().expect("temp path is UTF-8"),
)]);
let rx = watcher.receiver();
watcher
.ready_receiver()
.recv_timeout(Duration::from_secs(2))
.expect("watcher establishes its initial baseline");
assert!(
rx.recv_timeout(POLL_INTERVAL * 2).is_err(),
"startup must not emit"
);
fs::write(&path, css).expect("rewrite identical fixture");
let file = fs::OpenOptions::new().write(true).open(&path)?;
file.set_modified(
SystemTime::now()
.checked_add(Duration::from_secs(1))
.expect("valid future timestamp"),
)?;
assert!(
rx.recv_timeout(POLL_INTERVAL * 3).is_err(),
"metadata-only changes must not reset the page"
);
let _ = fs::remove_file(path);
drop(watcher);
Ok(())
}
}