1use std::collections::{HashMap, HashSet};
19use std::path::{Path, PathBuf};
20use std::sync::Arc;
21use std::time::Duration;
22
23use notify::RecursiveMode;
24use notify_debouncer_full::{new_debouncer, DebounceEventResult};
25use tokio::sync::mpsc;
26use tokio::sync::Mutex;
27
28use remembrall_core::graph::layers::detect_layer;
29use remembrall_core::graph::store::GraphStore;
30use remembrall_core::parser::{
31 parse_go_file, parse_java_file, parse_kotlin_file, parse_python_file, parse_ruby_file,
32 parse_rust_file, parse_ts_file, FileParseResult, TsLang,
33};
34
35const WATCHED_EXTENSIONS: &[&str] = &[
37 "py", "ts", "tsx", "js", "jsx", "rs", "rb", "go", "java", "kt", "kts",
38];
39
40const IGNORE_DIRS: &[&str] = &[
43 "node_modules",
44 ".git",
45 "target",
46 "__pycache__",
47 "vendor",
48 ".venv",
49 "venv",
50 "dist",
51 "build",
52 ".cache",
53 ".next",
54 ".nuxt",
55];
56
57pub struct FileWatcher {
67 graph: Arc<GraphStore>,
68 projects: Arc<Mutex<HashMap<PathBuf, String>>>,
70}
71
72impl FileWatcher {
73 pub fn new(graph: Arc<GraphStore>) -> Self {
74 Self {
75 graph,
76 projects: Arc::new(Mutex::new(HashMap::new())),
77 }
78 }
79
80 pub async fn add_project(&self, root: PathBuf, project: String) {
85 self.projects.lock().await.insert(root, project);
86 }
87
88 pub async fn run(self) {
95 let projects_snapshot: HashMap<PathBuf, String> = {
96 let guard = self.projects.lock().await;
97 guard.clone()
98 };
99
100 if projects_snapshot.is_empty() {
101 tracing::warn!("FileWatcher started with no projects - nothing to watch");
102 return;
103 }
104
105 let (tx, mut rx) = mpsc::channel::<Vec<PathBuf>>(256);
108
109 let roots: Vec<PathBuf> = projects_snapshot.keys().cloned().collect();
113 let tx_clone = tx.clone();
114
115 tokio::task::spawn_blocking(move || {
119 let rt = tokio::runtime::Handle::current();
120
121 let result = new_debouncer(
122 Duration::from_millis(500),
123 None,
124 move |res: DebounceEventResult| {
125 match res {
126 Ok(events) => {
127 let paths: Vec<PathBuf> = events
128 .into_iter()
129 .flat_map(|e| e.event.paths)
130 .collect();
131 if !paths.is_empty() {
132 let tx = tx_clone.clone();
133 rt.spawn(async move {
134 let _ = tx.send(paths).await;
135 });
136 }
137 }
138 Err(errs) => {
139 for e in errs {
140 tracing::warn!("watcher error: {e:?}");
141 }
142 }
143 }
144 },
145 );
146
147 let mut debouncer = match result {
148 Ok(d) => d,
149 Err(e) => {
150 tracing::error!("failed to create file watcher: {e}");
151 return;
152 }
153 };
154
155 for root in &roots {
156 if let Err(e) = debouncer.watch(root, RecursiveMode::Recursive) {
157 tracing::error!("failed to watch {}: {e}", root.display());
158 } else {
159 tracing::info!("watching {} for changes", root.display());
160 }
161 }
162
163 std::thread::park();
166 });
167
168 drop(tx);
171
172 while let Some(paths) = rx.recv().await {
174 let unique: HashSet<PathBuf> = paths.into_iter().collect();
176
177 for path in unique {
178 if !is_watched_file(&path) {
181 continue;
182 }
183
184 let project = match find_project(&path, &projects_snapshot) {
186 Some(p) => p,
187 None => {
188 tracing::debug!("change in unregistered path, skipping: {}", path.display());
189 continue;
190 }
191 };
192
193 reindex_file(&self.graph, &path, &project).await;
194 }
195 }
196
197 tracing::info!("FileWatcher event loop exited");
198 }
199}
200
201async fn reindex_file(graph: &Arc<GraphStore>, path: &Path, project: &str) {
211 let file_path = path.to_string_lossy().to_string();
212
213 if !path.exists() {
215 match graph.remove_file(&file_path, project).await {
216 Ok(removed) if removed > 0 => {
217 tracing::info!("removed {} symbols for deleted file: {}", removed, file_path);
218 }
219 Ok(_) => {}
220 Err(e) => {
221 tracing::warn!("remove_file failed for {}: {e}", file_path);
222 }
223 }
224 return;
225 }
226
227 let source = match std::fs::read_to_string(path) {
229 Ok(s) => s,
230 Err(e) => {
231 tracing::debug!("skipping {} - could not read: {e}", file_path);
232 return;
233 }
234 };
235
236 let ext = path
237 .extension()
238 .and_then(|e| e.to_str())
239 .unwrap_or("")
240 .to_lowercase();
241
242 let mtime = chrono::Utc::now();
243
244 let file_path_clone = file_path.clone();
247 let project_owned = project.to_string();
248 let parse_result: FileParseResult = match tokio::task::spawn_blocking(move || {
249 if ext == "py" {
250 parse_python_file(&file_path_clone, &source, &project_owned, mtime)
251 } else if ext == "rs" {
252 parse_rust_file(&file_path_clone, &source, &project_owned, mtime)
253 } else if ext == "rb" {
254 parse_ruby_file(&file_path_clone, &source, &project_owned, mtime)
255 } else if ext == "go" {
256 parse_go_file(&file_path_clone, &source, &project_owned, mtime)
257 } else if ext == "java" {
258 parse_java_file(&file_path_clone, &source, &project_owned, mtime)
259 } else if ext == "kt" || ext == "kts" {
260 parse_kotlin_file(&file_path_clone, &source, &project_owned, mtime)
261 } else if let Some(lang) = TsLang::from_extension(&ext) {
262 parse_ts_file(&file_path_clone, &source, &project_owned, mtime, lang)
263 } else {
264 FileParseResult::default()
265 }
266 })
267 .await
268 {
269 Ok(r) => r,
270 Err(e) => {
271 tracing::warn!("parse panicked for {}: {e}", file_path);
272 return;
273 }
274 };
275
276 if parse_result.symbols.is_empty() && parse_result.relationships.is_empty() {
278 return;
279 }
280
281 let layer = detect_layer(&file_path);
285 let mut parse_result = parse_result;
286 for sym in &mut parse_result.symbols {
287 sym.layer = layer.clone();
288 }
289
290 if let Err(e) = graph.remove_file(&file_path, project).await {
292 tracing::warn!("remove_file failed for {}: {e}", file_path);
293 return;
294 }
295
296 let mut symbols_stored = 0u64;
298 for symbol in &parse_result.symbols {
299 match graph.upsert_symbol(symbol).await {
300 Ok(_) => symbols_stored += 1,
301 Err(e) => {
302 tracing::warn!("upsert_symbol failed for {} in {}: {e}", symbol.name, file_path);
303 }
304 }
305 }
306
307 let mut rels_stored = 0u64;
311 for rel in &parse_result.relationships {
312 match graph.add_relationship(rel).await {
313 Ok(_) => rels_stored += 1,
314 Err(e) => {
315 tracing::debug!("skipping relationship in {}: {e}", file_path);
316 }
317 }
318 }
319
320 tracing::info!(
321 "reindexed {} - {} symbols, {} relationships",
322 file_path,
323 symbols_stored,
324 rels_stored,
325 );
326}
327
328fn is_watched_file(path: &Path) -> bool {
331 let ext = match path.extension().and_then(|e| e.to_str()) {
333 Some(e) => e.to_lowercase(),
334 None => return false,
335 };
336
337 if !WATCHED_EXTENSIONS.contains(&ext.as_str()) {
338 return false;
339 }
340
341 for component in path.components() {
343 if let std::path::Component::Normal(name) = component {
344 let name = name.to_string_lossy();
345 if name.starts_with('.') && name != "." {
346 return false;
347 }
348 if IGNORE_DIRS.contains(&name.as_ref()) {
349 return false;
350 }
351 }
352 }
353
354 true
355}
356
357fn find_project<'a>(
359 path: &Path,
360 projects: &'a HashMap<PathBuf, String>,
361) -> Option<String> {
362 projects
363 .iter()
364 .filter(|(root, _)| path.starts_with(root))
365 .max_by_key(|(root, _)| root.as_os_str().len())
366 .map(|(_, name)| name.clone())
367}