How to Watch for Configuration Changes at Runtime in Rust

You can watch for configuration changes at runtime by using the `notify` crate to monitor file system events and triggering a reload handler when a modification is detected.

You can watch for configuration changes at runtime by using the notify crate to monitor file system events and triggering a reload handler when a modification is detected. This approach allows your application to react immediately to edits without requiring a restart, keeping your runtime state synchronized with the latest configuration.

Here is a practical implementation using notify to watch a JSON config file and reload it into an Atomic wrapper:

use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher};
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;

// Simple config struct
#[derive(Debug, Clone)]
struct Config {
    log_level: String,
    port: u16,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let config_path = "config.json";
    let should_reload = Arc::new(AtomicBool::new(false));
    let config_lock = Arc::new(std::sync::Mutex::new(load_config(config_path)?));

    // Clone for the watcher thread
    let should_reload_clone = Arc::clone(&should_reload);
    let config_path_clone = config_path.to_string();
    let config_lock_clone = Arc::clone(&config_lock);

    // Start the watcher
    let mut watcher = RecommendedWatcher::new(move |res| {
        if let Ok(event) = res {
            if event.kind.is_modify() || event.kind.is_create() {
                println!("Config changed! Reloading...");
                should_reload_clone.store(true, Ordering::SeqCst);
            }
        }
    }, Default::default())?;

    watcher.watch(Path::new(&config_path_clone), RecursiveMode::NonRecursive)?;

    // Main loop to handle reloads safely
    loop {
        if should_reload.load(Ordering::SeqCst) {
            should_reload.store(false, Ordering::SeqCst);
            
            // Reload logic
            match load_config(&config_path_clone) {
                Ok(new_config) => {
                    let mut cfg = config_lock_clone.lock().unwrap();
                    *cfg = new_config;
                    println!("Configuration reloaded successfully.");
                }
                Err(e) => eprintln!("Failed to reload config: {}", e),
            }
        }
        thread::sleep(Duration::from_millis(100));
    }
}

fn load_config(path: &str) -> Result<Config, Box<dyn std::error::Error>> {
    let content = std::fs::read_to_string(path)?;
    // In a real app, use serde_json::from_str
    Ok(Config {
        log_level: "info".to_string(),
        port: 8080,
    })
}

For a more robust solution that handles debouncing (ignoring rapid successive saves) and complex file structures, consider using the config crate combined with notify. The config crate supports watching files natively in some versions, but manually wiring notify gives you finer control over the reload logic.

Key considerations for production use:

  1. Debouncing: File editors often write multiple times during a single save. The notify crate provides Config options to debounce events, or you can implement a simple timer in your event handler to wait for a quiet period before reloading.
  2. Atomic Swaps: If your configuration is read frequently, avoid locking a Mutex on every read. Instead, use Arc<AtomicUsize> for simple values or Arc<Config> with Arc::swap to atomically replace the entire configuration object, ensuring readers always see a consistent snapshot.
  3. Error Handling: Always handle reload failures gracefully. If the new config is invalid, keep the old one running and log the error to avoid crashing the service.