//! Content hot-reload via timestamp polling (dev-only). //! //! Periodically checks content YAML files for modifications and triggers //! a full reload when changes are detected. Designed for the authoring //! workflow — not enabled in production builds. //! //! Check interval: every 20 ticks (~2s at 10 tps per D-031). //! Failures are non-critical: previous content is preserved on reload error. use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::time::SystemTime; use bevy_ecs::prelude::*; use crate::content::line_pool::LinePoolIndex; use crate::content::loader; use crate::content::{ContentConfig, ContentStoreResource, LinePoolIndexResource}; /// How often to check for content changes (in system ticks). /// At 10 tps (D-031), 20 ticks = 2 seconds. const CHECK_INTERVAL_TICKS: u64 = 20; /// Consecutive reload failures before escalating to a warning. const FAILURE_WARN_THRESHOLD: u32 = 5; /// Resource tracking content file timestamps for change detection. #[derive(Resource, Debug)] pub struct ContentWatcher { file_timestamps: BTreeMap, ticks_since_check: u64, /// Consecutive reload failures. Resets on success. consecutive_failures: u32, } impl ContentWatcher { /// Create a new watcher and perform initial timestamp scan. /// Returns a watcher with no tracked files if content_root is invalid. pub fn new(content_root: &Path) -> Self { let mut watcher = Self { file_timestamps: BTreeMap::new(), ticks_since_check: 0, consecutive_failures: 0, }; if content_root.as_os_str().is_empty() || !content_root.is_dir() { tracing::warn!( "ContentWatcher: invalid content root {:?}, hot-reload disabled", content_root, ); return watcher; } watcher.scan(content_root); watcher } /// Scan content directory tree and record all YAML file timestamps. fn scan(&mut self, content_root: &Path) { self.file_timestamps.clear(); walk_yaml(content_root, &mut self.file_timestamps, 0); tracing::debug!( "ContentWatcher: tracking {} content files", self.file_timestamps.len() ); } /// Check for changes and rescan. Returns true if any files changed. fn check_and_rescan(&mut self, content_root: &Path) -> bool { let mut new_timestamps = BTreeMap::new(); walk_yaml(content_root, &mut new_timestamps, 0); let changed = new_timestamps != self.file_timestamps; if changed { self.file_timestamps = new_timestamps; } changed } /// Number of tracked files (for diagnostics). pub fn tracked_file_count(&self) -> usize { self.file_timestamps.len() } } /// Maximum recursion depth for directory walking (guards against symlink loops). const MAX_WALK_DEPTH: usize = 100; /// Recursively walk a directory, recording .yaml file modification timestamps. /// Stops recursing at MAX_WALK_DEPTH to guard against symlink loops. fn walk_yaml(dir: &Path, timestamps: &mut BTreeMap, depth: usize) { if depth >= MAX_WALK_DEPTH { tracing::warn!("walk_yaml: max depth {} reached at {:?}, stopping", MAX_WALK_DEPTH, dir); return; } let Ok(entries) = std::fs::read_dir(dir) else { return; }; for entry in entries.filter_map(|e| e.ok()) { let path = entry.path(); if path.is_dir() { walk_yaml(&path, timestamps, depth + 1); } else if path.extension().and_then(|e| e.to_str()) == Some("yaml") { if let Ok(meta) = std::fs::metadata(&path) { if let Ok(modified) = meta.modified() { timestamps.insert(path, modified); } } } } } /// System: periodically check for content file changes and reload. /// /// Only runs when a ContentWatcher resource exists (hot-reload enabled). /// Runs in PostUpdate to avoid interfering with the current tick. pub fn hot_reload_content( config: Res, watcher: Option>, store_res: Option>, index_res: Option>, ) { let Some(mut watcher) = watcher else { return; }; let Some(mut store_res) = store_res else { return; }; let Some(mut index_res) = index_res else { return; }; watcher.ticks_since_check += 1; if watcher.ticks_since_check < CHECK_INTERVAL_TICKS { return; } watcher.ticks_since_check = 0; if !watcher.check_and_rescan(&config.content_root) { return; } tracing::info!("Content files changed, reloading..."); match loader::load_content(&config.content_root) { Ok(store) => { let index = LinePoolIndex::build(&store); let d_count = index.dialogue_line_count(); let m_count = index.monologue_line_count(); store_res.0 = store; index_res.0 = index; watcher.consecutive_failures = 0; tracing::info!( "Content hot-reloaded: {} dialogue lines, {} monologue lines", d_count, m_count ); } Err(e) => { watcher.consecutive_failures += 1; if watcher.consecutive_failures >= FAILURE_WARN_THRESHOLD { tracing::warn!( "Content hot-reload failed {} consecutive times (keeping previous): {}", watcher.consecutive_failures, e, ); } else { tracing::warn!("Content hot-reload failed (keeping previous): {}", e); } } } } #[cfg(test)] mod tests { use super::*; use std::fs; #[test] fn watcher_tracks_yaml_files() { let dir = std::env::temp_dir().join("sr_hotreload_test_track"); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(&dir).unwrap(); fs::write(dir.join("test.yaml"), "key: value\n").unwrap(); fs::write(dir.join("other.txt"), "ignored\n").unwrap(); let watcher = ContentWatcher::new(&dir); assert_eq!(watcher.tracked_file_count(), 1); let _ = fs::remove_dir_all(&dir); } #[test] fn watcher_detects_new_file() { let dir = std::env::temp_dir().join("sr_hotreload_test_new"); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(&dir).unwrap(); fs::write(dir.join("a.yaml"), "key: a\n").unwrap(); let mut watcher = ContentWatcher::new(&dir); assert!(!watcher.check_and_rescan(&dir)); // no change yet fs::write(dir.join("b.yaml"), "key: b\n").unwrap(); assert!(watcher.check_and_rescan(&dir)); // new file detected let _ = fs::remove_dir_all(&dir); } #[test] fn watcher_detects_deleted_file() { let dir = std::env::temp_dir().join("sr_hotreload_test_del"); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(&dir).unwrap(); fs::write(dir.join("a.yaml"), "key: a\n").unwrap(); fs::write(dir.join("b.yaml"), "key: b\n").unwrap(); let mut watcher = ContentWatcher::new(&dir); assert_eq!(watcher.tracked_file_count(), 2); fs::remove_file(dir.join("b.yaml")).unwrap(); assert!(watcher.check_and_rescan(&dir)); let _ = fs::remove_dir_all(&dir); } #[test] fn watcher_detects_modification() { let dir = std::env::temp_dir().join("sr_hotreload_test_mod"); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(&dir).unwrap(); fs::write(dir.join("a.yaml"), "key: a\n").unwrap(); let mut watcher = ContentWatcher::new(&dir); // Sleep briefly to ensure modification time differs std::thread::sleep(std::time::Duration::from_millis(50)); fs::write(dir.join("a.yaml"), "key: modified\n").unwrap(); assert!(watcher.check_and_rescan(&dir)); let _ = fs::remove_dir_all(&dir); } #[test] fn watcher_recurses_subdirectories() { let dir = std::env::temp_dir().join("sr_hotreload_test_recurse"); let _ = fs::remove_dir_all(&dir); let sub = dir.join("sub/deep"); fs::create_dir_all(&sub).unwrap(); fs::write(dir.join("root.yaml"), "key: root\n").unwrap(); fs::write(sub.join("deep.yaml"), "key: deep\n").unwrap(); let watcher = ContentWatcher::new(&dir); assert_eq!(watcher.tracked_file_count(), 2); let _ = fs::remove_dir_all(&dir); } }