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