kardo_core/watcher/
mod.rs1use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
4use std::path::{Path, PathBuf};
5use std::sync::mpsc;
6use std::time::{Duration, Instant};
7
8#[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#[derive(Debug, Clone)]
25pub enum WatchEvent {
26 FilesChanged(Vec<String>),
28 Error(String),
30}
31
32pub struct WatcherConfig {
34 pub debounce_ms: u64,
36 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
55pub struct ProjectWatcher {
57 _watcher: RecommendedWatcher,
58 receiver: mpsc::Receiver<WatchEvent>,
59 project_root: PathBuf,
60}
61
62impl ProjectWatcher {
63 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 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 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 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 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 pub fn try_recv(&self) -> Option<WatchEvent> {
149 self.receiver.try_recv().ok()
150 }
151
152 pub fn project_root(&self) -> &Path {
154 &self.project_root
155 }
156}