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#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct HotReloadConfig {
64    pub enabled: bool,
65
66    pub watch_paths: Vec<PathBuf>,
67
68    #[serde(default = "default_debounce_duration")]
69    pub debounce_duration_ms: u64,
70}
71
72fn default_debounce_duration() -> u64 {
73    500
74}
75
76impl Default for HotReloadConfig {
77    fn default() -> Self {
78        Self {
79            enabled: false,
80            watch_paths: vec![PathBuf::from("configs/behaviors")],
81            debounce_duration_ms: default_debounce_duration(),
82        }
83    }
84}
85
86#[derive(Debug)]
87pub enum ReloadEvent {
88    Success { path: PathBuf, behavior: BoxedBehavior },
89    Error { path: PathBuf, error: String },
90}
91
92pub struct HotReloadWatcher {
93    config: HotReloadConfig,
94    loader: BehaviorLoader,
95    pending_reloads: Arc<RwLock<HashMap<PathBuf, tokio::time::Instant>>>,
96}
97
98impl HotReloadWatcher {
99    /// Create a new hot-reload watcher.
100    ///
101    /// # Arguments
102    ///
103    /// * `config` - Hot-reload configuration
104    /// * `loader` - Behavior loader with registered node types
105    pub fn new(config: HotReloadConfig, loader: BehaviorLoader) -> Self {
106        Self {
107            config,
108            loader,
109            pending_reloads: Arc::new(RwLock::new(HashMap::new())),
110        }
111    }
112
113    /// Start watching for file changes.
114    ///
115    /// Returns a receiver that emits reload events when behavior trees are reloaded.
116    /// The watcher runs in a background task and will continue until dropped.
117    ///
118    /// # Returns
119    ///
120    /// A receiver channel for reload events.
121    pub async fn start(self) -> Result<mpsc::UnboundedReceiver<ReloadEvent>> {
122        if !self.config.enabled {
123            info!("Hot-reload is disabled");
124            let (_tx, rx) = mpsc::unbounded_channel();
125            return Ok(rx);
126        }
127
128        let (reload_tx, reload_rx) = mpsc::unbounded_channel();
129        let (fs_tx, mut fs_rx) = mpsc::unbounded_channel();
130
131        let mut watcher: RecommendedWatcher = notify::recommended_watcher(move |res: Result<Event, _>| match res {
132            Ok(event) => {
133                if let Err(e) = fs_tx.send(event) {
134                    error!("Failed to send file system event: {}", e);
135                }
136            }
137            Err(e) => {
138                error!("File system watch error: {}", e);
139            }
140        })?;
141
142        for watch_path in &self.config.watch_paths {
143            if !watch_path.exists() {
144                warn!("Watch path does not exist: {:?}", watch_path);
145                continue;
146            }
147
148            watcher
149                .watch(watch_path, RecursiveMode::Recursive)
150                .with_context(|| format!("Failed to watch path: {:?}", watch_path))?;
151
152            info!("Watching for changes: {:?}", watch_path);
153        }
154
155        let loader = self.loader.clone();
156        let pending_reloads = self.pending_reloads.clone();
157        let debounce_duration = Duration::from_millis(self.config.debounce_duration_ms);
158
159        tokio::spawn(async move {
160            let _watcher = watcher;
161
162            while let Some(event) = fs_rx.recv().await {
163                if let Err(e) = handle_fs_event(event, &loader, &reload_tx, &pending_reloads, debounce_duration).await {
164                    error!("Error handling file system event: {}", e);
165                }
166            }
167
168            info!("Hot-reload watcher stopped");
169        });
170
171        Ok(reload_rx)
172    }
173
174    /// Check if hot-reload is enabled.
175    pub fn is_enabled(&self) -> bool {
176        self.config.enabled
177    }
178
179    /// Get the configured watch paths.
180    pub fn watch_paths(&self) -> &[PathBuf] {
181        &self.config.watch_paths
182    }
183}
184
185/// Handle a file system event.
186async fn handle_fs_event(
187    event: Event,
188    loader: &BehaviorLoader,
189    reload_tx: &mpsc::UnboundedSender<ReloadEvent>,
190    pending_reloads: &Arc<RwLock<HashMap<PathBuf, tokio::time::Instant>>>,
191    debounce_duration: Duration,
192) -> Result<()> {
193    match event.kind {
194        EventKind::Modify(_) | EventKind::Create(_) => {}
195        _ => return Ok(()),
196    }
197
198    for path in event.paths {
199        if !is_behavior_tree_file(&path) {
200            continue;
201        }
202
203        debug!("File change detected: {:?}", path);
204
205        let now = tokio::time::Instant::now();
206        let mut pending = pending_reloads.write().await;
207
208        if let Some(last_reload) = pending.get(&path) {
209            if now.duration_since(*last_reload) < debounce_duration {
210                debug!("Debouncing reload for: {:?}", path);
211                continue;
212            }
213        }
214
215        pending.insert(path.clone(), now);
216        drop(pending);
217
218        reload_behavior_tree(&path, loader, reload_tx).await;
219    }
220
221    Ok(())
222}
223
224/// Check if a path is a behavior tree file.
225fn is_behavior_tree_file(path: &Path) -> bool {
226    path.extension().map(|ext| ext == "json").unwrap_or(false)
227}
228
229/// Reload a behavior tree from a file.
230async fn reload_behavior_tree(path: &Path, loader: &BehaviorLoader, reload_tx: &mpsc::UnboundedSender<ReloadEvent>) {
231    info!("Reloading behavior tree: {:?}", path);
232
233    tokio::time::sleep(Duration::from_millis(50)).await;
234
235    match loader.load_from_file(path) {
236        Ok(behavior) => {
237            info!("Successfully reloaded: {:?}", path);
238            let event = ReloadEvent::Success {
239                path: path.to_path_buf(),
240                behavior,
241            };
242            if let Err(e) = reload_tx.send(event) {
243                error!("Failed to send reload event: {}", e);
244            }
245        }
246        Err(e) => {
247            error!("Failed to reload {:?}: {}", path, e);
248            let event = ReloadEvent::Error {
249                path: path.to_path_buf(),
250                error: format!("{:#}", e),
251            };
252            if let Err(e) = reload_tx.send(event) {
253                error!("Failed to send reload error event: {}", e);
254            }
255        }
256    }
257}