Skip to main content

mecha10_behavior_runtime/
hot_reload.rs

1//! Hot-reload functionality for behavior trees during development.
2//!
3//! Watches behavior tree JSON files for changes, debounces events, and validates/reloads
4
5use crate::{BehaviorLoader, BoxedBehavior};
6use anyhow::{Context as AnyhowContext, Result};
7use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12use std::time::Duration;
13use tokio::sync::{mpsc, RwLock};
14use tracing::{debug, error, info, warn};
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct HotReloadConfig {
18    pub enabled: bool,
19
20    pub watch_paths: Vec<PathBuf>,
21
22    #[serde(default = "default_debounce_duration")]
23    pub debounce_duration_ms: u64,
24}
25
26fn default_debounce_duration() -> u64 {
27    500
28}
29
30impl Default for HotReloadConfig {
31    fn default() -> Self {
32        Self {
33            enabled: false,
34            watch_paths: vec![PathBuf::from("configs/behaviors")],
35            debounce_duration_ms: default_debounce_duration(),
36        }
37    }
38}
39
40#[derive(Debug)]
41pub enum ReloadEvent {
42    Success { path: PathBuf, behavior: BoxedBehavior },
43    Error { path: PathBuf, error: String },
44}
45
46pub struct HotReloadWatcher {
47    config: HotReloadConfig,
48    loader: BehaviorLoader,
49    pending_reloads: Arc<RwLock<HashMap<PathBuf, tokio::time::Instant>>>,
50}
51
52impl HotReloadWatcher {
53    /// Create a new hot-reload watcher from `config` and a `loader` with registered node types.
54    pub fn new(config: HotReloadConfig, loader: BehaviorLoader) -> Self {
55        Self {
56            config,
57            loader,
58            pending_reloads: Arc::new(RwLock::new(HashMap::new())),
59        }
60    }
61
62    /// Start watching for file changes, running in a background task until dropped.
63    ///
64    /// Returns a receiver that emits reload events when behavior trees are reloaded.
65    pub async fn start(self) -> Result<mpsc::UnboundedReceiver<ReloadEvent>> {
66        if !self.config.enabled {
67            info!("Hot-reload is disabled");
68            let (_tx, rx) = mpsc::unbounded_channel();
69            return Ok(rx);
70        }
71
72        let (reload_tx, reload_rx) = mpsc::unbounded_channel();
73        let (fs_tx, mut fs_rx) = mpsc::unbounded_channel();
74
75        let mut watcher: RecommendedWatcher = notify::recommended_watcher(move |res: Result<Event, _>| match res {
76            Ok(event) => {
77                if let Err(e) = fs_tx.send(event) {
78                    error!("Failed to send file system event: {}", e);
79                }
80            }
81            Err(e) => {
82                error!("File system watch error: {}", e);
83            }
84        })?;
85
86        for watch_path in &self.config.watch_paths {
87            if !watch_path.exists() {
88                warn!("Watch path does not exist: {:?}", watch_path);
89                continue;
90            }
91
92            watcher
93                .watch(watch_path, RecursiveMode::Recursive)
94                .with_context(|| format!("Failed to watch path: {:?}", watch_path))?;
95
96            info!("Watching for changes: {:?}", watch_path);
97        }
98
99        let loader = self.loader.clone();
100        let pending_reloads = self.pending_reloads.clone();
101        let debounce_duration = Duration::from_millis(self.config.debounce_duration_ms);
102
103        tokio::spawn(async move {
104            let _watcher = watcher;
105
106            while let Some(event) = fs_rx.recv().await {
107                if let Err(e) = handle_fs_event(event, &loader, &reload_tx, &pending_reloads, debounce_duration).await {
108                    error!("Error handling file system event: {}", e);
109                }
110            }
111
112            info!("Hot-reload watcher stopped");
113        });
114
115        Ok(reload_rx)
116    }
117
118    /// Check if hot-reload is enabled.
119    pub fn is_enabled(&self) -> bool {
120        self.config.enabled
121    }
122
123    /// Get the configured watch paths.
124    pub fn watch_paths(&self) -> &[PathBuf] {
125        &self.config.watch_paths
126    }
127}
128
129/// Handle a file system event.
130async fn handle_fs_event(
131    event: Event,
132    loader: &BehaviorLoader,
133    reload_tx: &mpsc::UnboundedSender<ReloadEvent>,
134    pending_reloads: &Arc<RwLock<HashMap<PathBuf, tokio::time::Instant>>>,
135    debounce_duration: Duration,
136) -> Result<()> {
137    match event.kind {
138        EventKind::Modify(_) | EventKind::Create(_) => {}
139        _ => return Ok(()),
140    }
141
142    for path in event.paths {
143        if !is_behavior_tree_file(&path) {
144            continue;
145        }
146
147        debug!("File change detected: {:?}", path);
148
149        let now = tokio::time::Instant::now();
150        let mut pending = pending_reloads.write().await;
151
152        if let Some(last_reload) = pending.get(&path) {
153            if now.duration_since(*last_reload) < debounce_duration {
154                debug!("Debouncing reload for: {:?}", path);
155                continue;
156            }
157        }
158
159        pending.insert(path.clone(), now);
160        drop(pending);
161
162        reload_behavior_tree(&path, loader, reload_tx).await;
163    }
164
165    Ok(())
166}
167
168/// Check if a path is a behavior tree file.
169fn is_behavior_tree_file(path: &Path) -> bool {
170    path.extension().map(|ext| ext == "json").unwrap_or(false)
171}
172
173/// Reload a behavior tree from a file.
174async fn reload_behavior_tree(path: &Path, loader: &BehaviorLoader, reload_tx: &mpsc::UnboundedSender<ReloadEvent>) {
175    info!("Reloading behavior tree: {:?}", path);
176
177    tokio::time::sleep(Duration::from_millis(50)).await;
178
179    match loader.load_from_file(path) {
180        Ok(behavior) => {
181            info!("Successfully reloaded: {:?}", path);
182            let event = ReloadEvent::Success {
183                path: path.to_path_buf(),
184                behavior,
185            };
186            if let Err(e) = reload_tx.send(event) {
187                error!("Failed to send reload event: {}", e);
188            }
189        }
190        Err(e) => {
191            error!("Failed to reload {:?}: {}", path, e);
192            let event = ReloadEvent::Error {
193                path: path.to_path_buf(),
194                error: format!("{:#}", e),
195            };
196            if let Err(e) = reload_tx.send(event) {
197                error!("Failed to send reload error event: {}", e);
198            }
199        }
200    }
201}