Skip to main content

codesynapse_core/
watch.rs

1use crate::build::GraphBuilder;
2use crate::detect::Detector;
3use crate::error::{CodeSynapseError, Result};
4use crate::extract::Extractor;
5use crate::graph::MemoryGraphStore;
6use crate::ts_extract::{
7    AstroExtractor, CsprojExtractor, FortranExtractor, JsonPackageExtractor, MarkdownExtractor,
8    McpConfigExtractor, ObjCExtractor, PowerShellExtractor, RazorExtractor, SlnExtractor,
9    TsBashExtractor, TsCExtractor, TsCSharpExtractor, TsCppExtractor, TsElixirExtractor,
10    TsGoExtractor, TsJavaExtractor, TsJavaScriptExtractor, TsJuliaExtractor, TsKotlinExtractor,
11    TsPascalExtractor, TsPhpExtractor, TsPythonExtractor, TsRubyExtractor, TsRustExtractor,
12    TsSqlExtractor, TsSvelteExtractor, TsSwiftExtractor, TsTypeScriptExtractor, TsVueExtractor,
13    VerilogExtractor,
14};
15use crate::types::{Edge, Node};
16use ignore::gitignore::{Gitignore, GitignoreBuilder};
17use notify::{Event, EventKind, RecursiveMode, Watcher as NotifyWatcher};
18use std::collections::HashSet;
19use std::path::{Path, PathBuf};
20use std::sync::{Arc, Mutex};
21use std::time::{Duration, Instant};
22
23const CODESYNAPSE_OUT: &str = "codesynapse-out";
24const DEFAULT_DEBOUNCE_MS: u64 = 500;
25
26const VCS_MARKERS: &[&str] = &[".git", ".hg", ".svn", "_darcs", ".fossil"];
27
28pub struct WatchConfig {
29    pub root: PathBuf,
30    pub debounce_ms: u64,
31}
32
33impl Default for WatchConfig {
34    fn default() -> Self {
35        WatchConfig {
36            root: PathBuf::from("."),
37            debounce_ms: DEFAULT_DEBOUNCE_MS,
38        }
39    }
40}
41
42pub struct RebuildResult {
43    pub changed_files: Vec<PathBuf>,
44    pub nodes: Vec<Node>,
45    pub edges: Vec<Edge>,
46    pub elapsed_ms: u64,
47}
48
49pub struct Watcher {
50    config: WatchConfig,
51}
52
53impl Watcher {
54    pub fn new(config: WatchConfig) -> Self {
55        Watcher { config }
56    }
57
58    pub fn run<F>(&self, on_rebuild: F) -> Result<()>
59    where
60        F: Fn(RebuildResult) + Send + Sync + 'static,
61    {
62        let root = self
63            .config
64            .root
65            .canonicalize()
66            .unwrap_or_else(|_| self.config.root.clone());
67        let debounce = Duration::from_millis(self.config.debounce_ms);
68
69        let pending: Arc<Mutex<HashSet<PathBuf>>> = Arc::new(Mutex::new(HashSet::new()));
70        let last_event: Arc<Mutex<Option<Instant>>> = Arc::new(Mutex::new(None));
71
72        let pending_clone = Arc::clone(&pending);
73        let last_event_clone = Arc::clone(&last_event);
74        let root_clone = root.clone();
75        let ignore = build_ignore(&root);
76
77        let (tx, rx) = std::sync::mpsc::channel::<notify::Result<Event>>();
78
79        let mut watcher = notify::recommended_watcher(move |res: notify::Result<Event>| {
80            let _ = tx.send(res);
81        })
82        .map_err(|e| CodeSynapseError::Io(std::io::Error::other(e.to_string())))?;
83
84        watcher
85            .watch(&root, RecursiveMode::Recursive)
86            .map_err(|e| CodeSynapseError::Io(std::io::Error::other(e.to_string())))?;
87
88        let on_rebuild = Arc::new(on_rebuild);
89
90        loop {
91            // Drain available events without blocking for long.
92            match rx.recv_timeout(Duration::from_millis(50)) {
93                Ok(Ok(event)) => {
94                    if !is_modification(&event.kind) {
95                        continue;
96                    }
97                    let mut any = false;
98                    for path in event.paths {
99                        if is_watched_path(&path, &root_clone, &ignore) {
100                            pending_clone.lock().unwrap().insert(path);
101                            any = true;
102                        }
103                    }
104                    if any {
105                        *last_event_clone.lock().unwrap() = Some(Instant::now());
106                    }
107                }
108                Ok(Err(_)) | Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
109                Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
110            }
111
112            // Fire rebuild if debounce period has elapsed.
113            let should_rebuild = {
114                let last = last_event.lock().unwrap();
115                last.map(|t| t.elapsed() >= debounce).unwrap_or(false)
116            };
117
118            if should_rebuild {
119                let changed: Vec<PathBuf> = {
120                    let mut lock = pending.lock().unwrap();
121                    let batch: Vec<_> = lock.drain().collect();
122                    *last_event.lock().unwrap() = None;
123                    batch
124                };
125
126                if !changed.is_empty() {
127                    let result = rebuild(&root, changed);
128                    on_rebuild(result);
129                }
130            }
131        }
132
133        Ok(())
134    }
135}
136
137fn register_all_extractors(extractor: &mut Extractor) {
138    extractor.register("py", Box::new(TsPythonExtractor));
139    extractor.register("js", Box::new(TsJavaScriptExtractor));
140    extractor.register("jsx", Box::new(TsJavaScriptExtractor));
141    extractor.register("mjs", Box::new(TsJavaScriptExtractor));
142    extractor.register("cjs", Box::new(TsJavaScriptExtractor));
143    extractor.register("ts", Box::new(TsTypeScriptExtractor));
144    extractor.register("tsx", Box::new(TsTypeScriptExtractor));
145    extractor.register("mts", Box::new(TsTypeScriptExtractor));
146    extractor.register("cts", Box::new(TsTypeScriptExtractor));
147    extractor.register("go", Box::new(TsGoExtractor));
148    extractor.register("rs", Box::new(TsRustExtractor));
149    extractor.register("java", Box::new(TsJavaExtractor));
150    extractor.register("c", Box::new(TsCExtractor));
151    extractor.register("h", Box::new(TsCExtractor));
152    extractor.register("cpp", Box::new(TsCppExtractor));
153    extractor.register("cxx", Box::new(TsCppExtractor));
154    extractor.register("hpp", Box::new(TsCppExtractor));
155    extractor.register("cs", Box::new(TsCSharpExtractor));
156    extractor.register("kt", Box::new(TsKotlinExtractor));
157    extractor.register("kts", Box::new(TsKotlinExtractor));
158    extractor.register("swift", Box::new(TsSwiftExtractor));
159    extractor.register("php", Box::new(TsPhpExtractor));
160    extractor.register("rb", Box::new(TsRubyExtractor));
161    extractor.register("sql", Box::new(TsSqlExtractor));
162    extractor.register("sh", Box::new(TsBashExtractor));
163    extractor.register("bash", Box::new(TsBashExtractor));
164    extractor.register("vue", Box::new(TsVueExtractor));
165    extractor.register("svelte", Box::new(TsSvelteExtractor));
166    extractor.register("json", Box::new(JsonPackageExtractor));
167    extractor.register("mcp.json", Box::new(McpConfigExtractor));
168    extractor.register("jl", Box::new(TsJuliaExtractor));
169    extractor.register("ex", Box::new(TsElixirExtractor));
170    extractor.register("exs", Box::new(TsElixirExtractor));
171    extractor.register("pas", Box::new(TsPascalExtractor));
172    extractor.register("pp", Box::new(TsPascalExtractor));
173    extractor.register("ps1", Box::new(PowerShellExtractor));
174    extractor.register("psm1", Box::new(PowerShellExtractor));
175    extractor.register("psd1", Box::new(PowerShellExtractor));
176    extractor.register("md", Box::new(MarkdownExtractor));
177    extractor.register("mdx", Box::new(MarkdownExtractor));
178    extractor.register("v", Box::new(VerilogExtractor));
179    extractor.register("sv", Box::new(VerilogExtractor));
180    extractor.register("svh", Box::new(VerilogExtractor));
181    extractor.register("f", Box::new(FortranExtractor));
182    extractor.register("f90", Box::new(FortranExtractor));
183    extractor.register("f95", Box::new(FortranExtractor));
184    extractor.register("f03", Box::new(FortranExtractor));
185    extractor.register("f08", Box::new(FortranExtractor));
186    extractor.register("m", Box::new(ObjCExtractor));
187    extractor.register("mm", Box::new(ObjCExtractor));
188    extractor.register("astro", Box::new(AstroExtractor));
189    extractor.register("sln", Box::new(SlnExtractor));
190    extractor.register("csproj", Box::new(CsprojExtractor));
191    extractor.register("razor", Box::new(RazorExtractor));
192    extractor.register("cshtml", Box::new(RazorExtractor));
193}
194
195fn rebuild(root: &Path, changed_files: Vec<PathBuf>) -> RebuildResult {
196    let start = Instant::now();
197
198    let detector = Detector::new(root);
199    let all_files = detector.discover(root).unwrap_or_default();
200
201    let mut extractor = Extractor::new();
202    register_all_extractors(&mut extractor);
203    let file_data: Vec<(PathBuf, Vec<u8>)> = all_files
204        .iter()
205        .filter_map(|df| std::fs::read(&df.path).ok().map(|b| (df.path.clone(), b)))
206        .collect();
207
208    let pairs: Vec<(PathBuf, &[u8])> = file_data
209        .iter()
210        .map(|(p, b)| (p.clone(), b.as_slice()))
211        .collect();
212
213    let results = extractor.extract_all(&pairs);
214
215    let store = Box::new(MemoryGraphStore::new());
216    let builder = GraphBuilder::new(store);
217
218    let fragments: Vec<(String, Vec<Node>, Vec<Edge>)> = results
219        .into_iter()
220        .filter_map(|(path, res)| {
221            res.ok()
222                .map(|frag| (path.to_string_lossy().to_string(), frag.nodes, frag.edges))
223        })
224        .collect();
225
226    let _ = builder.build_from_fragments(fragments);
227
228    let nodes = builder.store().get_all_nodes().unwrap_or_default();
229    let edges = builder.store().get_all_edges().unwrap_or_default();
230    let elapsed_ms = start.elapsed().as_millis() as u64;
231
232    RebuildResult {
233        changed_files,
234        nodes,
235        edges,
236        elapsed_ms,
237    }
238}
239
240fn is_modification(kind: &EventKind) -> bool {
241    matches!(
242        kind,
243        EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
244    )
245}
246
247fn is_watched_path(path: &Path, _root: &Path, ignore: &Gitignore) -> bool {
248    // Skip anything inside the output directory.
249    if path.components().any(|c| c.as_os_str() == CODESYNAPSE_OUT) {
250        return false;
251    }
252
253    // Skip dotfiles and dot-directories.
254    for component in path.components() {
255        let name = component.as_os_str().to_string_lossy();
256        if name.starts_with('.') {
257            return false;
258        }
259    }
260
261    // Skip if the ignore rules say so (check path and all parents for directory patterns).
262    if ignore.matched_path_or_any_parents(path, false).is_ignore() {
263        return false;
264    }
265
266    // Only watch code/doc/image extensions.
267    let ext = path
268        .extension()
269        .map(|e| format!(".{}", e.to_string_lossy().to_lowercase()))
270        .unwrap_or_default();
271
272    is_tracked_extension(&ext)
273}
274
275fn is_tracked_extension(ext: &str) -> bool {
276    const CODE: &[&str] = &[
277        ".py", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".ejs", ".ets", ".go", ".rs", ".java",
278        ".groovy", ".gradle", ".cpp", ".cc", ".cxx", ".c", ".h", ".hpp", ".rb", ".swift", ".kt",
279        ".kts", ".cs", ".scala", ".php", ".lua", ".luau", ".zig", ".ps1", ".ex", ".exs", ".m",
280        ".mm", ".jl", ".vue", ".svelte", ".astro", ".dart", ".sql", ".r", ".sh", ".bash", ".json",
281        ".pas", ".pp",
282    ];
283    const DOC: &[&str] = &[".md", ".mdx", ".qmd", ".txt", ".rst"];
284    const IMAGE: &[&str] = &[".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"];
285
286    CODE.contains(&ext) || DOC.contains(&ext) || IMAGE.contains(&ext)
287}
288
289fn find_vcs_root(start: &Path) -> Option<PathBuf> {
290    let start = start.canonicalize().ok()?;
291    let mut current: Option<&Path> = Some(&start);
292    while let Some(dir) = current {
293        for marker in VCS_MARKERS {
294            if dir.join(marker).exists() {
295                return Some(dir.to_path_buf());
296            }
297        }
298        current = dir.parent();
299    }
300    None
301}
302
303fn build_ignore(root: &Path) -> Gitignore {
304    let ceiling = find_vcs_root(root).unwrap_or_else(|| root.to_path_buf());
305    let mut builder = GitignoreBuilder::new(&ceiling);
306
307    let mut dirs: Vec<PathBuf> = Vec::new();
308    let mut cur = root.to_path_buf();
309    loop {
310        dirs.push(cur.clone());
311        if cur == ceiling {
312            break;
313        }
314        match cur.parent() {
315            Some(p) => cur = p.to_path_buf(),
316            None => break,
317        }
318    }
319    dirs.reverse(); // ceiling-first so inner rules win
320
321    for dir in dirs {
322        let codesynapseignore = dir.join(".codesynapseignore");
323        if codesynapseignore.exists() {
324            let _ = builder.add(codesynapseignore);
325        } else {
326            let gitignore = dir.join(".gitignore");
327            if gitignore.exists() {
328                let _ = builder.add(gitignore);
329            }
330        }
331    }
332
333    builder
334        .build()
335        .unwrap_or_else(|_| GitignoreBuilder::new(root).build().unwrap())
336}
337
338// ---------------------------------------------------------------------------
339// Tests
340// ---------------------------------------------------------------------------
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345    use std::fs;
346    use std::sync::atomic::{AtomicUsize, Ordering};
347    use std::thread;
348
349    fn make_test_dir(suffix: &str) -> PathBuf {
350        let dir = std::env::temp_dir().join(format!("codesynapse_watch_test_{suffix}"));
351        fs::create_dir_all(&dir).unwrap();
352        dir
353    }
354
355    fn temp_py(dir: &Path, name: &str, content: &str) -> PathBuf {
356        let p = dir.join(name);
357        fs::write(&p, content).unwrap();
358        p
359    }
360
361    // ------------------------------------------------------------------
362    // WatchConfig defaults
363    // ------------------------------------------------------------------
364
365    #[test]
366    fn test_watch_config_defaults() {
367        let cfg = WatchConfig::default();
368        assert_eq!(cfg.debounce_ms, DEFAULT_DEBOUNCE_MS);
369        assert_eq!(cfg.root, PathBuf::from("."));
370    }
371
372    // ------------------------------------------------------------------
373    // is_watched_path
374    // ------------------------------------------------------------------
375
376    #[test]
377    fn test_is_watched_path_code_file() {
378        let dir = make_test_dir("code_file");
379        let ignore = build_ignore(&dir);
380        let p = dir.join("main.py");
381        assert!(is_watched_path(&p, &dir, &ignore));
382    }
383
384    #[test]
385    fn test_is_watched_path_doc_file() {
386        let dir = make_test_dir("doc_file");
387        let ignore = build_ignore(&dir);
388        let p = dir.join("README.md");
389        assert!(is_watched_path(&p, &dir, &ignore));
390    }
391
392    #[test]
393    fn test_is_watched_path_dotfile_rejected() {
394        let dir = make_test_dir("dotfile");
395        let ignore = build_ignore(&dir);
396        let p = dir.join(".hidden.py");
397        assert!(!is_watched_path(&p, &dir, &ignore));
398    }
399
400    #[test]
401    fn test_is_watched_path_codesynapse_out_rejected() {
402        let dir = make_test_dir("gout");
403        let ignore = build_ignore(&dir);
404        let p = dir.join("codesynapse-out").join("graph.json");
405        assert!(!is_watched_path(&p, &dir, &ignore));
406    }
407
408    #[test]
409    fn test_is_watched_path_image_accepted() {
410        let dir = make_test_dir("image");
411        let ignore = build_ignore(&dir);
412        let p = dir.join("logo.png");
413        assert!(is_watched_path(&p, &dir, &ignore));
414    }
415
416    #[test]
417    fn test_is_watched_path_unknown_ext_rejected() {
418        let dir = make_test_dir("unknown_ext");
419        let ignore = build_ignore(&dir);
420        let p = dir.join("archive.zip");
421        assert!(!is_watched_path(&p, &dir, &ignore));
422    }
423
424    // ------------------------------------------------------------------
425    // .codesynapseignore respected
426    // ------------------------------------------------------------------
427
428    #[test]
429    fn test_codesynapseignore_excludes_path() {
430        let dir = make_test_dir("codesynapseignore_excludes");
431        fs::write(dir.join(".codesynapseignore"), "vendor/\n").unwrap();
432        let ignore = build_ignore(&dir);
433        let p = dir.join("vendor").join("lib.py");
434        // Directory pattern `vendor/` is matched via parents check.
435        assert!(ignore.matched_path_or_any_parents(&p, false).is_ignore());
436    }
437
438    #[test]
439    fn test_codesynapseignore_falls_back_to_gitignore() {
440        let dir = make_test_dir("codesynapseignore_gitignore_fallback");
441        fs::write(dir.join(".gitignore"), "dist/\n").unwrap();
442        let ignore = build_ignore(&dir);
443        let p = dir.join("dist").join("bundle.js");
444        assert!(ignore.matched_path_or_any_parents(&p, false).is_ignore());
445    }
446
447    #[test]
448    fn test_codesynapseignore_takes_priority_over_gitignore() {
449        let dir = make_test_dir("codesynapseignore_priority");
450        fs::write(dir.join(".gitignore"), "src/\n").unwrap();
451        fs::write(dir.join(".codesynapseignore"), "!src/\n").unwrap();
452        let ignore = build_ignore(&dir);
453        let p = dir.join("src").join("main.py");
454        assert!(!ignore.matched_path_or_any_parents(&p, false).is_ignore());
455    }
456
457    // ------------------------------------------------------------------
458    // is_modification
459    // ------------------------------------------------------------------
460
461    #[test]
462    fn test_is_modification_create() {
463        assert!(is_modification(&EventKind::Create(
464            notify::event::CreateKind::File
465        )));
466    }
467
468    #[test]
469    fn test_is_modification_modify() {
470        assert!(is_modification(&EventKind::Modify(
471            notify::event::ModifyKind::Data(notify::event::DataChange::Content)
472        )));
473    }
474
475    #[test]
476    fn test_is_modification_remove() {
477        assert!(is_modification(&EventKind::Remove(
478            notify::event::RemoveKind::File
479        )));
480    }
481
482    #[test]
483    fn test_is_modification_access_rejected() {
484        assert!(!is_modification(&EventKind::Access(
485            notify::event::AccessKind::Read
486        )));
487    }
488
489    // ------------------------------------------------------------------
490    // is_tracked_extension
491    // ------------------------------------------------------------------
492
493    #[test]
494    fn test_tracked_extensions() {
495        assert!(is_tracked_extension(".py"));
496        assert!(is_tracked_extension(".ts"));
497        assert!(is_tracked_extension(".rs"));
498        assert!(is_tracked_extension(".md"));
499        assert!(is_tracked_extension(".png"));
500        assert!(!is_tracked_extension(".zip"));
501        assert!(!is_tracked_extension(".exe"));
502        assert!(!is_tracked_extension(""));
503    }
504
505    // ------------------------------------------------------------------
506    // rebuild smoke test
507    // ------------------------------------------------------------------
508
509    #[test]
510    fn test_rebuild_produces_nodes_for_py_file() {
511        let dir = make_test_dir("rebuild_nodes");
512        temp_py(
513            &dir,
514            "hello.py",
515            "class Greeter:\n    def greet(self): pass\n",
516        );
517
518        let result = rebuild(&dir, vec![dir.join("hello.py")]);
519
520        assert!(
521            !result.nodes.is_empty(),
522            "expected at least one node from hello.py"
523        );
524        assert_eq!(result.changed_files.len(), 1);
525    }
526
527    #[test]
528    fn test_rebuild_empty_dir_no_panic() {
529        let dir = make_test_dir("rebuild_empty");
530        let result = rebuild(&dir, vec![]);
531        assert!(result.elapsed_ms < 5000);
532    }
533
534    #[test]
535    fn test_rebuild_multi_file() {
536        let dir = make_test_dir("rebuild_multi");
537        temp_py(&dir, "a.py", "def foo(): pass\n");
538        temp_py(&dir, "b.py", "def bar(): pass\n");
539
540        let changed = vec![dir.join("a.py"), dir.join("b.py")];
541        let result = rebuild(&dir, changed.clone());
542
543        assert!(result.nodes.len() >= 2);
544        assert_eq!(result.changed_files.len(), 2);
545    }
546
547    // ------------------------------------------------------------------
548    // Watcher integration — fire callback on file change
549    // ------------------------------------------------------------------
550
551    #[test]
552    fn test_watcher_fires_on_file_write() {
553        let root = make_test_dir("watcher_fires");
554
555        let counter = Arc::new(AtomicUsize::new(0));
556        let counter_clone = Arc::clone(&counter);
557
558        let cfg = WatchConfig {
559            root: root.clone(),
560            debounce_ms: 100,
561        };
562        let watcher = Arc::new(Watcher::new(cfg));
563        let watcher_clone = Arc::clone(&watcher);
564
565        let handle = thread::spawn(move || {
566            let _ = watcher_clone.run(move |_result| {
567                counter_clone.fetch_add(1, Ordering::SeqCst);
568            });
569        });
570
571        // Give the watcher time to set up.
572        thread::sleep(Duration::from_millis(200));
573
574        // Write a tracked file.
575        fs::write(root.join("test.py"), "x = 1\n").unwrap();
576
577        // Wait for debounce + rebuild.
578        thread::sleep(Duration::from_millis(600));
579
580        assert!(
581            counter.load(Ordering::SeqCst) >= 1,
582            "callback should have fired at least once"
583        );
584
585        drop(handle);
586    }
587
588    #[test]
589    fn test_watcher_ignores_dotfiles() {
590        let root = make_test_dir("watcher_dotfiles");
591
592        let counter = Arc::new(AtomicUsize::new(0));
593        let counter_clone = Arc::clone(&counter);
594
595        let cfg = WatchConfig {
596            root: root.clone(),
597            debounce_ms: 100,
598        };
599        let watcher = Arc::new(Watcher::new(cfg));
600        let watcher_clone = Arc::clone(&watcher);
601
602        let _handle = thread::spawn(move || {
603            let _ = watcher_clone.run(move |_| {
604                counter_clone.fetch_add(1, Ordering::SeqCst);
605            });
606        });
607
608        thread::sleep(Duration::from_millis(200));
609
610        fs::write(root.join(".hidden.py"), "x = 1\n").unwrap();
611
612        thread::sleep(Duration::from_millis(600));
613
614        assert_eq!(
615            counter.load(Ordering::SeqCst),
616            0,
617            "dotfile should not trigger rebuild"
618        );
619    }
620
621    #[test]
622    fn test_watcher_ignores_codesynapse_out() {
623        let root = make_test_dir("watcher_gout");
624        let out = root.join("codesynapse-out");
625        fs::create_dir_all(&out).unwrap();
626
627        let counter = Arc::new(AtomicUsize::new(0));
628        let counter_clone = Arc::clone(&counter);
629
630        let cfg = WatchConfig {
631            root: root.clone(),
632            debounce_ms: 100,
633        };
634        let watcher = Arc::new(Watcher::new(cfg));
635        let watcher_clone = Arc::clone(&watcher);
636
637        let _handle = thread::spawn(move || {
638            let _ = watcher_clone.run(move |_| {
639                counter_clone.fetch_add(1, Ordering::SeqCst);
640            });
641        });
642
643        thread::sleep(Duration::from_millis(200));
644
645        fs::write(out.join("graph.json"), "{}").unwrap();
646
647        thread::sleep(Duration::from_millis(600));
648
649        assert_eq!(
650            counter.load(Ordering::SeqCst),
651            0,
652            "codesynapse-out writes should not trigger rebuild"
653        );
654    }
655}