Skip to main content

rustbrain_core/
watch.rs

1//! Debounced filesystem watcher for live re-indexing.
2//!
3//! Requires the `watch` Cargo feature (pulls in `notify`). The watcher blocks
4//! the calling thread until the event channel disconnects.
5//!
6//! Strategy: accumulate interesting paths (`.md` / `.rs` / `.canvas`), wait for
7//! a quiet period ([`WatchConfig::debounce`]), then run a full
8//! [`WorkspaceIndexer::index_workspace`] (content-hash skips unchanged files)
9//! which also remaps `graph.mmap`.
10//!
11//! Paths under `.brain/`, `target/`, `.git/`, and entries matching
12//! `.rustbrainignore` (when present) are ignored.
13
14use crate::error::{BrainError, Result};
15use std::path::{Path, PathBuf};
16use std::time::Duration;
17
18/// Configuration for [`watch_workspace`].
19#[derive(Debug, Clone)]
20pub struct WatchConfig {
21    /// Debounce window: wait this long after the last event before re-syncing.
22    pub debounce: Duration,
23    /// If true, print status lines to stderr.
24    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/// Watch `workspace` for Markdown / Rust / Canvas changes and re-index + remap.
37///
38/// Blocks the current thread until the process is interrupted (channel disconnect).
39/// Requires the `watch` feature.
40#[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                            // Stay alive; next quiet period can retry.
129                            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    // Full workspace reindex is correct and simple; content-hash skips unchanged files.
147    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: &notify::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
180/// True when the path extension is one rustbrain indexes.
181pub 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
188/// True when the path sits under a directory rustbrain never indexes.
189pub fn is_under_skipped(workspace: &Path, path: &Path) -> bool {
190    let Ok(rel) = path.strip_prefix(workspace) else {
191        // Absolute path outside workspace — still skip known noise components.
192        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/// Stub when the `watch` feature is disabled at compile time.
211#[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}