1use crate::error::{BrainError, Result};
15use std::path::{Path, PathBuf};
16use std::time::Duration;
17
18#[derive(Debug, Clone)]
20pub struct WatchConfig {
21 pub debounce: Duration,
23 pub verbose: bool,
25}
26
27impl Default for WatchConfig {
28 fn default() -> Self {
29 Self {
30 debounce: Duration::from_millis(300),
31 verbose: true,
32 }
33 }
34}
35
36#[cfg(feature = "watch")]
41pub fn watch_workspace(workspace: impl AsRef<Path>, config: WatchConfig) -> Result<()> {
42 use crate::ignore::IgnoreSet;
43 use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher};
44 use std::collections::HashSet;
45 use std::sync::mpsc::{self, RecvTimeoutError};
46 use std::time::Instant;
47
48 let workspace = workspace.as_ref().to_path_buf();
49 let brain_dir = workspace.join(".brain");
50 if !brain_dir.join("db.sqlite").exists() {
51 return Err(BrainError::BrainNotFound { path: brain_dir });
52 }
53
54 let ignore = IgnoreSet::load(&workspace, false).unwrap_or_default();
55
56 let (tx, rx) = mpsc::channel();
57 let mut watcher = RecommendedWatcher::new(
58 move |res| {
59 let _ = tx.send(res);
60 },
61 notify::Config::default(),
62 )
63 .map_err(|e| BrainError::indexer(format!("watcher init: {e}")))?;
64
65 watcher
66 .watch(&workspace, RecursiveMode::Recursive)
67 .map_err(|e| BrainError::indexer(format!("watch start: {e}")))?;
68
69 if config.verbose {
70 eprintln!(
71 "rustbrain watch: {} (debounce {}ms)",
72 workspace.display(),
73 config.debounce.as_millis()
74 );
75 }
76
77 let mut pending: HashSet<PathBuf> = HashSet::new();
78 let mut last_event = Instant::now();
79 let mut dirty = false;
80
81 loop {
82 let timeout = if dirty {
83 config
84 .debounce
85 .saturating_sub(last_event.elapsed())
86 .max(Duration::from_millis(10))
87 } else {
88 Duration::from_secs(3600)
89 };
90
91 match rx.recv_timeout(timeout) {
92 Ok(Ok(Event { kind, paths, .. })) => {
93 if !is_interesting_event(&kind) {
94 continue;
95 }
96 for p in paths {
97 if should_reindex_path(&workspace, &p, &ignore) {
98 pending.insert(p);
99 dirty = true;
100 last_event = Instant::now();
101 }
102 }
103 }
104 Ok(Err(e)) => {
105 if config.verbose {
106 eprintln!("rustbrain watch: event error: {e}");
107 }
108 }
109 Err(RecvTimeoutError::Timeout) => {
110 if dirty && last_event.elapsed() >= config.debounce {
111 let n = pending.len();
112 pending.clear();
113 dirty = false;
114 match reindex(&workspace) {
115 Ok(stats) => {
116 if config.verbose {
117 eprintln!(
118 "rustbrain watch: reindexed after {n} path event(s) (md≈{} rs≈{} edges+={} mmap={} pending_links={})",
119 stats.markdown_files,
120 stats.rust_files,
121 stats.edges_created,
122 stats.mmap_written,
123 stats.edges_pending
124 );
125 }
126 }
127 Err(e) => {
128 eprintln!("rustbrain watch: reindex failed: {e}");
130 }
131 }
132 }
133 }
134 Err(RecvTimeoutError::Disconnected) => break,
135 }
136 }
137
138 Ok(())
139}
140
141#[cfg(feature = "watch")]
142fn reindex(workspace: &Path) -> Result<crate::types::SyncStats> {
143 use crate::indexer::WorkspaceIndexer;
144 use crate::storage::Database;
145
146 let db_path = workspace.join(".brain").join("db.sqlite");
148 let db = Database::open(db_path)?;
149 let indexer = WorkspaceIndexer::new(db, workspace);
150 indexer.index_workspace()
151}
152
153#[cfg(feature = "watch")]
154fn is_interesting_event(kind: ¬ify::EventKind) -> bool {
155 use notify::EventKind;
156 matches!(
157 kind,
158 EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) | EventKind::Any
159 )
160}
161
162#[cfg(feature = "watch")]
163fn should_reindex_path(workspace: &Path, path: &Path, ignore: &crate::ignore::IgnoreSet) -> bool {
164 if !is_indexable(path) {
165 return false;
166 }
167 if is_under_skipped(workspace, path) {
168 return false;
169 }
170 if let Ok(rel) = path.strip_prefix(workspace) {
171 let rel_s = rel.to_string_lossy().replace('\\', "/");
172 let is_dir = path.is_dir();
173 if ignore.is_ignored(&rel_s, is_dir) {
174 return false;
175 }
176 }
177 true
178}
179
180pub fn is_indexable(path: &Path) -> bool {
182 matches!(
183 path.extension().and_then(|e| e.to_str()),
184 Some("md") | Some("rs") | Some("canvas")
185 )
186}
187
188pub fn is_under_skipped(workspace: &Path, path: &Path) -> bool {
190 let Ok(rel) = path.strip_prefix(workspace) else {
191 return path.components().any(|c| {
193 let s = c.as_os_str().to_string_lossy();
194 is_skipped_component(s.as_ref())
195 });
196 };
197 rel.components().any(|c| {
198 let s = c.as_os_str().to_string_lossy();
199 is_skipped_component(s.as_ref())
200 })
201}
202
203fn is_skipped_component(s: &str) -> bool {
204 matches!(
205 s,
206 "target" | "node_modules" | ".git" | ".brain" | "vendor" | "dist" | "build" | ".cargo"
207 ) || (s.starts_with('.') && s != "." && s != "..")
208}
209
210#[cfg(not(feature = "watch"))]
212pub fn watch_workspace(_workspace: impl AsRef<Path>, _config: WatchConfig) -> Result<()> {
213 Err(BrainError::FeatureDisabled("watch"))
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219 use std::path::{Path, PathBuf};
220
221 #[test]
222 fn indexable_extensions() {
223 assert!(is_indexable(Path::new("docs/a.md")));
224 assert!(is_indexable(Path::new("src/lib.rs")));
225 assert!(is_indexable(Path::new("map.canvas")));
226 assert!(!is_indexable(Path::new("Cargo.toml")));
227 assert!(!is_indexable(Path::new("notes.txt")));
228 }
229
230 #[test]
231 fn skips_brain_and_target() {
232 let ws = PathBuf::from("/proj");
233 assert!(is_under_skipped(&ws, &ws.join(".brain/db.sqlite")));
234 assert!(is_under_skipped(&ws, &ws.join("target/debug/foo.rs")));
235 assert!(is_under_skipped(&ws, &ws.join(".git/config")));
236 assert!(!is_under_skipped(&ws, &ws.join("docs/a.md")));
237 assert!(!is_under_skipped(&ws, &ws.join("src/lib.rs")));
238 }
239}