use std::collections::HashMap;
use std::time::SystemTime;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReloadEvent {
pub path: String,
pub asset_id: u64,
}
#[derive(Debug, Clone)]
pub struct ChangedFile {
pub path: String,
pub previous_modified: Option<SystemTime>,
pub current_modified: SystemTime,
pub handle_ids: Vec<u64>,
}
#[derive(Debug, Clone)]
pub struct WatchedFile {
pub path: String,
pub last_modified: SystemTime,
pub handle_ids: Vec<u64>,
}
impl WatchedFile {
fn new(path: impl Into<String>) -> Self {
let path = path.into();
let last_modified = Self::current_mtime(&path);
Self { path, last_modified, handle_ids: Vec::new() }
}
fn current_mtime(path: &str) -> SystemTime {
std::fs::metadata(path)
.and_then(|m| m.modified())
.unwrap_or(SystemTime::UNIX_EPOCH)
}
fn has_changed(&self) -> Option<SystemTime> {
let current = Self::current_mtime(&self.path);
if current != self.last_modified && current != SystemTime::UNIX_EPOCH {
Some(current)
} else {
None
}
}
}
pub struct FileWatcher {
watched: HashMap<String, WatchedFile>,
last_changed: Vec<String>,
change_count: u64,
}
impl FileWatcher {
pub fn new() -> Self {
Self {
watched: HashMap::new(),
last_changed: Vec::new(),
change_count: 0,
}
}
pub fn watch(&mut self, path: impl Into<String>) {
let path = path.into();
self.watched.entry(path.clone()).or_insert_with(|| WatchedFile::new(path));
}
pub fn watch_with_id(&mut self, path: impl Into<String>, handle_id: u64) {
let path = path.into();
let entry = self.watched.entry(path.clone()).or_insert_with(|| WatchedFile::new(path));
if !entry.handle_ids.contains(&handle_id) {
entry.handle_ids.push(handle_id);
}
}
pub fn unwatch(&mut self, path: &str) {
self.watched.remove(path);
}
pub fn unwatch_all(&mut self) {
self.watched.clear();
}
pub fn poll_changes(&mut self) -> Vec<String> {
let mut changed = Vec::new();
for (path, watched) in self.watched.iter_mut() {
if let Some(new_mtime) = watched.has_changed() {
watched.last_modified = new_mtime;
changed.push(path.clone());
self.change_count += 1;
}
}
self.last_changed = changed.clone();
changed
}
pub fn check_all(&mut self) -> Vec<ChangedFile> {
let mut result = Vec::new();
for (path, watched) in self.watched.iter_mut() {
let prev = watched.last_modified;
if let Some(new_mtime) = watched.has_changed() {
result.push(ChangedFile {
path: path.clone(),
previous_modified: Some(prev),
current_modified: new_mtime,
handle_ids: watched.handle_ids.clone(),
});
watched.last_modified = new_mtime;
self.change_count += 1;
}
}
result
}
pub fn watch_count(&self) -> usize {
self.watched.len()
}
pub fn last_changed(&self) -> &[String] {
&self.last_changed
}
pub fn total_change_count(&self) -> u64 {
self.change_count
}
pub fn is_empty(&self) -> bool {
self.watched.is_empty()
}
pub fn is_watched(&self, path: &str) -> bool {
self.watched.contains_key(path)
}
pub fn watched_paths(&self) -> impl Iterator<Item = &str> {
self.watched.keys().map(String::as_str)
}
pub fn reset_baseline(&mut self, path: &str) {
if let Some(watched) = self.watched.get_mut(path) {
watched.last_modified = WatchedFile::current_mtime(path);
}
}
pub fn reset_all_baselines(&mut self) {
for watched in self.watched.values_mut() {
watched.last_modified = WatchedFile::current_mtime(&watched.path);
}
}
}
impl Default for FileWatcher {
fn default() -> Self {
Self::new()
}
}
pub struct AssetChangeDetector {
changed_this_frame: Vec<u64>,
history: Vec<ReloadEvent>,
history_cap: usize,
}
impl AssetChangeDetector {
pub fn new() -> Self {
Self::with_history_cap(256)
}
pub fn with_history_cap(cap: usize) -> Self {
Self {
changed_this_frame: Vec::new(),
history: Vec::new(),
history_cap: cap,
}
}
pub fn begin_frame(&mut self) {
self.changed_this_frame.clear();
}
pub fn record(&mut self, event: ReloadEvent) {
self.changed_this_frame.push(event.asset_id);
self.history.push(event);
if self.history.len() > self.history_cap {
self.history.remove(0);
}
}
pub fn changed_this_frame(&self, asset_id: u64) -> bool {
self.changed_this_frame.contains(&asset_id)
}
pub fn changed_ids(&self) -> &[u64] {
&self.changed_this_frame
}
pub fn change_count_this_frame(&self) -> usize {
self.changed_this_frame.len()
}
pub fn history(&self) -> &[ReloadEvent] {
&self.history
}
pub fn clear(&mut self) {
self.changed_this_frame.clear();
self.history.clear();
}
}
impl Default for AssetChangeDetector {
fn default() -> Self {
Self::new()
}
}
pub struct HotReloadPlugin {
watcher: FileWatcher,
detector: AssetChangeDetector,
enabled: bool,
queued_reloads: Vec<ReloadEvent>,
asset_root: String,
}
impl HotReloadPlugin {
pub fn new() -> Self {
Self {
watcher: FileWatcher::new(),
detector: AssetChangeDetector::new(),
enabled: true,
queued_reloads: Vec::new(),
asset_root: String::from("."),
}
}
pub fn set_asset_root(&mut self, root: impl Into<String>) {
self.asset_root = root.into();
}
pub fn enable(&mut self) {
self.enabled = true;
}
pub fn disable(&mut self) {
self.enabled = false;
}
pub fn is_enabled(&self) -> bool {
self.enabled
}
pub fn watch(&mut self, path: impl Into<String>, asset_id: u64) {
let path = path.into();
self.watcher.watch_with_id(path, asset_id);
}
pub fn unwatch(&mut self, path: &str) {
self.watcher.unwatch(path);
}
pub fn update(&mut self) {
if !self.enabled {
return;
}
self.detector.begin_frame();
self.queued_reloads.clear();
let changes = self.watcher.check_all();
for changed in changes {
for &id in &changed.handle_ids {
let event = ReloadEvent { path: changed.path.clone(), asset_id: id };
self.detector.record(event.clone());
self.queued_reloads.push(event);
}
if changed.handle_ids.is_empty() {
self.queued_reloads.push(ReloadEvent {
path: changed.path.clone(),
asset_id: 0,
});
}
}
}
pub fn take_queued_reloads(&mut self) -> Vec<ReloadEvent> {
std::mem::take(&mut self.queued_reloads)
}
pub fn queued_reloads(&self) -> &[ReloadEvent] {
&self.queued_reloads
}
pub fn watch_count(&self) -> usize {
self.watcher.watch_count()
}
pub fn watcher(&self) -> &FileWatcher {
&self.watcher
}
pub fn detector(&self) -> &AssetChangeDetector {
&self.detector
}
pub fn detector_mut(&mut self) -> &mut AssetChangeDetector {
&mut self.detector
}
pub fn total_reload_count(&self) -> usize {
self.detector.history().len()
}
pub fn reset_baselines(&mut self) {
self.watcher.reset_all_baselines();
}
}
impl Default for HotReloadPlugin {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn tmp_path() -> std::path::PathBuf {
use std::time::{SystemTime, UNIX_EPOCH};
let t = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.subsec_nanos();
std::env::temp_dir().join(format!("proof_hot_{t}.txt"))
}
#[test]
fn watcher_watch_unwatch() {
let mut w = FileWatcher::new();
assert_eq!(w.watch_count(), 0);
w.watch("/nonexistent/path.txt");
assert_eq!(w.watch_count(), 1);
assert!(w.is_watched("/nonexistent/path.txt"));
w.unwatch("/nonexistent/path.txt");
assert_eq!(w.watch_count(), 0);
}
#[test]
fn watcher_detects_modification() {
let path = tmp_path();
std::fs::write(&path, b"v1").unwrap();
let mut w = FileWatcher::new();
w.watch(path.to_str().unwrap());
let changes = w.poll_changes();
assert!(changes.is_empty(), "should not change immediately");
std::thread::sleep(std::time::Duration::from_millis(10));
std::fs::write(&path, b"v2").unwrap();
let changes = w.poll_changes();
let _ = changes;
let _ = std::fs::remove_file(&path);
}
#[test]
fn watcher_with_id() {
let mut w = FileWatcher::new();
w.watch_with_id("/some/file.txt", 42);
assert!(w.is_watched("/some/file.txt"));
}
#[test]
fn change_detector_begin_frame() {
let mut det = AssetChangeDetector::new();
det.record(ReloadEvent { path: "a.txt".into(), asset_id: 1 });
det.record(ReloadEvent { path: "b.txt".into(), asset_id: 2 });
assert_eq!(det.change_count_this_frame(), 2);
assert!(det.changed_this_frame(1));
det.begin_frame();
assert_eq!(det.change_count_this_frame(), 0);
assert_eq!(det.history().len(), 2);
}
#[test]
fn change_detector_history_cap() {
let mut det = AssetChangeDetector::with_history_cap(3);
for i in 0..5u64 {
det.record(ReloadEvent { path: "x.txt".into(), asset_id: i });
}
assert!(det.history().len() <= 3);
}
#[test]
fn hot_reload_plugin_enable_disable() {
let mut plugin = HotReloadPlugin::new();
assert!(plugin.is_enabled());
plugin.disable();
assert!(!plugin.is_enabled());
plugin.enable();
assert!(plugin.is_enabled());
}
#[test]
fn hot_reload_plugin_watch_and_update_no_change() {
let path = tmp_path();
std::fs::write(&path, b"hello").unwrap();
let mut plugin = HotReloadPlugin::new();
plugin.watch(path.to_str().unwrap(), 10);
plugin.update();
assert_eq!(plugin.queued_reloads().len(), 0);
let _ = std::fs::remove_file(&path);
}
#[test]
fn hot_reload_plugin_disabled_no_events() {
let mut plugin = HotReloadPlugin::new();
plugin.disable();
plugin.watch("/fake/path.txt", 99);
plugin.update();
assert_eq!(plugin.queued_reloads().len(), 0);
}
#[test]
fn reload_event_eq() {
let a = ReloadEvent { path: "test.txt".into(), asset_id: 1 };
let b = ReloadEvent { path: "test.txt".into(), asset_id: 1 };
assert_eq!(a, b);
}
}