ryo-server 0.1.0

[preview] RYO Server - tarpc-based RPC server for ryo operations
Documentation
//! File system watcher for automatic reloading
//!
//! Watches project files for changes and notifies the server to reload.

use notify::RecursiveMode;
use notify_debouncer_mini::{new_debouncer, DebouncedEventKind};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::time::Duration;
use tokio::sync::mpsc as tokio_mpsc;

/// Events from the file watcher
#[derive(Debug, Clone)]
pub enum WatchEvent {
    /// Files were modified (debounced list of paths)
    FilesChanged(Vec<PathBuf>),
    /// Watcher encountered an error
    Error(String),
}

/// Configuration for the file watcher
#[derive(Debug, Clone)]
pub struct WatcherConfig {
    /// Debounce duration (time to wait for more changes before processing)
    pub debounce: Duration,
    /// File extensions to watch (e.g., ["rs"])
    pub extensions: Vec<String>,
    /// Directories to ignore
    pub ignore_dirs: Vec<String>,
}

impl Default for WatcherConfig {
    fn default() -> Self {
        Self {
            debounce: Duration::from_millis(500),
            extensions: vec!["rs".to_string()],
            ignore_dirs: vec![
                "target".to_string(),
                ".git".to_string(),
                "node_modules".to_string(),
            ],
        }
    }
}

/// File system watcher that monitors project files for changes
pub struct FileWatcher {
    /// Channel to receive watch events
    event_rx: tokio_mpsc::Receiver<WatchEvent>,
    /// Handle to stop the watcher (dropped when watcher stops)
    _handle: std::thread::JoinHandle<()>,
}

impl FileWatcher {
    /// Create a new file watcher for the given directory
    pub fn new(watch_path: &Path, config: WatcherConfig) -> anyhow::Result<Self> {
        let (tx, rx) = tokio_mpsc::channel(100);
        let watch_path = watch_path.to_path_buf();
        let config_clone = config.clone();

        // Spawn watcher in a separate thread (notify uses sync APIs)
        let handle = std::thread::spawn(move || {
            if let Err(e) = run_watcher(watch_path, config_clone, tx) {
                tracing::error!("Watcher thread error: {}", e);
            }
        });

        Ok(Self {
            event_rx: rx,
            _handle: handle,
        })
    }

    /// Receive the next watch event (async)
    pub async fn recv(&mut self) -> Option<WatchEvent> {
        self.event_rx.recv().await
    }
}

/// Run the file watcher (blocking, runs in separate thread)
fn run_watcher(
    watch_path: PathBuf,
    config: WatcherConfig,
    tx: tokio_mpsc::Sender<WatchEvent>,
) -> anyhow::Result<()> {
    let (notify_tx, notify_rx) = mpsc::channel();

    // Create debounced watcher
    let mut debouncer = new_debouncer(config.debounce, notify_tx)?;

    // Start watching
    debouncer
        .watcher()
        .watch(&watch_path, RecursiveMode::Recursive)?;

    tracing::info!("File watcher started for {:?}", watch_path);

    // Process events
    loop {
        match notify_rx.recv() {
            Ok(Ok(events)) => {
                // Collect changed paths, filtering by extension and ignoring directories
                let mut changed: HashSet<PathBuf> = HashSet::new();

                for event in events {
                    if event.kind != DebouncedEventKind::Any {
                        continue;
                    }

                    let path = &event.path;

                    // Check if path should be ignored
                    if should_ignore(path, &config.ignore_dirs) {
                        continue;
                    }

                    // Check extension filter
                    if !config.extensions.is_empty() {
                        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
                        if !config.extensions.iter().any(|e| e == ext) {
                            continue;
                        }
                    }

                    // Only include existing files (ignore deletions for now)
                    if path.exists() && path.is_file() {
                        changed.insert(path.clone());
                    }
                }

                if !changed.is_empty() {
                    let paths: Vec<_> = changed.into_iter().collect();
                    tracing::debug!("Files changed: {:?}", paths);

                    if tx.blocking_send(WatchEvent::FilesChanged(paths)).is_err() {
                        // Channel closed, stop watcher
                        break;
                    }
                }
            }
            Ok(Err(error)) => {
                tracing::warn!("Watch error: {:?}", error);
                let _ = tx.blocking_send(WatchEvent::Error(format!("{:?}", error)));
            }
            Err(_) => {
                // Channel closed
                break;
            }
        }
    }

    tracing::info!("File watcher stopped");
    Ok(())
}

/// Check if a path should be ignored
fn should_ignore(path: &Path, ignore_dirs: &[String]) -> bool {
    for component in path.components() {
        if let std::path::Component::Normal(name) = component {
            let name_str = name.to_string_lossy();
            if ignore_dirs.iter().any(|d| d == name_str.as_ref()) {
                return true;
            }
        }
    }
    false
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_should_ignore() {
        let ignore = vec!["target".to_string(), ".git".to_string()];

        assert!(should_ignore(Path::new("target/debug/foo.rs"), &ignore));
        assert!(should_ignore(Path::new(".git/config"), &ignore));
        assert!(should_ignore(Path::new("src/target/mod.rs"), &ignore));
        assert!(!should_ignore(Path::new("src/main.rs"), &ignore));
        assert!(!should_ignore(Path::new("crates/foo/src/lib.rs"), &ignore));
    }

    #[test]
    fn test_config_default() {
        let config = WatcherConfig::default();
        assert_eq!(config.debounce, Duration::from_millis(500));
        assert!(config.extensions.contains(&"rs".to_string()));
        assert!(config.ignore_dirs.contains(&"target".to_string()));
    }
}