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;
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::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                            // Stay alive; next quiet period can retry.
130                            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    // Full workspace reindex is correct and simple; content-hash skips unchanged files.
148    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: &notify::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
181/// True when the path extension is one rustbrain indexes.
182pub 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
189/// True when the path sits under a directory rustbrain never indexes.
190pub fn is_under_skipped(workspace: &Path, path: &Path) -> bool {
191    let Ok(rel) = path.strip_prefix(workspace) else {
192        // Absolute path outside workspace — still skip known noise components.
193        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/// Stub when the `watch` feature is disabled at compile time.
212#[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}