use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::fs::File;
use anyhow::{Result, Error};
use std::time::Duration;
use std::time::SystemTime;
use std::io::Write;
use std::io::Read;
#[derive(Debug, Clone)]
pub enum FileEvent {
Created(PathBuf),
Modified(PathBuf),
Deleted(PathBuf),
}
#[derive(Debug, Clone)]
pub struct FileWatcherConfig {
pub poll_interval: Duration,
pub extensions: Vec<String>,
pub ignore_dirs: Vec<String>,
pub max_files: usize,
}
impl Default for FileWatcherConfig {
fn default() -> Self {
Self {
poll_interval: Duration::from_millis(100),
extensions: vec![".js".to_string(), ".ts".to_string(), ".mjs".to_string(),
".cjs".to_string(), ".jsx".to_string(), ".tsx".to_string()],
ignore_dirs: vec!["node_modules".to_string(), ".git".to_string(),
"target".to_string(), "dist".to_string(), "build".to_string()],
max_files: 1000,
}
}
}
pub struct FileWatcher {
paths: Vec<PathBuf>,
config: FileWatcherConfig,
last_modified: Arc<Mutex<HashMap<PathBuf, SystemTime>>>,
event_sender: mpsc::UnboundedSender<FileEvent>,
running: Arc<Mutex<bool>>,
}
impl FileWatcher {
pub fn new(
paths: Vec<PathBuf>,
config: FileWatcherConfig,
event_sender: mpsc::UnboundedSender<FileEvent>,
) -> Self {
Self {
paths,
config,
last_modified: Arc::new(Mutex::new(HashMap::new())),
event_sender,
running: Arc::new(Mutex::new(false)),
}
}
pub async fn start(&self) -> anyhow::Result<()> {
let mut interval = interval(self.config.poll_interval);
let paths: _ = self.paths.clone();
let last_modified: _ = Arc::clone(&self.last_modified);
let event_sender: _ = self.event_sender.clone();
let running: _ = Arc::clone(&self.running);
let config: _ = self.config.clone();
{
let mut modified = last_modified.lock().unwrap();
for path in &paths {
if let Ok(metadata) = std::fs::metadata(path) {
if let Ok(modified_time) = metadata.modified() {
modified.insert(path.clone(), modified_time);
if metadata.is_file() {
let _: _ = event_sender.send(FileEvent::Created(path.clone()));
} else if metadata.is_dir() {
let _: _ = scan_path(path, &last_modified, &event_sender, &config).await;
}
}
}
}
}
tokio::spawn(async move {
*running.lock().unwrap() = true;
loop {
interval.tick().await;
if !*running.lock().unwrap() {
break;
}
for path in &paths {
if let Err(e) = scan_path(path, &last_modified, &event_sender, &config).await {
eprintln!("Error scanning path {:?}: {}", path, e);
}
}
}
});
Ok(())
}
pub async fn stop(&self) -> anyhow::Result<()> {
{
let mut running = self.running.lock().unwrap();
*running = false;
}
Ok(())
}
pub fn is_running(&self) -> bool {
*self.running.lock().unwrap()
}
}
async fn scan_path(
path: &Path,
last_modified: &Arc<Mutex<HashMap<PathBuf, SystemTime>>>,
event_sender: &mpsc::UnboundedSender<FileEvent>,
config: &FileWatcherConfig,
) -> anyhow::Result<()> {
let metadata: _ = std::fs::metadata(path)?;
if metadata.is_file() {
scan_file(path, last_modified, event_sender).await?;
} else if metadata.is_dir() {
scan_directory(path, last_modified, event_sender, config).await?;
}
Ok(())
}
async fn scan_file(
path: &Path,
last_modified: &Arc<Mutex<HashMap<PathBuf, SystemTime>>>,
event_sender: &mpsc::UnboundedSender<FileEvent>,
) -> anyhow::Result<()> {
let extension: _ = path.extension()
.and_then(|ext| ext.to_str())
.map(|ext| format!(".{}", ext));
let should_watch: _ = extension.map(|ext| {
config_file_extensions().contains(&ext.as_str())
}).unwrap_or(false);
if !should_watch {
return Ok(());
}
let metadata: _ = std::fs::metadata(path)?;
let current_modified: _ = metadata.modified()?;
let mut modified = last_modified.lock().unwrap();
match modified.get(path) {
Some(&last_time) => {
if current_modified > last_time {
event_sender.send(FileEvent::Modified(path.to_path_buf()))
.map_err(|e| anyhow::anyhow!("Failed to send event: {}", e))?;
modified.insert(path.to_path_buf(), current_modified);
}
}
None => {
event_sender.send(FileEvent::Created(path.to_path_buf()))
.map_err(|e| anyhow::anyhow!("Failed to send event: {}", e))?;
modified.insert(path.to_path_buf(), current_modified);
}
}
Ok(())
}
async fn scan_directory(
dir: &Path,
last_modified: &Arc<Mutex<HashMap<PathBuf, SystemTime>>>,
event_sender: &mpsc::UnboundedSender<FileEvent>,
config: &FileWatcherConfig,
) -> anyhow::Result<()> {
let dir_name: _ = dir.file_name()
.and_then(|name| name.to_str())
.unwrap_or("");
if config.ignore_dirs.contains(&dir_name.to_string()) {
return Ok(());
}
let entries: _ = std::fs::read_dir(dir)?;
for entry in entries {
let entry: _ = entry?;
let path: _ = entry.path();
{
let modified: _ = last_modified.lock().unwrap();
if modified.len() >= config.max_files {
break;
}
}
if let Ok(metadata) = entry.metadata() {
if metadata.is_file() {
scan_file(&path, last_modified, event_sender).await?;
} else if metadata.is_dir() {
Box::pin(scan_directory(&path, last_modified, event_sender, config)).await?;
}
}
}
Ok(())
}
fn config_file_extensions() -> &'static [&'static str] {
&["js", "ts", "mjs", "cjs", "jsx", "tsx"]
}
pub async fn create_file_watcher(
paths: Vec<PathBuf>,
) -> anyhow::Result<(FileWatcher, mpsc::UnboundedReceiver<FileEvent>)> {
let config: _ = FileWatcherConfig::default();
let (event_sender, event_receiver) = mpsc::unbounded_channel();
let watcher: _ = FileWatcher::new(paths, config, event_sender);
watcher.start().await?;
Ok((watcher, event_receiver))
}
#[cfg(test)]
mod tests {
#[tokio::test]
async fn test_file_watcher_basic() {
let temp_dir: _ = tempdir().expect("Failed to create temp dir");
let test_file: _ = temp_dir.path().join("test.js");
std::fs::write(&test_file, "console.log('initial')")
.expect("Failed to write test file");
sleep(Duration::from_millis(50)).await;
let (mut watcher, _event_receiver) = create_file_watcher(vec![test_file.clone()])
.await
.expect("Failed to create watcher");
sleep(Duration::from_millis(200)).await;
assert!(watcher.is_running(), "Watcher should be running after start");
watcher.stop().await.expect("Failed to stop watcher");
sleep(Duration::from_millis(100)).await;
assert!(!watcher.is_running(), "Watcher should be stopped");
temp_dir.close().expect("Failed to close temp dir");
}
#[tokio::test]
async fn test_file_watcher_ignore_directories() {
let temp_dir: _ = tempdir().expect("Failed to create temp dir");
let src_file: _ = temp_dir.path().join("src").join("test.js");
let node_modules_file: _ = temp_dir.path().join("node_modules").join("test.js");
std::fs::create_dir_all(src_file.parent().unwrap())
.expect("Failed to create src dir");
std::fs::create_dir_all(node_modules_file.parent().unwrap())
.expect("Failed to create node_modules dir");
std::fs::write(&src_file, "console.log('src')")
.expect("Failed to write src file");
std::fs::write(&node_modules_file, "console.log('node_modules')")
.expect("Failed to write node_modules file");
let (mut watcher, mut event_receiver) = create_file_watcher(vec![temp_dir.path().to_path_buf()])
.await
.expect("Failed to create watcher");
sleep(Duration::from_millis(200)).await;
let mut received_files = Vec::new();
while let Ok(event) = event_receiver.try_recv() {
match event {
FileEvent::Created(path) | FileEvent::Modified(path) => {
received_files.push(path);
}
_ => {}
}
}
println!("Received files: {:?}", received_files);
println!("Expected src file: {:?}", src_file);
println!("Expected to ignore: {:?}", node_modules_file);
assert!(received_files.contains(&src_file), "src file should be tracked");
assert!(!received_files.contains(&node_modules_file), "node_modules file should be ignored");
watcher.stop().await.expect("Failed to stop watcher");
temp_dir.close().expect("Failed to close temp dir");
}
}