mecha10-core 0.6.2

Mecha10 core runtime and foundational types
Documentation
//! Configuration Hot-Reload Support
//!
//! Provides file watching and automatic reloading of configuration files
//! with validation and change notifications.
//!
//! # Features
//!
//! - File system watching with debouncing
//! - Automatic validation on reload
//! - Change callbacks for application updates
//! - Support for JSON, YAML, and TOML configs
//! - Thread-safe shared configuration state
//! - Graceful error handling (keeps old config on invalid changes)
//!
//! # Example
//!
//! ```rust
//! use mecha10::prelude::*;
//! use mecha10::config_watcher::ConfigWatcher;
//!
//! #[derive(Debug, Clone, Deserialize)]
//! struct MyConfig {
//!     rate: f32,
//!     enabled: bool,
//! }
//!
//! #[tokio::main]
//! async fn main() -> Result<()> {
//!     // Create watcher with callback
//!     let watcher = ConfigWatcher::new("config.yaml", |config: &MyConfig| {
//!         println!("Config reloaded! Rate: {}", config.rate);
//!     })?;
//!
//!     // Get current config
//!     let config = watcher.get();
//!     println!("Initial rate: {}", config.rate);
//!
//!     // Config automatically reloads when file changes
//!     tokio::signal::ctrl_c().await?;
//!     Ok(())
//! }
//! ```

use crate::{Mecha10Error, Result};
use anyhow::Context as _;
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher as NotifyWatcher};
use serde::de::DeserializeOwned;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::RwLock;
use tokio::time::sleep;
use tracing::{debug, error, info, warn};

// ============================================================================
// Configuration Watcher
// ============================================================================

/// Hot-reload configuration watcher
///
/// Watches a configuration file for changes and automatically reloads it.
/// Validates new configurations before applying them.
///
/// # Type Parameters
///
/// * `T` - The configuration type (must implement `DeserializeOwned + Clone + Send + Sync`)
///
/// # Example
///
/// ```rust
/// use mecha10::config_watcher::ConfigWatcher;
/// use serde::Deserialize;
///
/// #[derive(Debug, Clone, Deserialize)]
/// struct AppConfig {
///     port: u16,
///     debug: bool,
/// }
///
/// let watcher = ConfigWatcher::new("config.yaml", |config: &AppConfig| {
///     println!("Port changed to: {}", config.port);
/// })?;
///
/// // Get current config
/// let config = watcher.get();
/// ```
pub struct ConfigWatcher<T>
where
    T: DeserializeOwned + Clone + Send + Sync + 'static,
{
    /// Current configuration
    config: Arc<RwLock<T>>,

    /// Path to config file
    path: PathBuf,

    /// File watcher
    _watcher: RecommendedWatcher,
}

impl<T> ConfigWatcher<T>
where
    T: DeserializeOwned + Clone + Send + Sync + 'static,
{
    /// Create a new configuration watcher
    ///
    /// # Arguments
    ///
    /// * `path` - Path to configuration file
    /// * `on_change` - Callback invoked when configuration changes
    ///
    /// # Returns
    ///
    /// A new `ConfigWatcher` instance
    ///
    /// # Errors
    ///
    /// Returns error if:
    /// - File doesn't exist
    /// - Initial configuration is invalid
    /// - File watcher can't be created
    ///
    /// # Example
    ///
    /// ```rust
    /// let watcher = ConfigWatcher::new("config.yaml", |config: &MyConfig| {
    ///     // Handle configuration change
    ///     println!("Config updated!");
    /// })?;
    /// ```
    pub fn new<P, F>(path: P, on_change: F) -> Result<Self>
    where
        P: AsRef<Path>,
        F: Fn(&T) + Send + Sync + 'static,
    {
        let path = path.as_ref().to_path_buf();

        // Load initial configuration
        let initial_config = Self::load_config(&path)?;
        let config = Arc::new(RwLock::new(initial_config));

        // Create callback wrapper
        let config_clone = Arc::clone(&config);
        let path_clone = path.clone();
        let on_change = Arc::new(on_change);

        // Create file watcher
        let (tx, mut rx) = tokio::sync::mpsc::channel(100);

        let mut watcher = RecommendedWatcher::new(
            move |res: notify::Result<Event>| {
                if let Ok(event) = res {
                    // Only care about modify events
                    if matches!(event.kind, EventKind::Modify(_) | EventKind::Create(_)) {
                        let _ = tx.blocking_send(event);
                    }
                }
            },
            Config::default().with_poll_interval(Duration::from_millis(500)),
        )
        .map_err(|e| Mecha10Error::Configuration(format!("Failed to create file watcher: {}", e)))?;

        // Watch the config file
        watcher
            .watch(&path, RecursiveMode::NonRecursive)
            .map_err(|e| Mecha10Error::Configuration(format!("Failed to watch file {}: {}", path.display(), e)))?;

        info!("Started watching config file: {}", path.display());

        // Spawn reload task
        tokio::spawn(async move {
            let mut last_reload = tokio::time::Instant::now();

            while let Some(_event) = rx.recv().await {
                // Debounce: ignore events within 1 second of last reload
                if last_reload.elapsed() < Duration::from_secs(1) {
                    continue;
                }

                debug!("Config file changed, debouncing...");

                // Wait a bit for file writes to complete
                sleep(Duration::from_millis(100)).await;

                // Attempt to reload
                match Self::load_config(&path_clone) {
                    Ok(new_config) => {
                        info!("Successfully reloaded config from: {}", path_clone.display());

                        // Update shared config
                        {
                            let mut config = config_clone.write().await;
                            *config = new_config.clone();
                        }

                        // Invoke callback
                        on_change(&new_config);

                        last_reload = tokio::time::Instant::now();
                    }
                    Err(e) => {
                        error!(
                            "Failed to reload config from {}: {}. Keeping old config.",
                            path_clone.display(),
                            e
                        );
                        warn!("Fix the configuration errors to apply changes.");
                    }
                }
            }
        });

        Ok(Self {
            config,
            path,
            _watcher: watcher,
        })
    }

    /// Get the current configuration
    ///
    /// Returns a clone of the current configuration.
    ///
    /// # Example
    ///
    /// ```rust
    /// let config = watcher.get();
    /// println!("Current rate: {}", config.rate);
    /// ```
    pub fn get(&self) -> T {
        let config = self.config.blocking_read();
        config.clone()
    }

    /// Get the current configuration asynchronously
    ///
    /// Returns a clone of the current configuration.
    ///
    /// # Example
    ///
    /// ```rust
    /// let config = watcher.get_async().await;
    /// println!("Current rate: {}", config.rate);
    /// ```
    pub async fn get_async(&self) -> T {
        let config = self.config.read().await;
        config.clone()
    }

    /// Manually reload the configuration
    ///
    /// Forces a reload of the configuration from disk.
    ///
    /// # Returns
    ///
    /// Ok(()) if reload succeeded, Err if loading or validation failed
    ///
    /// # Example
    ///
    /// ```rust
    /// watcher.reload().await?;
    /// ```
    pub async fn reload(&self) -> Result<()> {
        let new_config = Self::load_config(&self.path)?;

        let mut config = self.config.write().await;
        *config = new_config;

        info!("Manually reloaded config from: {}", self.path.display());
        Ok(())
    }

    /// Get the path to the configuration file
    pub fn path(&self) -> &Path {
        &self.path
    }

    // Helper: Load and parse configuration file
    fn load_config(path: &Path) -> Result<T> {
        use crate::config::load_config;

        load_config(path)
            .with_context(|| format!("Failed to load configuration from {}", path.display()))
            .map_err(|e| Mecha10Error::Configuration(format!("{:#}", e)))
    }
}

// ============================================================================
// Validated Configuration Watcher
// ============================================================================

/// Configuration watcher with custom validation
///
/// Like `ConfigWatcher`, but also runs custom validation logic before applying changes.
///
/// # Example
///
/// ```rust
/// use mecha10::config_watcher::ValidatedConfigWatcher;
/// use mecha10::config::Validate;
///
/// #[derive(Debug, Clone, Deserialize)]
/// struct AppConfig {
///     port: u16,
/// }
///
/// impl Validate for AppConfig {
///     fn validate(&self) -> Result<()> {
///         if self.port < 1024 {
///             return Err(Mecha10Error::Configuration(
///                 "Port must be >= 1024".to_string()
///             ));
///         }
///         Ok(())
///     }
/// }
///
/// let watcher = ValidatedConfigWatcher::new("config.yaml", |config: &AppConfig| {
///     println!("Valid config loaded!");
/// })?;
/// ```
pub struct ValidatedConfigWatcher<T>
where
    T: DeserializeOwned + Clone + Send + Sync + crate::config::Validate + 'static,
{
    inner: ConfigWatcher<T>,
}

impl<T> ValidatedConfigWatcher<T>
where
    T: DeserializeOwned + Clone + Send + Sync + crate::config::Validate + 'static,
{
    /// Create a new validated configuration watcher
    ///
    /// # Arguments
    ///
    /// * `path` - Path to configuration file
    /// * `on_change` - Callback invoked when configuration changes
    ///
    /// # Returns
    ///
    /// A new `ValidatedConfigWatcher` instance
    ///
    /// # Errors
    ///
    /// Returns error if initial configuration is invalid
    pub fn new<P, F>(path: P, on_change: F) -> Result<Self>
    where
        P: AsRef<Path>,
        F: Fn(&T) + Send + Sync + 'static,
    {
        // Wrap on_change to include validation
        let inner = ConfigWatcher::new(path, move |config: &T| {
            if let Err(e) = config.validate() {
                error!("Configuration validation failed: {}", e);
                warn!("Keeping previous configuration.");
                return;
            }
            on_change(config);
        })?;

        Ok(Self { inner })
    }

    /// Get the current configuration
    pub fn get(&self) -> T {
        self.inner.get()
    }

    /// Get the current configuration asynchronously
    pub async fn get_async(&self) -> T {
        self.inner.get_async().await
    }

    /// Manually reload and validate the configuration
    pub async fn reload(&self) -> Result<()> {
        self.inner.reload().await?;
        self.inner.get().validate()?;
        Ok(())
    }

    /// Get the path to the configuration file
    pub fn path(&self) -> &Path {
        self.inner.path()
    }
}