use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::sync::mpsc;
#[derive(Debug, Clone)]
pub enum WatchEvent {
BinaryChanged(PathBuf),
ConfigChanged(PathBuf),
Error(String),
}
#[derive(Debug, Clone)]
pub struct WatcherConfig {
pub target_dir: PathBuf,
pub config_files: Vec<PathBuf>,
pub debounce: Duration,
}
impl Default for WatcherConfig {
fn default() -> Self {
Self {
target_dir: PathBuf::from("target/debug"),
config_files: Vec::new(),
debounce: Duration::from_millis(500),
}
}
}
pub struct WatcherHandle {
pub events: mpsc::Receiver<WatchEvent>,
pub watcher: RecommendedWatcher,
}
pub fn start_watching(config: WatcherConfig) -> Result<WatcherHandle, String> {
let (tx, rx) = mpsc::channel(100);
let target_dir = config.target_dir.clone();
let config_files: Vec<PathBuf> = config.config_files.clone();
let tx_clone = tx.clone();
let mut watcher = notify::recommended_watcher(move |result: Result<Event, notify::Error>| {
match result {
Ok(event) => {
let dominated_events = matches!(
event.kind,
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
);
if !dominated_events {
return;
}
for path in event.paths {
let watch_event = categorize_path(&path, &target_dir, &config_files);
if let Some(evt) = watch_event {
let _ = tx_clone.try_send(evt);
}
}
}
Err(e) => {
let _ = tx_clone.try_send(WatchEvent::Error(e.to_string()));
}
}
})
.map_err(|e| format!("Failed to create file watcher: {}", e))?;
if config.target_dir.exists() {
watcher
.watch(&config.target_dir, RecursiveMode::NonRecursive)
.map_err(|e| format!("Failed to watch {}: {}", config.target_dir.display(), e))?;
} else if let Some(parent) = config.target_dir.parent() {
if parent.exists() {
let _ = watcher.watch(parent, RecursiveMode::NonRecursive);
}
}
for config_file in &config.config_files {
if let Some(parent) = config_file.parent() {
if parent.exists() {
let _ = watcher.watch(parent, RecursiveMode::NonRecursive);
}
}
}
Ok(WatcherHandle {
events: rx,
watcher,
})
}
fn categorize_path(path: &Path, target_dir: &Path, config_files: &[PathBuf]) -> Option<WatchEvent> {
for config_file in config_files {
if path == config_file {
return Some(WatchEvent::ConfigChanged(path.to_path_buf()));
}
}
if path.starts_with(target_dir) {
if is_binary_or_library(path) {
return Some(WatchEvent::BinaryChanged(path.to_path_buf()));
}
}
None
}
fn is_binary_or_library(path: &Path) -> bool {
let extension = path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_lowercase());
match extension.as_deref() {
Some("exe") => true,
Some("rlib") | Some("dylib") | Some("so") | Some("dll") | Some("a") => true,
Some("dsym") => false, None => {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
!name.starts_with('.') && !name.ends_with(".d") && name != "build" && name != "deps"
}
_ => false,
}
}
pub async fn debounced_events(
mut events: mpsc::Receiver<WatchEvent>,
debounce: Duration,
) -> mpsc::Receiver<()> {
let (tx, rx) = mpsc::channel(10);
tokio::spawn(async move {
let mut pending = false;
let mut debounce_timer: Option<tokio::time::Instant> = None;
loop {
let timeout = debounce_timer
.map(|t| t.saturating_duration_since(tokio::time::Instant::now()))
.unwrap_or(Duration::from_secs(3600));
tokio::select! {
event = events.recv() => {
match event {
Some(WatchEvent::BinaryChanged(_)) | Some(WatchEvent::ConfigChanged(_)) => {
pending = true;
debounce_timer = Some(tokio::time::Instant::now() + debounce);
}
Some(WatchEvent::Error(e)) => {
eprintln!("File watcher error: {}", e);
}
None => break, }
}
_ = tokio::time::sleep(timeout), if pending => {
pending = false;
debounce_timer = None;
if tx.send(()).await.is_err() {
break; }
}
}
}
});
rx
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_binary_or_library() {
assert!(
is_binary_or_library(Path::new("target/debug/myapp")),
"Binary without extension should trigger"
);
assert!(
is_binary_or_library(Path::new("target/debug/jonesy")),
"Binary without extension should trigger"
);
assert!(
is_binary_or_library(Path::new("target/debug/my-app-name")),
"Binary with hyphens should trigger"
);
assert!(
is_binary_or_library(Path::new("target/debug/libfoo.rlib")),
"Rust library (.rlib) should trigger"
);
assert!(
is_binary_or_library(Path::new("target/debug/libjonesy.rlib")),
"Rust library (.rlib) should trigger"
);
assert!(
is_binary_or_library(Path::new("target/debug/libfoo.dylib")),
"Dynamic library (.dylib) should trigger"
);
assert!(
is_binary_or_library(Path::new("target/debug/libfoo.so")),
"Shared object (.so) should trigger"
);
assert!(
is_binary_or_library(Path::new("target/debug/foo.dll")),
"DLL (.dll) should trigger"
);
assert!(
is_binary_or_library(Path::new("target/debug/libfoo.a")),
"Static library (.a) should trigger"
);
assert!(
!is_binary_or_library(Path::new("target/debug/.fingerprint")),
"Hidden directories should be skipped"
);
assert!(
!is_binary_or_library(Path::new("target/debug/.cargo-lock")),
"Hidden files should be skipped"
);
assert!(
!is_binary_or_library(Path::new("target/debug/deps")),
"deps directory should be skipped"
);
assert!(
!is_binary_or_library(Path::new("target/debug/build")),
"build directory should be skipped"
);
assert!(
!is_binary_or_library(Path::new("target/debug/foo.d")),
"Dependency files (.d) should be skipped"
);
assert!(
!is_binary_or_library(Path::new("target/debug/foo.rmeta")),
"Rust metadata (.rmeta) should be skipped"
);
assert!(
!is_binary_or_library(Path::new("target/debug/myapp.dSYM")),
"dSYM bundles should be skipped"
);
assert!(
!is_binary_or_library(Path::new("target/debug/foo.o")),
"Object files (.o) should be skipped"
);
assert!(
!is_binary_or_library(Path::new("target/debug/foo.pdb")),
"PDB files should be skipped"
);
}
#[test]
fn test_categorize_path_binaries() {
let target_dir = PathBuf::from("/project/target/debug");
let config_files = vec![
PathBuf::from("/project/jonesy.toml"),
PathBuf::from("/project/Cargo.toml"),
];
let evt = categorize_path(
Path::new("/project/target/debug/myapp"),
&target_dir,
&config_files,
);
assert!(
matches!(evt, Some(WatchEvent::BinaryChanged(_))),
"Binary should trigger BinaryChanged"
);
let evt = categorize_path(
Path::new("/project/target/debug/libfoo.rlib"),
&target_dir,
&config_files,
);
assert!(
matches!(evt, Some(WatchEvent::BinaryChanged(_))),
"Rust library should trigger BinaryChanged"
);
let evt = categorize_path(
Path::new("/project/target/debug/libfoo.dylib"),
&target_dir,
&config_files,
);
assert!(
matches!(evt, Some(WatchEvent::BinaryChanged(_))),
"Dynamic library should trigger BinaryChanged"
);
let evt = categorize_path(
Path::new("/project/target/debug/libfoo.so"),
&target_dir,
&config_files,
);
assert!(
matches!(evt, Some(WatchEvent::BinaryChanged(_))),
"Shared object should trigger BinaryChanged"
);
let evt = categorize_path(
Path::new("/project/target/debug/libfoo.a"),
&target_dir,
&config_files,
);
assert!(
matches!(evt, Some(WatchEvent::BinaryChanged(_))),
"Static library should trigger BinaryChanged"
);
}
#[test]
fn test_categorize_path_config_files() {
let target_dir = PathBuf::from("/project/target/debug");
let config_files = vec![
PathBuf::from("/project/jonesy.toml"),
PathBuf::from("/project/Cargo.toml"),
PathBuf::from("/project/crate_a/Cargo.toml"),
];
let evt = categorize_path(
Path::new("/project/jonesy.toml"),
&target_dir,
&config_files,
);
assert!(
matches!(evt, Some(WatchEvent::ConfigChanged(_))),
"jonesy.toml should trigger ConfigChanged"
);
let evt = categorize_path(Path::new("/project/Cargo.toml"), &target_dir, &config_files);
assert!(
matches!(evt, Some(WatchEvent::ConfigChanged(_))),
"Workspace Cargo.toml should trigger ConfigChanged"
);
let evt = categorize_path(
Path::new("/project/crate_a/Cargo.toml"),
&target_dir,
&config_files,
);
assert!(
matches!(evt, Some(WatchEvent::ConfigChanged(_))),
"Member Cargo.toml should trigger ConfigChanged"
);
}
#[test]
fn test_watcher_config_default() {
let config = WatcherConfig::default();
assert_eq!(config.target_dir, PathBuf::from("target/debug"));
assert!(config.config_files.is_empty());
assert_eq!(config.debounce, Duration::from_millis(500));
}
#[test]
fn test_start_watching_with_real_directory() {
let temp_dir = std::env::temp_dir().join("jonesy_test_watcher");
let _ = std::fs::create_dir_all(&temp_dir);
let config = WatcherConfig {
target_dir: temp_dir.clone(),
config_files: vec![],
debounce: Duration::from_millis(100),
};
let result = start_watching(config);
assert!(
result.is_ok(),
"Should create watcher for existing directory"
);
drop(result);
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_start_watching_nonexistent_target_dir() {
let config = WatcherConfig {
target_dir: PathBuf::from("/nonexistent/target/debug"),
config_files: vec![],
debounce: Duration::from_millis(100),
};
let _result = start_watching(config);
}
#[test]
fn test_start_watching_with_config_files() {
let temp_dir = std::env::temp_dir().join("jonesy_test_watcher_config");
let _ = std::fs::create_dir_all(&temp_dir);
let config_file = temp_dir.join("jonesy.toml");
std::fs::write(&config_file, "# test").unwrap();
let config = WatcherConfig {
target_dir: temp_dir.clone(),
config_files: vec![config_file],
debounce: Duration::from_millis(100),
};
let result = start_watching(config);
assert!(result.is_ok());
drop(result);
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_is_binary_or_library_exe() {
assert!(is_binary_or_library(Path::new("target/debug/foo.exe")));
}
#[test]
fn test_categorize_path_unrelated() {
let target_dir = PathBuf::from("/project/target/debug");
let config_files = vec![PathBuf::from("/project/jonesy.toml")];
let evt = categorize_path(
Path::new("/project/src/main.rs"),
&target_dir,
&config_files,
);
assert!(evt.is_none(), "Source files should be ignored");
let evt = categorize_path(
Path::new("/other/project/target/debug/myapp"),
&target_dir,
&config_files,
);
assert!(
evt.is_none(),
"Files outside watched target should be ignored"
);
let evt = categorize_path(
Path::new("/project/rustfmt.toml"),
&target_dir,
&config_files,
);
assert!(evt.is_none(), "Non-jonesy config files should be ignored");
let evt = categorize_path(
Path::new("/project/target/debug/foo.d"),
&target_dir,
&config_files,
);
assert!(
evt.is_none(),
"Dependency files in target should be ignored"
);
}
#[tokio::test]
async fn test_debounced_events_single_event() {
let (tx, rx) = mpsc::channel(10);
let mut triggers = debounced_events(rx, Duration::from_millis(50)).await;
tx.send(WatchEvent::BinaryChanged(PathBuf::from(
"target/debug/myapp",
)))
.await
.unwrap();
let result = tokio::time::timeout(Duration::from_millis(200), triggers.recv()).await;
assert!(result.is_ok(), "Should receive trigger after debounce");
assert!(result.unwrap().is_some());
}
#[tokio::test]
async fn test_debounced_events_coalesces_multiple() {
let (tx, rx) = mpsc::channel(10);
let mut triggers = debounced_events(rx, Duration::from_millis(100)).await;
tx.send(WatchEvent::BinaryChanged(PathBuf::from("a")))
.await
.unwrap();
tx.send(WatchEvent::BinaryChanged(PathBuf::from("b")))
.await
.unwrap();
tx.send(WatchEvent::ConfigChanged(PathBuf::from("c")))
.await
.unwrap();
let result = tokio::time::timeout(Duration::from_millis(300), triggers.recv()).await;
assert!(result.is_ok());
assert!(result.unwrap().is_some());
let result2 = tokio::time::timeout(Duration::from_millis(50), triggers.recv()).await;
assert!(result2.is_err(), "Should not get a second trigger");
}
#[tokio::test]
async fn test_debounced_events_error_does_not_trigger() {
let (tx, rx) = mpsc::channel(10);
let mut triggers = debounced_events(rx, Duration::from_millis(50)).await;
tx.send(WatchEvent::Error("test error".to_string()))
.await
.unwrap();
let result = tokio::time::timeout(Duration::from_millis(150), triggers.recv()).await;
assert!(result.is_err(), "Error events should not trigger analysis");
}
#[tokio::test]
async fn test_debounced_events_channel_close() {
let (tx, rx) = mpsc::channel(10);
let mut triggers = debounced_events(rx, Duration::from_millis(50)).await;
drop(tx);
let result = tokio::time::timeout(Duration::from_millis(200), triggers.recv()).await;
assert!(result.is_ok());
assert!(
result.unwrap().is_none(),
"Should get None when channel closes"
);
}
}