codemem_engine/watch/
mod.rs1use crossbeam_channel::Receiver;
7use ignore::gitignore::{Gitignore, GitignoreBuilder};
8use notify_debouncer_mini::new_debouncer;
9use std::collections::HashSet;
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12use std::time::Duration;
13
14#[derive(Debug, Clone)]
16pub enum WatchEvent {
17 FileChanged(PathBuf),
18 FileCreated(PathBuf),
19 FileDeleted(PathBuf),
20}
21
22const IGNORE_DIRS: &[&str] = &[
24 "node_modules",
25 "target",
26 ".git",
27 "__pycache__",
28 ".venv",
29 "venv",
30 ".mypy_cache",
31 ".pytest_cache",
32 "dist",
33 "build",
34 ".next",
35 "vendor",
36 ".cargo",
37];
38
39const WATCHABLE_EXTENSIONS: &[&str] = &[
41 "rs", "ts", "tsx", "js", "jsx", "py", "go", "c", "cpp", "cc", "cxx", "h", "hpp", "java", "rb",
42 "cs", "kt", "kts", "swift", "php", "scala", "sc", "tf", "hcl", "tfvars", "toml", "json",
43 "yaml", "yml",
44];
45
46pub(crate) fn is_watchable(path: &Path) -> bool {
48 path.extension()
49 .and_then(|ext| ext.to_str())
50 .map(|ext| WATCHABLE_EXTENSIONS.contains(&ext))
51 .unwrap_or(false)
52}
53
54pub(crate) fn should_ignore(path: &Path, gitignore: Option<&Gitignore>, is_dir: bool) -> bool {
63 if let Some(gi) = gitignore {
64 if gi.matched(path, is_dir).is_ignore() {
66 return true;
67 }
68 let mut current = path.to_path_buf();
74 while current.pop() {
75 if gi.matched(¤t, true).is_ignore() {
76 return true;
77 }
78 }
79 }
80 for component in path.components() {
82 if let std::path::Component::Normal(name) = component {
83 if let Some(name_str) = name.to_str() {
84 if IGNORE_DIRS.contains(&name_str) {
85 return true;
86 }
87 }
88 }
89 }
90 false
91}
92
93pub(crate) fn build_gitignore(root: &Path) -> Option<Gitignore> {
98 let mut builder = GitignoreBuilder::new(root);
99 if let Some(err) = builder.add(root.join(".gitignore")) {
101 tracing::debug!("No .gitignore found: {err}");
102 }
103 for dir in IGNORE_DIRS {
105 let _ = builder.add_line(None, &format!("{dir}/"));
106 }
107 builder.build().ok()
108}
109
110pub fn detect_language(path: &Path) -> Option<&'static str> {
112 path.extension()
113 .and_then(|ext| ext.to_str())
114 .and_then(|ext| match ext {
115 "rs" => Some("rust"),
116 "ts" | "tsx" => Some("typescript"),
117 "js" | "jsx" => Some("javascript"),
118 "py" => Some("python"),
119 "go" => Some("go"),
120 "c" | "h" => Some("c"),
121 "cpp" | "cc" | "cxx" | "hpp" => Some("cpp"),
122 "java" => Some("java"),
123 "rb" => Some("ruby"),
124 "cs" => Some("csharp"),
125 "kt" | "kts" => Some("kotlin"),
126 "swift" => Some("swift"),
127 "php" => Some("php"),
128 "scala" | "sc" => Some("scala"),
129 "tf" | "hcl" | "tfvars" => Some("hcl"),
130 _ => None,
131 })
132}
133
134pub struct FileWatcher {
136 _debouncer: notify_debouncer_mini::Debouncer<notify::RecommendedWatcher>,
137 receiver: Receiver<WatchEvent>,
138 _gitignore: Arc<Option<Gitignore>>,
139}
140
141impl FileWatcher {
142 pub fn new(root: &Path) -> Result<Self, codemem_core::CodememError> {
144 let (tx, rx) = crossbeam_channel::unbounded::<WatchEvent>();
145 let event_tx = tx;
146
147 let gitignore = Arc::new(build_gitignore(root));
148 let gi_clone = Arc::clone(&gitignore);
149
150 let known_files = std::sync::Mutex::new(HashSet::<PathBuf>::new());
152
153 let mut debouncer = new_debouncer(
154 Duration::from_millis(50),
155 move |res: Result<Vec<notify_debouncer_mini::DebouncedEvent>, notify::Error>| match res
156 {
157 Ok(events) => {
158 let mut seen = HashSet::new();
159 for event in events {
160 let path = event.path;
161 if !seen.insert(path.clone()) {
162 continue;
163 }
164 if should_ignore(&path, gi_clone.as_ref().as_ref(), false)
167 || !is_watchable(&path)
168 {
169 continue;
170 }
171 let watch_event = if path.exists() {
175 if let Ok(mut known) = known_files.lock() {
176 if known.len() > 50_000 {
180 known.clear();
181 }
182 if known.insert(path.clone()) {
183 WatchEvent::FileCreated(path)
184 } else {
185 WatchEvent::FileChanged(path)
186 }
187 } else {
188 WatchEvent::FileChanged(path)
189 }
190 } else {
191 if let Ok(mut known) = known_files.lock() {
192 known.remove(&path);
193 }
194 WatchEvent::FileDeleted(path)
195 };
196 let _ = event_tx.send(watch_event);
197 }
198 }
199 Err(e) => {
200 tracing::error!("Watch error: {e}");
201 }
202 },
203 )
204 .map_err(|e| {
205 codemem_core::CodememError::Io(std::io::Error::other(format!(
206 "Failed to create debouncer: {e}"
207 )))
208 })?;
209
210 debouncer
211 .watcher()
212 .watch(root, notify::RecursiveMode::Recursive)
213 .map_err(|e| {
214 codemem_core::CodememError::Io(std::io::Error::other(format!(
215 "Failed to watch {}: {e}",
216 root.display()
217 )))
218 })?;
219
220 tracing::info!("Watching {} for changes", root.display());
221
222 Ok(Self {
223 _debouncer: debouncer,
224 receiver: rx,
225 _gitignore: gitignore,
226 })
227 }
228
229 pub fn receiver(&self) -> &Receiver<WatchEvent> {
231 &self.receiver
232 }
233}
234
235#[cfg(test)]
236#[path = "tests/lib_tests.rs"]
237mod tests;