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
11use crate::error::{BrainError, Result};
12use crate::indexer::WorkspaceIndexer;
13use crate::storage::Database;
14use crate::types::SyncStats;
15use std::collections::HashSet;
16use std::path::{Path, PathBuf};
17use std::sync::mpsc::{self, RecvTimeoutError};
18use std::time::{Duration, Instant};
19
20/// Configuration for [`watch_workspace`].
21#[derive(Debug, Clone)]
22pub struct WatchConfig {
23    /// Debounce window: wait this long after the last event before re-syncing.
24    pub debounce: Duration,
25    /// If true, print status lines to stderr.
26    pub verbose: bool,
27}
28
29impl Default for WatchConfig {
30    fn default() -> Self {
31        Self {
32            debounce: Duration::from_millis(300),
33            verbose: true,
34        }
35    }
36}
37
38/// Watch `workspace` for Markdown / Rust / Canvas changes and re-index + remmap.
39///
40/// Blocks the current thread until the process is interrupted (channel disconnect).
41/// Requires the `watch` feature.
42#[cfg(feature = "watch")]
43pub fn watch_workspace(workspace: impl AsRef<Path>, config: WatchConfig) -> Result<()> {
44    use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher};
45
46    let workspace = workspace.as_ref().to_path_buf();
47    let brain_dir = workspace.join(".brain");
48    if !brain_dir.join("db.sqlite").exists() {
49        return Err(BrainError::BrainNotFound { path: brain_dir });
50    }
51
52    let (tx, rx) = mpsc::channel();
53    let mut watcher = RecommendedWatcher::new(
54        move |res| {
55            let _ = tx.send(res);
56        },
57        notify::Config::default(),
58    )
59    .map_err(|e| BrainError::indexer(format!("watcher init: {e}")))?;
60
61    watcher
62        .watch(&workspace, RecursiveMode::Recursive)
63        .map_err(|e| BrainError::indexer(format!("watch start: {e}")))?;
64
65    if config.verbose {
66        eprintln!(
67            "rustbrain watch: {} (debounce {}ms)",
68            workspace.display(),
69            config.debounce.as_millis()
70        );
71    }
72
73    let mut pending: HashSet<PathBuf> = HashSet::new();
74    let mut last_event = Instant::now();
75    let mut dirty = false;
76
77    loop {
78        let timeout = if dirty {
79            config
80                .debounce
81                .saturating_sub(last_event.elapsed())
82                .max(Duration::from_millis(10))
83        } else {
84            Duration::from_secs(3600)
85        };
86
87        match rx.recv_timeout(timeout) {
88            Ok(Ok(Event { kind, paths, .. })) => {
89                if !is_interesting_event(&kind) {
90                    continue;
91                }
92                for p in paths {
93                    if is_indexable(&p) && !is_under_skipped(&workspace, &p) {
94                        pending.insert(p);
95                        dirty = true;
96                        last_event = Instant::now();
97                    }
98                }
99            }
100            Ok(Err(e)) => {
101                if config.verbose {
102                    eprintln!("rustbrain watch: event error: {e}");
103                }
104            }
105            Err(RecvTimeoutError::Timeout) => {
106                if dirty && last_event.elapsed() >= config.debounce {
107                    let paths: Vec<PathBuf> = pending.drain().collect();
108                    dirty = false;
109                    match reindex(&workspace, &paths, config.verbose) {
110                        Ok(stats) => {
111                            if config.verbose {
112                                eprintln!(
113                                    "rustbrain watch: reindexed (md≈{} rs≈{} edges+={} mmap={})",
114                                    stats.markdown_files,
115                                    stats.rust_files,
116                                    stats.edges_created,
117                                    stats.mmap_written
118                                );
119                            }
120                        }
121                        Err(e) => {
122                            eprintln!("rustbrain watch: reindex failed: {e}");
123                        }
124                    }
125                }
126            }
127            Err(RecvTimeoutError::Disconnected) => break,
128        }
129    }
130
131    Ok(())
132}
133
134#[cfg(feature = "watch")]
135fn reindex(workspace: &Path, _paths: &[PathBuf], _verbose: bool) -> Result<SyncStats> {
136    // Full workspace reindex is correct and simple; content-hash skips unchanged files.
137    let db_path = workspace.join(".brain").join("db.sqlite");
138    let db = Database::open(db_path)?;
139    let indexer = WorkspaceIndexer::new(db, workspace);
140    indexer.index_workspace()
141}
142
143#[cfg(feature = "watch")]
144fn is_interesting_event(kind: &notify::EventKind) -> bool {
145    use notify::EventKind;
146    matches!(
147        kind,
148        EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) | EventKind::Any
149    )
150}
151
152fn is_indexable(path: &Path) -> bool {
153    matches!(
154        path.extension().and_then(|e| e.to_str()),
155        Some("md") | Some("rs") | Some("canvas")
156    )
157}
158
159fn is_under_skipped(workspace: &Path, path: &Path) -> bool {
160    let Ok(rel) = path.strip_prefix(workspace) else {
161        return false;
162    };
163    rel.components().any(|c| {
164        let s = c.as_os_str().to_string_lossy();
165        matches!(
166            s.as_ref(),
167            "target" | "node_modules" | ".git" | ".brain" | "vendor" | "dist" | "build"
168        ) || s.starts_with('.')
169    })
170}
171
172#[cfg(not(feature = "watch"))]
173pub fn watch_workspace(_workspace: impl AsRef<Path>, _config: WatchConfig) -> Result<()> {
174    Err(BrainError::FeatureDisabled("watch"))
175}