use crate::error::{BrainError, Result};
use crate::indexer::WorkspaceIndexer;
use crate::storage::Database;
use crate::types::SyncStats;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, RecvTimeoutError};
use std::time::{Duration, Instant};
#[derive(Debug, Clone)]
pub struct WatchConfig {
pub debounce: Duration,
pub verbose: bool,
}
impl Default for WatchConfig {
fn default() -> Self {
Self {
debounce: Duration::from_millis(300),
verbose: true,
}
}
}
#[cfg(feature = "watch")]
pub fn watch_workspace(workspace: impl AsRef<Path>, config: WatchConfig) -> Result<()> {
use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher};
let workspace = workspace.as_ref().to_path_buf();
let brain_dir = workspace.join(".brain");
if !brain_dir.join("db.sqlite").exists() {
return Err(BrainError::BrainNotFound { path: brain_dir });
}
let (tx, rx) = mpsc::channel();
let mut watcher = RecommendedWatcher::new(
move |res| {
let _ = tx.send(res);
},
notify::Config::default(),
)
.map_err(|e| BrainError::indexer(format!("watcher init: {e}")))?;
watcher
.watch(&workspace, RecursiveMode::Recursive)
.map_err(|e| BrainError::indexer(format!("watch start: {e}")))?;
if config.verbose {
eprintln!(
"rustbrain watch: {} (debounce {}ms)",
workspace.display(),
config.debounce.as_millis()
);
}
let mut pending: HashSet<PathBuf> = HashSet::new();
let mut last_event = Instant::now();
let mut dirty = false;
loop {
let timeout = if dirty {
config
.debounce
.saturating_sub(last_event.elapsed())
.max(Duration::from_millis(10))
} else {
Duration::from_secs(3600)
};
match rx.recv_timeout(timeout) {
Ok(Ok(Event { kind, paths, .. })) => {
if !is_interesting_event(&kind) {
continue;
}
for p in paths {
if is_indexable(&p) && !is_under_skipped(&workspace, &p) {
pending.insert(p);
dirty = true;
last_event = Instant::now();
}
}
}
Ok(Err(e)) => {
if config.verbose {
eprintln!("rustbrain watch: event error: {e}");
}
}
Err(RecvTimeoutError::Timeout) => {
if dirty && last_event.elapsed() >= config.debounce {
let paths: Vec<PathBuf> = pending.drain().collect();
dirty = false;
match reindex(&workspace, &paths, config.verbose) {
Ok(stats) => {
if config.verbose {
eprintln!(
"rustbrain watch: reindexed (md≈{} rs≈{} edges+={} mmap={})",
stats.markdown_files,
stats.rust_files,
stats.edges_created,
stats.mmap_written
);
}
}
Err(e) => {
eprintln!("rustbrain watch: reindex failed: {e}");
}
}
}
}
Err(RecvTimeoutError::Disconnected) => break,
}
}
Ok(())
}
#[cfg(feature = "watch")]
fn reindex(workspace: &Path, _paths: &[PathBuf], _verbose: bool) -> Result<SyncStats> {
let db_path = workspace.join(".brain").join("db.sqlite");
let db = Database::open(db_path)?;
let indexer = WorkspaceIndexer::new(db, workspace);
indexer.index_workspace()
}
#[cfg(feature = "watch")]
fn is_interesting_event(kind: ¬ify::EventKind) -> bool {
use notify::EventKind;
matches!(
kind,
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) | EventKind::Any
)
}
fn is_indexable(path: &Path) -> bool {
matches!(
path.extension().and_then(|e| e.to_str()),
Some("md") | Some("rs") | Some("canvas")
)
}
fn is_under_skipped(workspace: &Path, path: &Path) -> bool {
let Ok(rel) = path.strip_prefix(workspace) else {
return false;
};
rel.components().any(|c| {
let s = c.as_os_str().to_string_lossy();
matches!(
s.as_ref(),
"target" | "node_modules" | ".git" | ".brain" | "vendor" | "dist" | "build"
) || s.starts_with('.')
})
}
#[cfg(not(feature = "watch"))]
pub fn watch_workspace(_workspace: impl AsRef<Path>, _config: WatchConfig) -> Result<()> {
Err(BrainError::FeatureDisabled("watch"))
}