mecha10-behavior-runtime 0.1.25

Behavior tree runtime for Mecha10 - unified AI and logic composition system
Documentation
//! Hot-reload functionality for behavior trees during development
//!
//! This module provides automatic file watching and reloading of behavior trees
//! when changes are detected. It's designed for rapid iteration during development.
//!
//! # Features
//!
//! - Watch behavior tree JSON files for changes
//! - Debounce rapid file system events
//! - Validate trees before reloading
//! - Error handling for invalid trees
//! - Configurable watch paths and debounce duration
//!
//! # Example
//!
//! ```rust,no_run
//! use mecha10_behavior_runtime::prelude::*;
//! use mecha10_behavior_runtime::hot_reload::{HotReloadWatcher, HotReloadConfig, ReloadEvent};
//! use std::path::PathBuf;
//!
//! # async fn example() -> anyhow::Result<()> {
//! let registry = NodeRegistry::new();
//! let loader = BehaviorLoader::new(registry);
//!
//! let config = HotReloadConfig {
//!     enabled: true,
//!     watch_paths: vec![PathBuf::from("behaviors")],
//!     debounce_duration_ms: 500,
//! };
//!
//! let watcher = HotReloadWatcher::new(config, loader);
//!
//! // Start watching for changes
//! let mut receiver = watcher.start().await?;
//!
//! // Handle reload events
//! while let Some(event) = receiver.recv().await {
//!     match event {
//!         ReloadEvent::Success { path, behavior } => {
//!             println!("Reloaded: {:?}", path);
//!         }
//!         ReloadEvent::Error { path, error } => {
//!             eprintln!("Failed to reload {:?}: {}", path, error);
//!         }
//!     }
//! }
//! # Ok(())
//! # }
//! ```

use crate::{BehaviorLoader, BoxedBehavior};
use anyhow::{Context as AnyhowContext, Result};
use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{mpsc, RwLock};
use tracing::{debug, error, info, warn};

/// Configuration for hot-reload behavior.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HotReloadConfig {
    /// Enable or disable hot-reload
    pub enabled: bool,

    /// Paths to watch for behavior tree files
    pub watch_paths: Vec<PathBuf>,

    /// Debounce duration in milliseconds
    /// This prevents rapid reloads when multiple file system events are triggered
    #[serde(default = "default_debounce_duration")]
    pub debounce_duration_ms: u64,
}

fn default_debounce_duration() -> u64 {
    500
}

impl Default for HotReloadConfig {
    fn default() -> Self {
        Self {
            enabled: false,
            watch_paths: vec![PathBuf::from("behaviors")],
            debounce_duration_ms: default_debounce_duration(),
        }
    }
}

/// Event emitted when a behavior tree is reloaded.
#[derive(Debug)]
pub enum ReloadEvent {
    /// Successfully reloaded a behavior tree
    Success {
        /// Path to the reloaded file
        path: PathBuf,
        /// The newly loaded behavior
        behavior: BoxedBehavior,
    },
    /// Failed to reload a behavior tree
    Error {
        /// Path to the file that failed to load
        path: PathBuf,
        /// Error that occurred
        error: String,
    },
}

/// File watcher for automatic behavior tree hot-reload.
///
/// This watches specified directories for changes to behavior tree JSON files
/// and automatically reloads them when modifications are detected.
pub struct HotReloadWatcher {
    config: HotReloadConfig,
    loader: BehaviorLoader,
    pending_reloads: Arc<RwLock<HashMap<PathBuf, tokio::time::Instant>>>,
}

impl HotReloadWatcher {
    /// Create a new hot-reload watcher.
    ///
    /// # Arguments
    ///
    /// * `config` - Hot-reload configuration
    /// * `loader` - Behavior loader with registered node types
    pub fn new(config: HotReloadConfig, loader: BehaviorLoader) -> Self {
        Self {
            config,
            loader,
            pending_reloads: Arc::new(RwLock::new(HashMap::new())),
        }
    }

    /// Start watching for file changes.
    ///
    /// Returns a receiver that emits reload events when behavior trees are reloaded.
    /// The watcher runs in a background task and will continue until dropped.
    ///
    /// # Returns
    ///
    /// A receiver channel for reload events.
    pub async fn start(self) -> Result<mpsc::UnboundedReceiver<ReloadEvent>> {
        if !self.config.enabled {
            info!("Hot-reload is disabled");
            let (_tx, rx) = mpsc::unbounded_channel();
            return Ok(rx);
        }

        let (reload_tx, reload_rx) = mpsc::unbounded_channel();
        let (fs_tx, mut fs_rx) = mpsc::unbounded_channel();

        // Set up file system watcher
        let mut watcher: RecommendedWatcher = notify::recommended_watcher(move |res: Result<Event, _>| match res {
            Ok(event) => {
                if let Err(e) = fs_tx.send(event) {
                    error!("Failed to send file system event: {}", e);
                }
            }
            Err(e) => {
                error!("File system watch error: {}", e);
            }
        })?;

        // Watch all configured paths
        for watch_path in &self.config.watch_paths {
            if !watch_path.exists() {
                warn!("Watch path does not exist: {:?}", watch_path);
                continue;
            }

            watcher
                .watch(watch_path, RecursiveMode::Recursive)
                .with_context(|| format!("Failed to watch path: {:?}", watch_path))?;

            info!("Watching for changes: {:?}", watch_path);
        }

        // Spawn background task to handle file system events
        let loader = self.loader.clone();
        let pending_reloads = self.pending_reloads.clone();
        let debounce_duration = Duration::from_millis(self.config.debounce_duration_ms);

        tokio::spawn(async move {
            // Keep watcher alive for the lifetime of this task
            let _watcher = watcher;

            while let Some(event) = fs_rx.recv().await {
                if let Err(e) = handle_fs_event(event, &loader, &reload_tx, &pending_reloads, debounce_duration).await {
                    error!("Error handling file system event: {}", e);
                }
            }

            info!("Hot-reload watcher stopped");
        });

        Ok(reload_rx)
    }

    /// Check if hot-reload is enabled.
    pub fn is_enabled(&self) -> bool {
        self.config.enabled
    }

    /// Get the configured watch paths.
    pub fn watch_paths(&self) -> &[PathBuf] {
        &self.config.watch_paths
    }
}

/// Handle a file system event.
async fn handle_fs_event(
    event: Event,
    loader: &BehaviorLoader,
    reload_tx: &mpsc::UnboundedSender<ReloadEvent>,
    pending_reloads: &Arc<RwLock<HashMap<PathBuf, tokio::time::Instant>>>,
    debounce_duration: Duration,
) -> Result<()> {
    // Only handle modify and create events
    match event.kind {
        EventKind::Modify(_) | EventKind::Create(_) => {}
        _ => return Ok(()),
    }

    for path in event.paths {
        // Only process JSON files
        if !is_behavior_tree_file(&path) {
            continue;
        }

        debug!("File change detected: {:?}", path);

        // Check debounce
        let now = tokio::time::Instant::now();
        let mut pending = pending_reloads.write().await;

        if let Some(last_reload) = pending.get(&path) {
            if now.duration_since(*last_reload) < debounce_duration {
                debug!("Debouncing reload for: {:?}", path);
                continue;
            }
        }

        pending.insert(path.clone(), now);
        drop(pending);

        // Attempt to reload the behavior tree
        reload_behavior_tree(&path, loader, reload_tx).await;
    }

    Ok(())
}

/// Check if a path is a behavior tree file.
fn is_behavior_tree_file(path: &Path) -> bool {
    path.extension().map(|ext| ext == "json").unwrap_or(false)
}

/// Reload a behavior tree from a file.
async fn reload_behavior_tree(path: &Path, loader: &BehaviorLoader, reload_tx: &mpsc::UnboundedSender<ReloadEvent>) {
    info!("Reloading behavior tree: {:?}", path);

    // Wait a small amount of time to ensure the file write is complete
    tokio::time::sleep(Duration::from_millis(50)).await;

    // Attempt to load the behavior tree
    match loader.load_from_file(path) {
        Ok(behavior) => {
            info!("Successfully reloaded: {:?}", path);
            let event = ReloadEvent::Success {
                path: path.to_path_buf(),
                behavior,
            };
            if let Err(e) = reload_tx.send(event) {
                error!("Failed to send reload event: {}", e);
            }
        }
        Err(e) => {
            error!("Failed to reload {:?}: {}", path, e);
            let event = ReloadEvent::Error {
                path: path.to_path_buf(),
                error: format!("{:#}", e),
            };
            if let Err(e) = reload_tx.send(event) {
                error!("Failed to send reload error event: {}", e);
            }
        }
    }
}