Skip to main content

codegraph/
watcher.rs

1use crate::config::CodeGraphConfig;
2use crate::extraction::should_include_file;
3use crate::{find_nearest_codegraph_root, CodeGraph, CODEGRAPH_DIR};
4use anyhow::{anyhow, Result};
5use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
6use std::collections::BTreeSet;
7use std::path::PathBuf;
8use std::sync::mpsc;
9use std::time::Duration;
10
11const DEFAULT_DEBOUNCE_MS: u64 = 300;
12
13pub struct WatcherConfig {
14    pub debounce_ms: u64,
15}
16
17impl Default for WatcherConfig {
18    fn default() -> Self {
19        Self {
20            debounce_ms: DEFAULT_DEBOUNCE_MS,
21        }
22    }
23}
24
25pub fn run_watcher(root: PathBuf, watcher_config: WatcherConfig) -> Result<()> {
26    let cg_root = find_nearest_codegraph_root(&root)
27        .ok_or_else(|| anyhow!("CodeGraph not initialized in {}", root.display()))?;
28    let cg = CodeGraph::open(&cg_root)?;
29    let config = cg.config().clone();
30    drop(cg);
31
32    let (tx, rx) = mpsc::channel();
33
34    let mut watcher = RecommendedWatcher::new(
35        move |res: Result<Event, notify::Error>| {
36            if let Ok(event) = res {
37                let _ = tx.send(event);
38            }
39        },
40        Config::default(),
41    )?;
42
43    watcher.watch(&cg_root, RecursiveMode::Recursive)?;
44
45    let mut pending_paths: BTreeSet<PathBuf> = BTreeSet::new();
46    let debounce = Duration::from_millis(watcher_config.debounce_ms);
47
48    eprintln!(
49        "Watching {} (debounce {}ms)...",
50        cg_root.display(),
51        watcher_config.debounce_ms
52    );
53    eprintln!("Press Ctrl+C to stop.");
54
55    loop {
56        match rx.recv_timeout(debounce) {
57            Ok(event) => {
58                if is_relevant_event(&event, &cg_root, &config) {
59                    for path in &event.paths {
60                        let rel = path.strip_prefix(&cg_root).unwrap_or(path).to_path_buf();
61                        if should_watch_path(&rel, &config) {
62                            pending_paths.insert(path.clone());
63                        }
64                    }
65                }
66            }
67            Err(mpsc::RecvTimeoutError::Timeout) => {
68                if !pending_paths.is_empty() {
69                    let paths = std::mem::take(&mut pending_paths);
70                    match sync_if_relevant_changes(&cg_root, &config, &paths) {
71                        Ok(count) => {
72                            if count > 0 {
73                                eprintln!("Synced {} changed file(s)", count);
74                            }
75                        }
76                        Err(err) => {
77                            eprintln!("Sync error: {err}");
78                        }
79                    }
80                }
81            }
82            Err(mpsc::RecvTimeoutError::Disconnected) => {
83                break;
84            }
85        }
86    }
87
88    Ok(())
89}
90
91fn is_relevant_event(event: &Event, root: &std::path::Path, config: &CodeGraphConfig) -> bool {
92    match event.kind {
93        EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) => {}
94        _ => return false,
95    }
96
97    for path in &event.paths {
98        let rel = match path.strip_prefix(root) {
99            Ok(r) => r.to_path_buf(),
100            Err(_) => continue,
101        };
102        if should_watch_path(&rel, config) {
103            return true;
104        }
105    }
106    false
107}
108
109pub fn should_watch_path(rel: &std::path::Path, config: &CodeGraphConfig) -> bool {
110    if rel.components().any(|c| c.as_os_str() == CODEGRAPH_DIR) {
111        return false;
112    }
113    should_include_file(rel, config)
114}
115
116fn sync_if_relevant_changes(
117    root: &std::path::Path,
118    config: &CodeGraphConfig,
119    changed_paths: &BTreeSet<PathBuf>,
120) -> Result<usize> {
121    let mut relevant: BTreeSet<PathBuf> = BTreeSet::new();
122    for path in changed_paths {
123        let rel = path.strip_prefix(root).unwrap_or(path).to_path_buf();
124        if should_watch_path(&rel, config) {
125            relevant.insert(rel);
126        }
127    }
128    if relevant.is_empty() {
129        return Ok(0);
130    }
131
132    let mut cg = CodeGraph::open(root)?;
133    let result = cg.sync()?;
134    Ok((result.files_indexed + result.files_deleted) as usize)
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use crate::config::CodeGraphConfig;
141
142    #[test]
143    fn test_should_watch_path_excludes_codegraph_dir() {
144        let config = CodeGraphConfig::default_for_root(".");
145        assert!(!should_watch_path(
146            std::path::Path::new(".codegraph/codegraph.db"),
147            &config
148        ));
149        assert!(!should_watch_path(
150            std::path::Path::new(".codegraph/config.json"),
151            &config
152        ));
153    }
154
155    #[test]
156    fn test_should_watch_path_excludes_build_outputs() {
157        let config = CodeGraphConfig::default_for_root(".");
158        assert!(!should_watch_path(
159            std::path::Path::new("target/debug/main"),
160            &config
161        ));
162        assert!(!should_watch_path(
163            std::path::Path::new("build/output.js"),
164            &config
165        ));
166        assert!(!should_watch_path(
167            std::path::Path::new("dist/bundle.js"),
168            &config
169        ));
170    }
171
172    #[test]
173    fn test_should_watch_path_includes_source_files() {
174        let config = CodeGraphConfig::default_for_root(".");
175        assert!(should_watch_path(
176            std::path::Path::new("src/main.rs"),
177            &config
178        ));
179        assert!(should_watch_path(
180            std::path::Path::new("lib/app.ts"),
181            &config
182        ));
183        assert!(should_watch_path(
184            std::path::Path::new("src/lib.mbt"),
185            &config
186        ));
187    }
188
189    #[test]
190    fn test_should_watch_path_excludes_non_included_files() {
191        let config = CodeGraphConfig::default_for_root(".");
192        assert!(!should_watch_path(
193            std::path::Path::new("README.md"),
194            &config
195        ));
196        assert!(!should_watch_path(
197            std::path::Path::new("image.png"),
198            &config
199        ));
200    }
201
202    #[test]
203    fn test_watcher_config_default_debounce() {
204        let config = WatcherConfig::default();
205        assert_eq!(config.debounce_ms, 300);
206    }
207}