1use crossbeam_channel::Receiver;
7use notify_debouncer_mini::{new_debouncer, DebouncedEventKind};
8use std::collections::HashSet;
9use std::path::{Path, PathBuf};
10use std::time::Duration;
11
12#[derive(Debug, Clone)]
14pub enum WatchEvent {
15 FileChanged(PathBuf),
16 FileCreated(PathBuf),
17 FileDeleted(PathBuf),
18}
19
20const IGNORE_DIRS: &[&str] = &[
22 "node_modules",
23 "target",
24 ".git",
25 "__pycache__",
26 ".venv",
27 "venv",
28 ".mypy_cache",
29 ".pytest_cache",
30 "dist",
31 "build",
32 ".next",
33 "vendor",
34 ".cargo",
35];
36
37const WATCHABLE_EXTENSIONS: &[&str] = &[
39 "rs", "ts", "tsx", "js", "jsx", "py", "go", "c", "cpp", "cc", "cxx", "h", "hpp", "java",
40 "toml", "json", "yaml", "yml",
41];
42
43pub fn is_watchable(path: &Path) -> bool {
45 path.extension()
46 .and_then(|ext| ext.to_str())
47 .map(|ext| WATCHABLE_EXTENSIONS.contains(&ext))
48 .unwrap_or(false)
49}
50
51pub fn should_ignore(path: &Path) -> bool {
53 for component in path.components() {
54 if let std::path::Component::Normal(name) = component {
55 if let Some(name_str) = name.to_str() {
56 if IGNORE_DIRS.contains(&name_str) {
57 return true;
58 }
59 }
60 }
61 }
62 false
63}
64
65pub fn detect_language(path: &Path) -> Option<&'static str> {
67 path.extension()
68 .and_then(|ext| ext.to_str())
69 .and_then(|ext| match ext {
70 "rs" => Some("rust"),
71 "ts" | "tsx" => Some("typescript"),
72 "js" | "jsx" => Some("javascript"),
73 "py" => Some("python"),
74 "go" => Some("go"),
75 "c" | "h" => Some("c"),
76 "cpp" | "cc" | "cxx" | "hpp" => Some("cpp"),
77 "java" => Some("java"),
78 _ => None,
79 })
80}
81
82pub struct FileWatcher {
84 _debouncer: notify_debouncer_mini::Debouncer<notify::RecommendedWatcher>,
85 receiver: Receiver<WatchEvent>,
86}
87
88impl FileWatcher {
89 pub fn new(root: &Path) -> Result<Self, codemem_core::CodememError> {
91 let (tx, rx) = crossbeam_channel::unbounded::<WatchEvent>();
92 let event_tx = tx;
93
94 let mut debouncer = new_debouncer(
95 Duration::from_millis(50),
96 move |res: Result<Vec<notify_debouncer_mini::DebouncedEvent>, notify::Error>| match res
97 {
98 Ok(events) => {
99 let mut seen = HashSet::new();
100 for event in events {
101 let path = event.path;
102 if !seen.insert(path.clone()) {
103 continue;
104 }
105 if should_ignore(&path) || !is_watchable(&path) {
106 continue;
107 }
108 let watch_event = match event.kind {
109 DebouncedEventKind::Any => {
110 if path.exists() {
111 WatchEvent::FileChanged(path)
112 } else {
113 WatchEvent::FileDeleted(path)
114 }
115 }
116 DebouncedEventKind::AnyContinuous => WatchEvent::FileChanged(path),
117 _ => WatchEvent::FileChanged(path),
118 };
119 let _ = event_tx.send(watch_event);
120 }
121 }
122 Err(e) => {
123 tracing::error!("Watch error: {e}");
124 }
125 },
126 )
127 .map_err(|e| {
128 codemem_core::CodememError::Io(std::io::Error::other(format!(
129 "Failed to create debouncer: {e}"
130 )))
131 })?;
132
133 debouncer
134 .watcher()
135 .watch(root, notify::RecursiveMode::Recursive)
136 .map_err(|e| {
137 codemem_core::CodememError::Io(std::io::Error::other(format!(
138 "Failed to watch {}: {e}",
139 root.display()
140 )))
141 })?;
142
143 tracing::info!("Watching {} for changes", root.display());
144
145 Ok(Self {
146 _debouncer: debouncer,
147 receiver: rx,
148 })
149 }
150
151 pub fn receiver(&self) -> &Receiver<WatchEvent> {
153 &self.receiver
154 }
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160
161 #[test]
162 fn test_is_watchable() {
163 assert!(is_watchable(Path::new("src/main.rs")));
164 assert!(is_watchable(Path::new("index.ts")));
165 assert!(is_watchable(Path::new("app.py")));
166 assert!(is_watchable(Path::new("main.go")));
167 assert!(!is_watchable(Path::new("image.png")));
168 assert!(!is_watchable(Path::new("binary.exe")));
169 }
170
171 #[test]
172 fn test_should_ignore() {
173 assert!(should_ignore(Path::new("project/node_modules/foo/bar.js")));
174 assert!(should_ignore(Path::new("project/target/debug/build.rs")));
175 assert!(should_ignore(Path::new(".git/config")));
176 assert!(!should_ignore(Path::new("src/main.rs")));
177 assert!(!should_ignore(Path::new("lib/utils.ts")));
178 }
179
180 #[test]
181 fn test_detect_language() {
182 assert_eq!(detect_language(Path::new("main.rs")), Some("rust"));
183 assert_eq!(detect_language(Path::new("app.tsx")), Some("typescript"));
184 assert_eq!(detect_language(Path::new("script.py")), Some("python"));
185 assert_eq!(detect_language(Path::new("main.go")), Some("go"));
186 assert_eq!(detect_language(Path::new("readme.md")), None);
187 }
188}