1use 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#[derive(Debug, Clone)]
22pub struct WatchConfig {
23 pub debounce: Duration,
25 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#[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 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: ¬ify::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}