use ignore::WalkBuilder;
use notify::RecursiveMode;
use notify_debouncer_full::{DebouncedEvent, Debouncer, RecommendedCache, new_debouncer};
use std::path::{Path, PathBuf};
use std::time::Duration;
use thiserror::Error;
use tracing::{info, warn};
use crate::sync::{delete_file, ensure_fresh_file, scan_workspace, update_file};
use crate::workspace::{Workspace, is_hard_excluded, to_forward_slash};
#[derive(Debug, Error)]
pub enum WatcherError {
#[error("Failed to initialize notify watcher: {0}")]
Notify(#[from] notify::Error),
#[error("Failed to build gitignore filters: {0}")]
Ignore(#[from] ignore::Error),
}
pub struct WatcherHandle {
_debouncer: Debouncer<notify::RecommendedWatcher, RecommendedCache>,
}
pub fn start_watcher(
workspace: Workspace,
db_path: PathBuf,
) -> Result<WatcherHandle, WatcherError> {
let ws_clone = workspace.clone();
let db_clone = db_path.clone();
let mut debouncer = new_debouncer(
Duration::from_millis(150),
None,
move |res: Result<Vec<DebouncedEvent>, _>| {
let events = match res {
Ok(evts) => evts,
Err(err) => {
warn!("File watcher error: {:?}", err);
return;
}
};
if events.is_empty() {
return;
}
let mut ignore_builder = WalkBuilder::new(&ws_clone.canonical_root);
ignore_builder
.standard_filters(true)
.hidden(false)
.add_custom_ignore_filename(".julieignore")
.add_custom_ignore_filename(".code-kb-ignore")
.add_custom_ignore_filename(".codekbignore");
let mut ignore_matcher = match ignore_builder.build_matchers().pop() {
Some(m) => m,
None => {
warn!("Failed to initialize ignore matcher for workspace root; skipping tick");
return;
}
};
let mut relevant_files = Vec::new();
for event in &events {
if event.kind.is_access() {
continue;
}
for path in &event.paths {
let norm_path = dunce::simplified(path);
if let Some(rel) =
crate::workspace::strip_prefix_lossy(norm_path, &ws_clone.canonical_root)
{
let rel_str = to_forward_slash(rel);
let rel_str = rel_str.trim_start_matches('/').to_string();
if rel_str.is_empty() || is_hard_excluded(&rel_str) {
continue;
}
let is_dir = norm_path.is_dir();
let (matched, error) =
ignore_matcher.matched_with_errors(Path::new(&rel_str), is_dir);
if let Some(error) = error {
warn!("Failed to load ignore rule: {error}");
}
if matched.is_ignore() {
continue;
}
relevant_files.push((norm_path.to_path_buf(), rel_str));
}
}
}
if relevant_files.is_empty() {
return;
}
relevant_files.sort_by(|a, b| a.1.cmp(&b.1));
relevant_files.dedup_by(|a, b| a.1 == b.1);
if relevant_files.len() > 50 {
tracing::debug!(
"Git storm detected ({} files changed in window). Running bulk scan...",
relevant_files.len()
);
if let Err(e) = scan_workspace(&ws_clone, &db_clone, false) {
warn!("Bulk scan failed during git storm: {e}");
}
return;
}
let conn_opt = crate::db::open_read_only(&db_clone).ok();
for (abs, rel) in relevant_files {
if abs.exists() && abs.is_file() {
let mut handled = false;
if let Some(ref conn) = conn_opt
&& let Ok(_fresh) = ensure_fresh_file(&ws_clone, &db_clone, conn, &rel)
{
handled = true;
}
if !handled {
let _ = update_file(&ws_clone, &db_clone, &rel);
}
} else if !abs.exists() {
let mut child_paths = Vec::new();
let local_conn;
let conn_ref = match conn_opt.as_ref() {
Some(c) => Some(c),
None => {
local_conn = crate::db::open_read_only(&db_clone).ok();
local_conn.as_ref()
}
};
if let Some(conn) = conn_ref {
let escaped = crate::queries::escape_like(&rel);
let pattern = format!("{escaped}/%");
if let Ok(mut stmt) = conn.prepare(
"SELECT path FROM files WHERE path LIKE ?1 ESCAPE '\\' LIMIT 51",
) && let Ok(rows) = stmt.query_map([&pattern], |r| r.get::<_, String>(0))
{
for child in rows.flatten() {
child_paths.push(child);
}
}
}
if child_paths.len() > 50 {
let _ = scan_workspace(&ws_clone, &db_clone, false);
} else {
for child in child_paths {
let _ = delete_file(&ws_clone, &db_clone, &child);
}
let _ = delete_file(&ws_clone, &db_clone, &rel);
}
}
}
},
)?;
debouncer.watch(&workspace.canonical_root, RecursiveMode::Recursive)?;
info!(
"Tier 3 file watcher active on '{}' (150ms debounce)",
workspace.canonical_root.display()
);
Ok(WatcherHandle {
_debouncer: debouncer,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn test_drive_root_leading_slash_trimmed() {
let raw_rel = Path::new("/src/components/button.rs");
let rel_str = to_forward_slash(raw_rel);
let trimmed = rel_str.trim_start_matches('/').to_string();
assert_eq!(trimmed, "src/components/button.rs");
assert!(!trimmed.starts_with('/'));
let raw_rel_win = Path::new("\\src\\components\\button.rs");
let rel_str_win = to_forward_slash(raw_rel_win);
let trimmed_win = rel_str_win.trim_start_matches('/').to_string();
assert_eq!(trimmed_win, "src/components/button.rs");
assert!(!trimmed_win.starts_with('/'));
}
#[test]
fn test_child_files_query_pattern_escaped() {
let dir_rel = "src/components";
let escaped = dir_rel
.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_");
let pattern = format!("{escaped}/%");
assert_eq!(pattern, "src/components/%");
let dir_with_wildcards = "src/foo_bar%baz";
let escaped2 = dir_with_wildcards
.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_");
let pattern2 = format!("{escaped2}/%");
assert_eq!(pattern2, "src/foo\\_bar\\%baz/%");
}
}