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}