1use 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 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 fn should_ignore(path: &Path, gitignore: Option<&Gitignore>) -> bool {
60 if let Some(gi) = gitignore {
61 if gi.matched(path, path.is_dir()).is_ignore() {
63 return true;
64 }
65 let mut current = path.to_path_buf();
67 while current.pop() {
68 if gi.matched(¤t, true).is_ignore() {
69 return true;
70 }
71 }
72 }
73 for component in path.components() {
75 if let std::path::Component::Normal(name) = component {
76 if let Some(name_str) = name.to_str() {
77 if IGNORE_DIRS.contains(&name_str) {
78 return true;
79 }
80 }
81 }
82 }
83 false
84}
85
86pub fn build_gitignore(root: &Path) -> Option<Gitignore> {
91 let mut builder = GitignoreBuilder::new(root);
92 if let Some(err) = builder.add(root.join(".gitignore")) {
94 tracing::debug!("No .gitignore found: {err}");
95 }
96 for dir in IGNORE_DIRS {
98 let _ = builder.add_line(None, &format!("{dir}/"));
99 }
100 builder.build().ok()
101}
102
103pub fn detect_language(path: &Path) -> Option<&'static str> {
105 path.extension()
106 .and_then(|ext| ext.to_str())
107 .and_then(|ext| match ext {
108 "rs" => Some("rust"),
109 "ts" | "tsx" => Some("typescript"),
110 "js" | "jsx" => Some("javascript"),
111 "py" => Some("python"),
112 "go" => Some("go"),
113 "c" | "h" => Some("c"),
114 "cpp" | "cc" | "cxx" | "hpp" => Some("cpp"),
115 "java" => Some("java"),
116 "rb" => Some("ruby"),
117 "cs" => Some("csharp"),
118 "kt" | "kts" => Some("kotlin"),
119 "swift" => Some("swift"),
120 "php" => Some("php"),
121 "scala" | "sc" => Some("scala"),
122 "tf" | "hcl" | "tfvars" => Some("hcl"),
123 _ => None,
124 })
125}
126
127pub struct FileWatcher {
129 _debouncer: notify_debouncer_mini::Debouncer<notify::RecommendedWatcher>,
130 receiver: Receiver<WatchEvent>,
131 #[allow(dead_code)]
132 gitignore: Arc<Option<Gitignore>>,
133}
134
135impl FileWatcher {
136 pub fn new(root: &Path) -> Result<Self, codemem_core::CodememError> {
138 let (tx, rx) = crossbeam_channel::unbounded::<WatchEvent>();
139 let event_tx = tx;
140
141 let gitignore = Arc::new(build_gitignore(root));
142 let gi_clone = Arc::clone(&gitignore);
143
144 let known_files = std::sync::Mutex::new(HashSet::<PathBuf>::new());
146
147 let mut debouncer = new_debouncer(
148 Duration::from_millis(50),
149 move |res: Result<Vec<notify_debouncer_mini::DebouncedEvent>, notify::Error>| match res
150 {
151 Ok(events) => {
152 let mut seen = HashSet::new();
153 for event in events {
154 let path = event.path;
155 if !seen.insert(path.clone()) {
156 continue;
157 }
158 if should_ignore(&path, gi_clone.as_ref().as_ref()) || !is_watchable(&path)
159 {
160 continue;
161 }
162 let watch_event = if path.exists() {
166 if let Ok(mut known) = known_files.lock() {
167 if known.insert(path.clone()) {
168 WatchEvent::FileCreated(path)
169 } else {
170 WatchEvent::FileChanged(path)
171 }
172 } else {
173 WatchEvent::FileChanged(path)
174 }
175 } else {
176 if let Ok(mut known) = known_files.lock() {
177 known.remove(&path);
178 }
179 WatchEvent::FileDeleted(path)
180 };
181 let _ = event_tx.send(watch_event);
182 }
183 }
184 Err(e) => {
185 tracing::error!("Watch error: {e}");
186 }
187 },
188 )
189 .map_err(|e| {
190 codemem_core::CodememError::Io(std::io::Error::other(format!(
191 "Failed to create debouncer: {e}"
192 )))
193 })?;
194
195 debouncer
196 .watcher()
197 .watch(root, notify::RecursiveMode::Recursive)
198 .map_err(|e| {
199 codemem_core::CodememError::Io(std::io::Error::other(format!(
200 "Failed to watch {}: {e}",
201 root.display()
202 )))
203 })?;
204
205 tracing::info!("Watching {} for changes", root.display());
206
207 Ok(Self {
208 _debouncer: debouncer,
209 receiver: rx,
210 gitignore,
211 })
212 }
213
214 pub fn receiver(&self) -> &Receiver<WatchEvent> {
216 &self.receiver
217 }
218}
219
220#[cfg(test)]
221#[path = "tests/lib_tests.rs"]
222mod tests;