use anyhow::Result;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::mpsc;
use tracing::{error, info, warn};
use crate::config::Config;
use crate::db::{graph::GraphDb, vector::VectorDb};
use crate::pipeline::{
files::is_supported_file, progress::ProgressTracker,
runner::run_indexing_pipeline_with_progress, state::IndexState,
};
pub async fn setup_watch_mode(
cfg: &Config,
vector_db: &Arc<VectorDb>,
graph_db: &Arc<GraphDb>,
index_state: &mut IndexState,
) -> Result<()> {
setup_watch_mode_with_progress(
cfg,
vector_db,
graph_db,
index_state,
Arc::new(ProgressTracker::new()),
)
.await
}
#[expect(
clippy::too_many_lines,
reason = "function is verbose but correct — extraction deferred"
)]
#[expect(
clippy::cognitive_complexity,
reason = "function is verbose but correct — extraction deferred"
)]
pub async fn setup_watch_mode_with_progress(
cfg: &Config,
vector_db: &Arc<VectorDb>,
graph_db: &Arc<GraphDb>,
index_state: &mut IndexState,
progress: Arc<ProgressTracker>,
) -> Result<()> {
let (tx, mut rx) = mpsc::channel::<Vec<PathBuf>>(100);
let include_cf = cfg.include_config_files;
let mut debouncer = notify_debouncer_mini::new_debouncer(
std::time::Duration::from_millis(500),
move |res: notify_debouncer_mini::DebounceEventResult| {
if let Ok(events) = res {
let paths: Vec<PathBuf> = events
.into_iter()
.map(|e| e.path)
.filter(|p| is_supported_file(p, include_cf))
.collect();
if !paths.is_empty() {
let _ = tx.blocking_send(paths);
}
}
},
)?;
let repo_path = Path::new(&cfg.repo_path);
match debouncer
.watcher()
.watch(repo_path, notify::RecursiveMode::Recursive)
{
Ok(()) => {
info!("Recursive watch mode enabled for {}", cfg.repo_path);
}
Err(e) => {
let err_msg = e.to_string();
if err_msg.contains("Permission denied") {
warn!(
"Permission denied when watching subdirectory. \
This can happen for system directories (e.g., data/neo4j/import). \
Falling back to non-recursive watch mode for the repository root."
);
debouncer
.watcher()
.watch(repo_path, notify::RecursiveMode::NonRecursive)?;
warn!("Watch mode is now monitoring only top-level directories.");
} else {
return Err(e.into());
}
}
}
while let Some(mut paths) = rx.recv().await {
while let Ok(mut more_paths) = rx.try_recv() {
paths.append(&mut more_paths);
}
paths.sort();
paths.dedup();
if paths.len() == 1 {
info!("Change detected in: {}", paths[0].display());
} else {
info!(
"Changes detected in {} files, triggering update...",
paths.len()
);
}
if let Err(e) = run_indexing_pipeline_with_progress(
cfg,
vector_db,
graph_db,
index_state,
Arc::clone(&progress),
)
.await
{
error!("Error during incremental update: {e:#}");
}
tokio::time::sleep(tokio::time::Duration::from_millis(300)).await;
let mut residual_count = 0;
while rx.try_recv().is_ok() {
residual_count += 1;
}
if residual_count > 0 {
info!(
"Discarded {} residual filesystem event(s) from post-indexation period",
residual_count
);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
#[expect(
clippy::cognitive_complexity,
reason = "function is verbose but correct — extraction deferred"
)]
fn test_watch_supported_files() {
assert!(is_supported_file(Path::new("test.java"), true));
assert!(is_supported_file(Path::new("test.ts"), true));
assert!(is_supported_file(Path::new("test.tsx"), true));
assert!(is_supported_file(Path::new("test.cts"), true));
assert!(is_supported_file(Path::new("test.kt"), true));
assert!(is_supported_file(Path::new("test.kts"), true));
assert!(is_supported_file(Path::new("test.html"), true));
assert!(is_supported_file(Path::new("test.css"), true));
assert!(is_supported_file(Path::new("test.scss"), true));
assert!(is_supported_file(Path::new("test.rs"), true));
assert!(!is_supported_file(Path::new("test.txt"), true));
assert!(is_supported_file(Path::new("test.py"), true));
assert!(is_supported_file(Path::new("test.pyi"), true));
assert!(is_supported_file(Path::new("test.pyw"), true));
assert!(is_supported_file(Path::new("test.md"), true));
}
#[test]
fn test_path_deduplication() {
let mut paths = vec![
PathBuf::from("src/file1.ts"),
PathBuf::from("src/file2.ts"),
PathBuf::from("src/file1.ts"),
PathBuf::from("src/file3.ts"),
PathBuf::from("src/file2.ts"),
];
paths.sort();
paths.dedup();
assert_eq!(paths.len(), 3);
assert_eq!(paths[0], PathBuf::from("src/file1.ts"));
assert_eq!(paths[1], PathBuf::from("src/file2.ts"));
assert_eq!(paths[2], PathBuf::from("src/file3.ts"));
}
#[test]
fn test_path_deduplication_empty() {
let mut paths: Vec<PathBuf> = vec![];
paths.sort();
paths.dedup();
assert_eq!(paths.len(), 0);
}
#[test]
fn test_path_deduplication_single_element() {
let mut paths = vec![PathBuf::from("src/only_file.ts")];
paths.sort();
paths.dedup();
assert_eq!(paths.len(), 1);
assert_eq!(paths[0], PathBuf::from("src/only_file.ts"));
}
#[test]
fn test_path_deduplication_all_duplicates() {
let mut paths = vec![
PathBuf::from("src/file.ts"),
PathBuf::from("src/file.ts"),
PathBuf::from("src/file.ts"),
PathBuf::from("src/file.ts"),
];
paths.sort();
paths.dedup();
assert_eq!(paths.len(), 1);
assert_eq!(paths[0], PathBuf::from("src/file.ts"));
}
#[test]
fn test_supported_file_extensions() {
let supported = vec!["file.java", "module.ts", "component.tsx", "script.cts"];
for filename in supported {
assert!(
is_supported_file(Path::new(filename), true),
"File {} should be supported",
filename
);
}
}
#[test]
fn test_unsupported_file_extensions() {
let unsupported = vec![
"document.txt",
"image.png",
"script.pyc",
"script.pyo",
"script.py.bak",
"styles.less",
];
for filename in unsupported {
assert!(
!is_supported_file(Path::new(filename), true),
"File {} should not be supported",
filename
);
}
}
#[test]
fn test_supported_file_with_nested_paths() {
let supported_paths = vec![
"src/components/Frame.ts",
"packages/core/src/index.ts",
"lib/utils/helper.tsx",
"main/java/com/example/MyClass.java",
];
for path in supported_paths {
assert!(
is_supported_file(Path::new(path), true),
"Path {} should be supported",
path
);
}
}
#[test]
fn test_path_batch_accumulation_logic() {
let mut batch1 = vec![PathBuf::from("src/file1.ts"), PathBuf::from("src/file2.ts")];
let batch2 = vec![
PathBuf::from("src/file3.ts"),
PathBuf::from("src/file1.ts"), ];
batch1.append(&mut batch2.clone());
batch1.sort();
batch1.dedup();
assert_eq!(batch1.len(), 3);
assert!(batch1.contains(&PathBuf::from("src/file1.ts")));
assert!(batch1.contains(&PathBuf::from("src/file2.ts")));
assert!(batch1.contains(&PathBuf::from("src/file3.ts")));
}
#[test]
fn test_supported_files_case_sensitivity() {
assert!(is_supported_file(Path::new("file.ts"), true));
assert!(!is_supported_file(Path::new("file.TS"), true));
}
}