Skip to main content

kardo_core/watcher/
mod.rs

1//! File system watcher for real-time project monitoring.
2
3use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
4use std::path::{Path, PathBuf};
5use std::sync::mpsc;
6use std::time::{Duration, Instant};
7
8/// Typed errors for watcher operations.
9#[derive(Debug, thiserror::Error)]
10pub enum WatcherError {
11    #[error("Watcher initialization failed: {0}")]
12    Init(String),
13    #[error("IO error: {0}")]
14    Io(#[from] std::io::Error),
15}
16
17impl From<WatcherError> for String {
18    fn from(e: WatcherError) -> Self {
19        e.to_string()
20    }
21}
22
23/// Events emitted by the file watcher.
24#[derive(Debug, Clone)]
25pub enum WatchEvent {
26    /// Files were modified (debounced list of relative paths).
27    FilesChanged(Vec<String>),
28    /// An error occurred.
29    Error(String),
30}
31
32/// Configuration for the file watcher.
33pub struct WatcherConfig {
34    /// Debounce interval (default 500ms).
35    pub debounce_ms: u64,
36    /// Patterns to ignore (gitignore-style).
37    pub ignore_patterns: Vec<String>,
38}
39
40impl Default for WatcherConfig {
41    fn default() -> Self {
42        Self {
43            debounce_ms: 500,
44            ignore_patterns: vec![
45                ".git".to_string(),
46                ".kardo".to_string(),
47                "node_modules".to_string(),
48                "target".to_string(),
49                ".DS_Store".to_string(),
50            ],
51        }
52    }
53}
54
55/// File system watcher that monitors a project directory.
56pub struct ProjectWatcher {
57    _watcher: RecommendedWatcher,
58    receiver: mpsc::Receiver<WatchEvent>,
59    project_root: PathBuf,
60}
61
62impl ProjectWatcher {
63    /// Start watching a project directory.
64    pub fn start(project_root: &Path, config: WatcherConfig) -> Result<Self, WatcherError> {
65        let (tx, rx) = mpsc::channel();
66        let root = project_root.to_path_buf();
67        let root_clone = root.clone();
68
69        let debounce_duration = Duration::from_millis(config.debounce_ms);
70        let ignore = config.ignore_patterns.clone();
71
72        // Use a thread to debounce events
73        let (notify_tx, notify_rx) = mpsc::channel::<Event>();
74
75        let watcher_result = RecommendedWatcher::new(
76            move |res: Result<Event, notify::Error>| {
77                if let Ok(event) = res {
78                    let _ = notify_tx.send(event);
79                }
80            },
81            Config::default(),
82        );
83
84        let mut watcher = watcher_result
85            .map_err(|e| WatcherError::Init(format!("Failed to create watcher: {}", e)))?;
86        watcher
87            .watch(project_root, RecursiveMode::Recursive)
88            .map_err(|e| WatcherError::Init(format!("Failed to watch directory: {}", e)))?;
89
90        // Debounce thread
91        std::thread::spawn(move || {
92            let mut pending: Vec<PathBuf> = Vec::new();
93            let mut last_event = Instant::now();
94
95            loop {
96                match notify_rx.recv_timeout(Duration::from_millis(100)) {
97                    Ok(event) => {
98                        match event.kind {
99                            EventKind::Create(_)
100                            | EventKind::Modify(_)
101                            | EventKind::Remove(_) => {
102                                for path in event.paths {
103                                    // Check ignore patterns
104                                    let path_str = path.to_string_lossy();
105                                    let should_ignore = ignore
106                                        .iter()
107                                        .any(|pattern| path_str.contains(pattern));
108
109                                    if !should_ignore && !pending.contains(&path) {
110                                        pending.push(path);
111                                    }
112                                }
113                                last_event = Instant::now();
114                            }
115                            _ => {}
116                        }
117                    }
118                    Err(mpsc::RecvTimeoutError::Timeout) => {
119                        // Check if debounce period has elapsed
120                        if !pending.is_empty() && last_event.elapsed() >= debounce_duration {
121                            let changed: Vec<String> = pending
122                                .drain(..)
123                                .filter_map(|p| {
124                                    p.strip_prefix(&root_clone)
125                                        .ok()
126                                        .map(|rel| rel.to_string_lossy().to_string())
127                                })
128                                .collect();
129
130                            if !changed.is_empty() {
131                                let _ = tx.send(WatchEvent::FilesChanged(changed));
132                            }
133                        }
134                    }
135                    Err(mpsc::RecvTimeoutError::Disconnected) => break,
136                }
137            }
138        });
139
140        Ok(Self {
141            _watcher: watcher,
142            receiver: rx,
143            project_root: root,
144        })
145    }
146
147    /// Try to receive pending watch events (non-blocking).
148    pub fn try_recv(&self) -> Option<WatchEvent> {
149        self.receiver.try_recv().ok()
150    }
151
152    /// Get the project root being watched.
153    pub fn project_root(&self) -> &Path {
154        &self.project_root
155    }
156}