Skip to main content

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("configs/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("configs/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        let mut watcher: RecommendedWatcher = notify::recommended_watcher(move |res: Result<Event, _>| match res {
154            Ok(event) => {
155                if let Err(e) = fs_tx.send(event) {
156                    error!("Failed to send file system event: {}", e);
157                }
158            }
159            Err(e) => {
160                error!("File system watch error: {}", e);
161            }
162        })?;
163
164        for watch_path in &self.config.watch_paths {
165            if !watch_path.exists() {
166                warn!("Watch path does not exist: {:?}", watch_path);
167                continue;
168            }
169
170            watcher
171                .watch(watch_path, RecursiveMode::Recursive)
172                .with_context(|| format!("Failed to watch path: {:?}", watch_path))?;
173
174            info!("Watching for changes: {:?}", watch_path);
175        }
176
177        let loader = self.loader.clone();
178        let pending_reloads = self.pending_reloads.clone();
179        let debounce_duration = Duration::from_millis(self.config.debounce_duration_ms);
180
181        tokio::spawn(async move {
182            let _watcher = watcher;
183
184            while let Some(event) = fs_rx.recv().await {
185                if let Err(e) = handle_fs_event(event, &loader, &reload_tx, &pending_reloads, debounce_duration).await {
186                    error!("Error handling file system event: {}", e);
187                }
188            }
189
190            info!("Hot-reload watcher stopped");
191        });
192
193        Ok(reload_rx)
194    }
195
196    /// Check if hot-reload is enabled.
197    pub fn is_enabled(&self) -> bool {
198        self.config.enabled
199    }
200
201    /// Get the configured watch paths.
202    pub fn watch_paths(&self) -> &[PathBuf] {
203        &self.config.watch_paths
204    }
205}
206
207/// Handle a file system event.
208async fn handle_fs_event(
209    event: Event,
210    loader: &BehaviorLoader,
211    reload_tx: &mpsc::UnboundedSender<ReloadEvent>,
212    pending_reloads: &Arc<RwLock<HashMap<PathBuf, tokio::time::Instant>>>,
213    debounce_duration: Duration,
214) -> Result<()> {
215    match event.kind {
216        EventKind::Modify(_) | EventKind::Create(_) => {}
217        _ => return Ok(()),
218    }
219
220    for path in event.paths {
221        if !is_behavior_tree_file(&path) {
222            continue;
223        }
224
225        debug!("File change detected: {:?}", path);
226
227        let now = tokio::time::Instant::now();
228        let mut pending = pending_reloads.write().await;
229
230        if let Some(last_reload) = pending.get(&path) {
231            if now.duration_since(*last_reload) < debounce_duration {
232                debug!("Debouncing reload for: {:?}", path);
233                continue;
234            }
235        }
236
237        pending.insert(path.clone(), now);
238        drop(pending);
239
240        reload_behavior_tree(&path, loader, reload_tx).await;
241    }
242
243    Ok(())
244}
245
246/// Check if a path is a behavior tree file.
247fn is_behavior_tree_file(path: &Path) -> bool {
248    path.extension().map(|ext| ext == "json").unwrap_or(false)
249}
250
251/// Reload a behavior tree from a file.
252async fn reload_behavior_tree(path: &Path, loader: &BehaviorLoader, reload_tx: &mpsc::UnboundedSender<ReloadEvent>) {
253    info!("Reloading behavior tree: {:?}", path);
254
255    tokio::time::sleep(Duration::from_millis(50)).await;
256
257    match loader.load_from_file(path) {
258        Ok(behavior) => {
259            info!("Successfully reloaded: {:?}", path);
260            let event = ReloadEvent::Success {
261                path: path.to_path_buf(),
262                behavior,
263            };
264            if let Err(e) = reload_tx.send(event) {
265                error!("Failed to send reload event: {}", e);
266            }
267        }
268        Err(e) => {
269            error!("Failed to reload {:?}: {}", path, e);
270            let event = ReloadEvent::Error {
271                path: path.to_path_buf(),
272                error: format!("{:#}", e),
273            };
274            if let Err(e) = reload_tx.send(event) {
275                error!("Failed to send reload error event: {}", e);
276            }
277        }
278    }
279}