mecha10_behavior_runtime/
hot_reload.rs

1//! Hot-reload functionality for behavior trees during development
2//!
3//! This module provides automatic file watching and reloading of behavior trees
4//! when changes are detected. It's designed for rapid iteration during development.
5//!
6//! # Features
7//!
8//! - Watch behavior tree JSON files for changes
9//! - Debounce rapid file system events
10//! - Validate trees before reloading
11//! - Error handling for invalid trees
12//! - Configurable watch paths and debounce duration
13//!
14//! # Example
15//!
16//! ```rust,no_run
17//! use mecha10_behavior_runtime::prelude::*;
18//! use mecha10_behavior_runtime::hot_reload::{HotReloadWatcher, HotReloadConfig, ReloadEvent};
19//! use std::path::PathBuf;
20//!
21//! # async fn example() -> anyhow::Result<()> {
22//! let registry = NodeRegistry::new();
23//! let loader = BehaviorLoader::new(registry);
24//!
25//! let config = HotReloadConfig {
26//!     enabled: true,
27//!     watch_paths: vec![PathBuf::from("behaviors")],
28//!     debounce_duration_ms: 500,
29//! };
30//!
31//! let watcher = HotReloadWatcher::new(config, loader);
32//!
33//! // Start watching for changes
34//! let mut receiver = watcher.start().await?;
35//!
36//! // Handle reload events
37//! while let Some(event) = receiver.recv().await {
38//!     match event {
39//!         ReloadEvent::Success { path, behavior } => {
40//!             println!("Reloaded: {:?}", path);
41//!         }
42//!         ReloadEvent::Error { path, error } => {
43//!             eprintln!("Failed to reload {:?}: {}", path, error);
44//!         }
45//!     }
46//! }
47//! # Ok(())
48//! # }
49//! ```
50
51use crate::{BehaviorLoader, BoxedBehavior};
52use anyhow::{Context as AnyhowContext, Result};
53use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
54use serde::{Deserialize, Serialize};
55use std::collections::HashMap;
56use std::path::{Path, PathBuf};
57use std::sync::Arc;
58use std::time::Duration;
59use tokio::sync::{mpsc, RwLock};
60use tracing::{debug, error, info, warn};
61
62/// Configuration for hot-reload behavior.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct HotReloadConfig {
65    /// Enable or disable hot-reload
66    pub enabled: bool,
67
68    /// Paths to watch for behavior tree files
69    pub watch_paths: Vec<PathBuf>,
70
71    /// Debounce duration in milliseconds
72    /// This prevents rapid reloads when multiple file system events are triggered
73    #[serde(default = "default_debounce_duration")]
74    pub debounce_duration_ms: u64,
75}
76
77fn default_debounce_duration() -> u64 {
78    500
79}
80
81impl Default for HotReloadConfig {
82    fn default() -> Self {
83        Self {
84            enabled: false,
85            watch_paths: vec![PathBuf::from("behaviors")],
86            debounce_duration_ms: default_debounce_duration(),
87        }
88    }
89}
90
91/// Event emitted when a behavior tree is reloaded.
92#[derive(Debug)]
93pub enum ReloadEvent {
94    /// Successfully reloaded a behavior tree
95    Success {
96        /// Path to the reloaded file
97        path: PathBuf,
98        /// The newly loaded behavior
99        behavior: BoxedBehavior,
100    },
101    /// Failed to reload a behavior tree
102    Error {
103        /// Path to the file that failed to load
104        path: PathBuf,
105        /// Error that occurred
106        error: String,
107    },
108}
109
110/// File watcher for automatic behavior tree hot-reload.
111///
112/// This watches specified directories for changes to behavior tree JSON files
113/// and automatically reloads them when modifications are detected.
114pub struct HotReloadWatcher {
115    config: HotReloadConfig,
116    loader: BehaviorLoader,
117    pending_reloads: Arc<RwLock<HashMap<PathBuf, tokio::time::Instant>>>,
118}
119
120impl HotReloadWatcher {
121    /// Create a new hot-reload watcher.
122    ///
123    /// # Arguments
124    ///
125    /// * `config` - Hot-reload configuration
126    /// * `loader` - Behavior loader with registered node types
127    pub fn new(config: HotReloadConfig, loader: BehaviorLoader) -> Self {
128        Self {
129            config,
130            loader,
131            pending_reloads: Arc::new(RwLock::new(HashMap::new())),
132        }
133    }
134
135    /// Start watching for file changes.
136    ///
137    /// Returns a receiver that emits reload events when behavior trees are reloaded.
138    /// The watcher runs in a background task and will continue until dropped.
139    ///
140    /// # Returns
141    ///
142    /// A receiver channel for reload events.
143    pub async fn start(self) -> Result<mpsc::UnboundedReceiver<ReloadEvent>> {
144        if !self.config.enabled {
145            info!("Hot-reload is disabled");
146            let (_tx, rx) = mpsc::unbounded_channel();
147            return Ok(rx);
148        }
149
150        let (reload_tx, reload_rx) = mpsc::unbounded_channel();
151        let (fs_tx, mut fs_rx) = mpsc::unbounded_channel();
152
153        // Set up file system watcher
154        let mut watcher: RecommendedWatcher = notify::recommended_watcher(move |res: Result<Event, _>| match res {
155            Ok(event) => {
156                if let Err(e) = fs_tx.send(event) {
157                    error!("Failed to send file system event: {}", e);
158                }
159            }
160            Err(e) => {
161                error!("File system watch error: {}", e);
162            }
163        })?;
164
165        // Watch all configured paths
166        for watch_path in &self.config.watch_paths {
167            if !watch_path.exists() {
168                warn!("Watch path does not exist: {:?}", watch_path);
169                continue;
170            }
171
172            watcher
173                .watch(watch_path, RecursiveMode::Recursive)
174                .with_context(|| format!("Failed to watch path: {:?}", watch_path))?;
175
176            info!("Watching for changes: {:?}", watch_path);
177        }
178
179        // Spawn background task to handle file system events
180        let loader = self.loader.clone();
181        let pending_reloads = self.pending_reloads.clone();
182        let debounce_duration = Duration::from_millis(self.config.debounce_duration_ms);
183
184        tokio::spawn(async move {
185            // Keep watcher alive for the lifetime of this task
186            let _watcher = watcher;
187
188            while let Some(event) = fs_rx.recv().await {
189                if let Err(e) = handle_fs_event(event, &loader, &reload_tx, &pending_reloads, debounce_duration).await {
190                    error!("Error handling file system event: {}", e);
191                }
192            }
193
194            info!("Hot-reload watcher stopped");
195        });
196
197        Ok(reload_rx)
198    }
199
200    /// Check if hot-reload is enabled.
201    pub fn is_enabled(&self) -> bool {
202        self.config.enabled
203    }
204
205    /// Get the configured watch paths.
206    pub fn watch_paths(&self) -> &[PathBuf] {
207        &self.config.watch_paths
208    }
209}
210
211/// Handle a file system event.
212async fn handle_fs_event(
213    event: Event,
214    loader: &BehaviorLoader,
215    reload_tx: &mpsc::UnboundedSender<ReloadEvent>,
216    pending_reloads: &Arc<RwLock<HashMap<PathBuf, tokio::time::Instant>>>,
217    debounce_duration: Duration,
218) -> Result<()> {
219    // Only handle modify and create events
220    match event.kind {
221        EventKind::Modify(_) | EventKind::Create(_) => {}
222        _ => return Ok(()),
223    }
224
225    for path in event.paths {
226        // Only process JSON files
227        if !is_behavior_tree_file(&path) {
228            continue;
229        }
230
231        debug!("File change detected: {:?}", path);
232
233        // Check debounce
234        let now = tokio::time::Instant::now();
235        let mut pending = pending_reloads.write().await;
236
237        if let Some(last_reload) = pending.get(&path) {
238            if now.duration_since(*last_reload) < debounce_duration {
239                debug!("Debouncing reload for: {:?}", path);
240                continue;
241            }
242        }
243
244        pending.insert(path.clone(), now);
245        drop(pending);
246
247        // Attempt to reload the behavior tree
248        reload_behavior_tree(&path, loader, reload_tx).await;
249    }
250
251    Ok(())
252}
253
254/// Check if a path is a behavior tree file.
255fn is_behavior_tree_file(path: &Path) -> bool {
256    path.extension().map(|ext| ext == "json").unwrap_or(false)
257}
258
259/// Reload a behavior tree from a file.
260async fn reload_behavior_tree(path: &Path, loader: &BehaviorLoader, reload_tx: &mpsc::UnboundedSender<ReloadEvent>) {
261    info!("Reloading behavior tree: {:?}", path);
262
263    // Wait a small amount of time to ensure the file write is complete
264    tokio::time::sleep(Duration::from_millis(50)).await;
265
266    // Attempt to load the behavior tree
267    match loader.load_from_file(path) {
268        Ok(behavior) => {
269            info!("Successfully reloaded: {:?}", path);
270            let event = ReloadEvent::Success {
271                path: path.to_path_buf(),
272                behavior,
273            };
274            if let Err(e) = reload_tx.send(event) {
275                error!("Failed to send reload event: {}", e);
276            }
277        }
278        Err(e) => {
279            error!("Failed to reload {:?}: {}", path, e);
280            let event = ReloadEvent::Error {
281                path: path.to_path_buf(),
282                error: format!("{:#}", e),
283            };
284            if let Err(e) = reload_tx.send(event) {
285                error!("Failed to send reload error event: {}", e);
286            }
287        }
288    }
289}