use std::collections::{HashMap, HashSet};
use std::ffi::CString;
use std::fs;
use std::io::{BufRead, BufReader, Result};
use std::os::unix::ffi::OsStrExt;
use std::os::unix::io::AsRawFd;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::io::unix::AsyncFd;
#[derive(Debug, Clone)]
struct GlobPattern {
pattern: String,
}
impl PartialEq for GlobPattern {
fn eq(&self, other: &Self) -> bool {
self.pattern == other.pattern
}
}
impl GlobPattern {
fn new(pattern: &str) -> Self {
Self {
pattern: pattern.to_string(),
}
}
fn matches(&self, text: &str) -> bool {
Self::match_recursive(self.pattern.as_bytes(), text.as_bytes())
}
fn match_recursive(pattern: &[u8], text: &[u8]) -> bool {
let mut p = 0;
let mut t = 0;
let mut star_p = None;
let mut star_t = None;
while t < text.len() {
if p < pattern.len() {
match pattern[p] {
b'*' => {
star_p = Some(p);
star_t = Some(t);
p += 1;
continue;
}
b'?' => {
p += 1;
t += 1;
continue;
}
b'[' => {
if let Some((matched, end_pos)) =
Self::match_char_class(&pattern[p..], text[t])
{
if matched {
p += end_pos;
t += 1;
continue;
}
}
}
c => {
if c == text[t] {
p += 1;
t += 1;
continue;
}
}
}
}
if let (Some(sp), Some(st)) = (star_p, star_t) {
p = sp + 1;
star_t = Some(st + 1);
t = st + 1;
} else {
return false;
}
}
while p < pattern.len() && pattern[p] == b'*' {
p += 1;
}
p == pattern.len()
}
fn match_char_class(pattern: &[u8], ch: u8) -> Option<(bool, usize)> {
if pattern.is_empty() || pattern[0] != b'[' {
return None;
}
let mut i = 1;
let mut matched = false;
let negated = i < pattern.len() && (pattern[i] == b'!' || pattern[i] == b'^');
if negated {
i += 1;
}
while i < pattern.len() {
if pattern[i] == b']' && i > 1 + (negated as usize) {
return Some((matched != negated, i + 1));
}
if i + 2 < pattern.len() && pattern[i + 1] == b'-' && pattern[i + 2] != b']' {
let start = pattern[i];
let end = pattern[i + 2];
if ch >= start && ch <= end {
matched = true;
}
i += 3;
} else {
if pattern[i] == ch {
matched = true;
}
i += 1;
}
}
None
}
}
#[derive(Debug, Clone, PartialEq)]
enum Segment {
Exact(String),
Wildcard(GlobPattern),
DoubleWildcard, }
#[derive(Debug, Clone)]
struct Pattern {
segments: Vec<Segment>,
}
impl Pattern {
fn parse(pattern: &str) -> Self {
let mut segments = Vec::new();
let effective_pattern = if !pattern.contains('/') {
format!("**/{}", pattern)
} else {
pattern.trim_start_matches('/').to_string()
};
let normalized = effective_pattern.replace("//", "/");
for part in normalized.split('/') {
if part.is_empty() || part == "." {
continue;
}
if part == "**" {
segments.push(Segment::DoubleWildcard);
} else if part.contains('*') || part.contains('?') || part.contains('[') {
segments.push(Segment::Wildcard(GlobPattern::new(part)));
} else {
segments.push(Segment::Exact(part.to_string()));
}
}
Pattern { segments }
}
fn check(&self, path_segments: &[String], allow_prefix: bool) -> bool {
let pattern_segments = &self.segments;
let mut path_index = 0;
for pattern_index in 0..pattern_segments.len() {
let pattern_segment = &pattern_segments[pattern_index];
if path_index >= path_segments.len() {
if pattern_segment == &Segment::DoubleWildcard && pattern_index == pattern_segments.len() - 1
{
return true;
}
return allow_prefix;
}
match &pattern_segment {
Segment::Exact(s) => {
if s != &path_segments[path_index] {
return false;
}
path_index += 1;
}
Segment::Wildcard(p) => {
if !p.matches(&path_segments[path_index]) {
return false;
}
path_index += 1;
}
Segment::DoubleWildcard => {
if allow_prefix {
return true;
}
let patterns_left = pattern_segments.len() - (pattern_index + 1);
let next_path_index = path_segments.len() - patterns_left;
if next_path_index < path_index {
return false;
}
path_index = next_path_index;
}
}
}
if path_index < path_segments.len() {
return false;
}
return !allow_prefix;
}
}
struct Inotify {
fd: AsyncFd<i32>,
}
impl Inotify {
fn new() -> Result<Self> {
let fd = unsafe { libc::inotify_init1(libc::IN_NONBLOCK | libc::IN_CLOEXEC) };
if fd < 0 {
return Err(std::io::Error::last_os_error());
}
Ok(Self {
fd: AsyncFd::new(fd)?,
})
}
fn add_watch(&self, path: &Path, mask: u32) -> Result<i32> {
let c_path = CString::new(path.as_os_str().as_bytes())
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
let wd = unsafe { libc::inotify_add_watch(self.fd.as_raw_fd(), c_path.as_ptr(), mask) };
if wd < 0 {
return Err(std::io::Error::last_os_error());
}
Ok(wd)
}
async fn read_events(&self, buffer: &mut [u8]) -> Result<usize> {
loop {
let mut guard = self.fd.readable().await?;
match guard.try_io(|inner| {
let res = unsafe {
libc::read(
inner.as_raw_fd(),
buffer.as_mut_ptr() as *mut _,
buffer.len(),
)
};
if res < 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(res as usize)
}
}) {
Ok(Ok(len)) => return Ok(len),
Ok(Err(e)) => {
if e.kind() == std::io::ErrorKind::WouldBlock {
continue;
}
return Err(e);
}
Err(_) => continue,
}
}
}
}
impl Drop for Inotify {
fn drop(&mut self) {
unsafe { libc::close(self.fd.as_raw_fd()) };
}
}
fn path_to_segments(path: &Path) -> Vec<String> {
let path_str = path.to_string_lossy();
let path_str = path_str.replace("//", "/");
path_str
.split('/')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect()
}
const INOTIFY_MASK: u32 = libc::IN_MODIFY
| libc::IN_CLOSE_WRITE
| libc::IN_CREATE
| libc::IN_DELETE
| libc::IN_MOVED_FROM
| libc::IN_MOVED_TO
| libc::IN_DONT_FOLLOW;
fn parse_inotify_events(buffer: &[u8], len: usize) -> Vec<(i32, u32, String)> {
let mut events = Vec::new();
let mut ptr = buffer.as_ptr();
let end = unsafe { ptr.add(len) };
while ptr < end {
let event = unsafe { &*(ptr as *const libc::inotify_event) };
let name_len = event.len as usize;
if name_len > 0 {
let name_ptr = unsafe { ptr.add(std::mem::size_of::<libc::inotify_event>()) };
let name_slice =
unsafe { std::slice::from_raw_parts(name_ptr as *const u8, name_len) };
let name_str = String::from_utf8_lossy(name_slice)
.trim_matches(char::from(0))
.to_string();
events.push((event.wd, event.mask, name_str));
}
ptr = unsafe { ptr.add(std::mem::size_of::<libc::inotify_event>() + name_len) };
}
events
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WatchEvent {
Create,
Delete,
Update,
Initial,
DebugWatch,
}
pub struct Watcher {
includes: Vec<String>,
excludes: Vec<String>,
base_dir: PathBuf,
watch_create: bool,
watch_delete: bool,
watch_update: bool,
watch_initial: bool,
match_files: bool,
match_dirs: bool,
return_absolute: bool,
debug_watches_enabled: bool,
}
#[deprecated(since = "0.1.2", note = "Renamed to Watcher")]
pub type WatchBuilder = Watcher;
impl Default for Watcher {
fn default() -> Self {
Self::new()
}
}
struct WatcherState<F> {
root: PathBuf,
inotify: Inotify,
watches: HashMap<i32, PathBuf>,
paths: HashSet<PathBuf>,
include_patterns: Vec<Pattern>,
exclude_patterns: Vec<Pattern>,
callback: F,
}
impl Watcher {
pub fn new() -> Self {
Watcher {
includes: Vec::new(),
excludes: Vec::new(),
base_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")),
watch_create: true,
watch_delete: true,
watch_update: true,
watch_initial: false,
match_files: true,
match_dirs: true,
return_absolute: false,
debug_watches_enabled: false,
}
}
pub fn debug_watches(mut self, enabled: bool) -> Self {
self.debug_watches_enabled = enabled;
self
}
pub fn add_include(mut self, pattern: impl Into<String>) -> Self {
self.includes.push(pattern.into());
self
}
pub fn add_includes(mut self, patterns: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.includes.extend(patterns.into_iter().map(|p| p.into()));
self
}
pub fn add_exclude(mut self, pattern: impl Into<String>) -> Self {
self.excludes.push(pattern.into());
self
}
pub fn add_excludes(mut self, patterns: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.excludes.extend(patterns.into_iter().map(|p| p.into()));
self
}
pub fn add_ignore_file(mut self, path: impl AsRef<Path>) -> Self {
let path = path.as_ref();
let full_path = if path.is_absolute() {
path.to_path_buf()
} else {
self.base_dir.join(path)
};
if let Ok(file) = fs::File::open(&full_path) {
let reader = BufReader::new(file);
let mut has_negation = false;
for line in reader.lines().map_while(Result::ok) {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if trimmed.starts_with('!') {
has_negation = true;
} else {
self.excludes.push(trimmed.to_string());
}
}
if has_negation {
println!("Warning: negation patterns (!) in {} are ignored; excludes always take precedence over includes in this library", full_path.display());
}
}
self
}
pub fn set_base_dir(mut self, base_dir: impl Into<PathBuf>) -> Self {
self.base_dir = base_dir.into();
self
}
pub fn watch_create(mut self, enabled: bool) -> Self {
self.watch_create = enabled;
self
}
pub fn watch_delete(mut self, enabled: bool) -> Self {
self.watch_delete = enabled;
self
}
pub fn watch_update(mut self, enabled: bool) -> Self {
self.watch_update = enabled;
self
}
pub fn watch_initial(mut self, enabled: bool) -> Self {
self.watch_initial = enabled;
self
}
pub fn match_files(mut self, enabled: bool) -> Self {
self.match_files = enabled;
self
}
pub fn match_dirs(mut self, enabled: bool) -> Self {
self.match_dirs = enabled;
self
}
pub fn return_absolute(mut self, enabled: bool) -> Self {
self.return_absolute = enabled;
self
}
pub async fn run<F>(self, callback: F) -> Result<()>
where
F: FnMut(WatchEvent, PathBuf),
{
self.run_internal(callback, None).await
}
pub async fn run_debounced<F>(self, ms: u64, mut callback: F) -> Result<()>
where
F: FnMut(PathBuf),
{
self.run_internal(|_, path| callback(path), Some(Duration::from_millis(ms))).await
}
fn should_watch<F>(&self, state: &WatcherState<F>, relative_path: &Path, is_dir: bool) -> bool {
let segments = path_to_segments(relative_path);
if state.exclude_patterns.iter().any(|p| p.check(&segments, false)) {
return false;
}
state.include_patterns.iter().any(|p| p.check(&segments, is_dir))
}
fn check_event<F>(&self, state: &WatcherState<F>, rel_path: &Path, is_dir: bool) -> bool {
if if is_dir { !self.match_dirs } else { !self.match_files } {
return false;
}
self.should_watch(state, rel_path, false)
}
fn emit_event<F>(
&self,
state: &mut WatcherState<F>,
event: WatchEvent,
rel_path: &Path,
) where
F: FnMut(WatchEvent, PathBuf),
{
let path = if self.return_absolute {
if rel_path.as_os_str().is_empty() {
state.root.clone()
} else {
state.root.join(rel_path)
}
} else {
rel_path.to_path_buf()
};
(state.callback)(event, path);
}
fn add_watch_recursive<F>(
&self,
state: &mut WatcherState<F>,
initial_path: PathBuf,
emit_initial: bool,
) where
F: FnMut(WatchEvent, PathBuf),
{
if state.paths.contains(&initial_path) {
return;
}
let mut stack = vec![initial_path];
while let Some(rel_path) = stack.pop() {
if !self.should_watch(state, &rel_path, true) {
continue;
}
let full_path = if rel_path.as_os_str().is_empty() {
state.root.clone()
} else {
state.root.join(&rel_path)
};
if !full_path.is_dir() {
continue;
}
let wd = match state.inotify.add_watch(&full_path, INOTIFY_MASK) {
Ok(wd) => wd,
Err(e) => {
eprintln!("Failed to add watch for {:?}: {}", full_path, e);
continue;
}
};
state.paths.insert(rel_path.clone());
state.watches.insert(wd, rel_path.clone());
if self.debug_watches_enabled {
(state.callback)(WatchEvent::DebugWatch, rel_path.clone());
}
if let Ok(entries) = std::fs::read_dir(&full_path) {
for entry in entries.flatten() {
if let Ok(ft) = entry.file_type() {
let child_rel_path = rel_path.join(entry.file_name());
let is_dir = ft.is_dir();
if emit_initial && self.check_event(state, &child_rel_path, is_dir) {
self.emit_event(state, WatchEvent::Initial, &child_rel_path);
}
if is_dir && !state.paths.contains(&child_rel_path) {
stack.push(child_rel_path);
}
}
}
}
}
}
async fn run_internal<F>(self, callback: F, debounce: Option<Duration>) -> Result<()>
where
F: FnMut(WatchEvent, PathBuf),
{
let includes = if self.includes.is_empty() {
vec!["**".to_string()]
} else {
self.includes.clone()
};
if includes.is_empty() {
loop {
tokio::time::sleep(Duration::from_secs(3600)).await;
}
}
let root = if self.base_dir.is_absolute() {
self.base_dir.clone()
} else {
std::env::current_dir()
.unwrap_or_else(|_| PathBuf::from("/"))
.join(&self.base_dir)
};
let mut state = WatcherState {
root,
inotify: Inotify::new()?,
watches: HashMap::new(),
paths: HashSet::new(),
include_patterns: includes.iter().map(|p| Pattern::parse(p)).collect(),
exclude_patterns: self.excludes.iter().map(|p| Pattern::parse(p)).collect(),
callback,
};
let emit_initial = self.watch_initial && debounce.is_none();
self.add_watch_recursive(&mut state, PathBuf::new(), emit_initial);
let mut debounce_deadline: Option<tokio::time::Instant> = None;
let mut debounce_first_path: Option<PathBuf> = None;
let mut buffer = [0u8; 8192];
loop {
let read_future = state.inotify.read_events(&mut buffer);
let read_result = if let Some(deadline) = debounce_deadline {
let now = tokio::time::Instant::now();
if deadline <= now {
debounce_deadline = None;
(state.callback)(WatchEvent::Update, debounce_first_path.take().unwrap_or_default());
continue;
}
match tokio::time::timeout(deadline - now, read_future).await {
Ok(result) => Some(result),
Err(_) => {
debounce_deadline = None;
(state.callback)(WatchEvent::Update, debounce_first_path.take().unwrap_or_default());
continue;
}
}
} else {
Some(read_future.await)
};
let Some(result) = read_result else { continue };
match result {
Ok(len) => {
let events = parse_inotify_events(&buffer, len);
let mut first_matching_path: Option<PathBuf> = None;
for (wd, mask, name_str) in events {
if (mask & libc::IN_IGNORED as u32) != 0 {
if let Some(path) = state.watches.remove(&wd) {
state.paths.remove(&path);
}
continue;
}
let rel_path = if let Some(dir_path) = state.watches.get(&wd) {
dir_path.join(&name_str)
} else {
println!("Warning: received event for unknown watch descriptor {}", wd);
continue;
};
let is_dir = mask & libc::IN_ISDIR as u32 != 0;
let is_create = (mask & libc::IN_CREATE as u32) != 0
|| (mask & libc::IN_MOVED_TO as u32) != 0;
let is_delete = (mask & libc::IN_DELETE as u32) != 0
|| (mask & libc::IN_MOVED_FROM as u32) != 0;
let is_update = (mask & libc::IN_MODIFY as u32) != 0
|| (mask & libc::IN_CLOSE_WRITE as u32) != 0;
if is_dir && is_create {
self.add_watch_recursive(&mut state, rel_path.clone(), false);
}
let event_type = if is_create && self.watch_create {
WatchEvent::Create
} else if is_delete && self.watch_delete {
WatchEvent::Delete
} else if is_update && self.watch_update {
WatchEvent::Update
} else {
continue
};
if !self.check_event(&state, &rel_path, is_dir) {
continue;
}
if first_matching_path.is_none() {
first_matching_path = Some(rel_path.clone());
}
if debounce.is_none() {
self.emit_event(&mut state, event_type, &rel_path);
}
}
if let Some(d) = debounce {
if let Some(path) = first_matching_path {
if debounce_first_path.is_none() {
debounce_first_path = Some(path);
}
debounce_deadline = Some(tokio::time::Instant::now() + d);
}
}
}
Err(e) => {
eprintln!("Error reading inotify events: {}", e);
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use tokio::task::JoinHandle;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum EventType {
Create,
Delete,
Update,
Initial,
DebugWatch,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct Event {
path: PathBuf,
event_type: EventType,
}
type EventTracker = Arc<Mutex<Vec<Event>>>;
struct TestInstance {
test_dir: PathBuf,
tracker: EventTracker,
watcher_handle: Option<JoinHandle<()>>,
}
impl TestInstance {
async fn new<F>(test_name: &str, configure: F) -> Self
where
F: FnOnce(Watcher) -> Watcher + Send + 'static,
{
let test_dir = std::env::current_dir()
.unwrap()
.join(format!(".file-watcher-test-{}", test_name));
if test_dir.exists() {
std::fs::remove_dir_all(&test_dir).unwrap();
}
std::fs::create_dir(&test_dir).unwrap();
let tracker = Arc::new(Mutex::new(Vec::new()));
let tracker_clone = tracker.clone();
let test_dir_clone = test_dir.clone();
let watcher_handle = tokio::spawn(async move {
let watcher = Watcher::new()
.set_base_dir(&test_dir_clone)
.debug_watches(true);
let watcher = configure(watcher);
let _ = watcher
.run(move |event_type, path| {
tracker_clone.lock().unwrap().push(Event {
path: path.clone(),
event_type: match event_type {
WatchEvent::Create => EventType::Create,
WatchEvent::Delete => EventType::Delete,
WatchEvent::Update => EventType::Update,
WatchEvent::Initial => EventType::Initial,
WatchEvent::DebugWatch => EventType::DebugWatch,
},
});
})
.await;
});
tokio::time::sleep(Duration::from_millis(100)).await;
let instance = Self {
test_dir,
tracker,
watcher_handle: Some(watcher_handle),
};
instance.assert_events(&[], &[], &[], &[""]).await;
instance
}
fn create_dir(&self, path: &str) {
std::fs::create_dir_all(self.test_dir.join(path)).unwrap();
}
fn write_file(&self, path: &str, content: &str) {
let full_path = self.test_dir.join(path);
if let Some(parent) = full_path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(full_path, content).unwrap();
}
fn remove_file(&self, path: &str) {
std::fs::remove_file(self.test_dir.join(path)).unwrap();
}
fn rename(&self, from: &str, to: &str) {
std::fs::rename(self.test_dir.join(from), self.test_dir.join(to)).unwrap();
}
async fn assert_events(
&self,
creates: &[&str],
deletes: &[&str],
updates: &[&str],
watches: &[&str],
) {
tokio::time::sleep(Duration::from_millis(200)).await;
let events = self.tracker.lock().unwrap().clone();
let mut expected = HashSet::new();
for create in creates {
expected.insert(Event {
path: PathBuf::from(create),
event_type: EventType::Create,
});
}
for delete in deletes {
expected.insert(Event {
path: PathBuf::from(delete),
event_type: EventType::Delete,
});
}
for update in updates {
expected.insert(Event {
path: PathBuf::from(update),
event_type: EventType::Update,
});
}
for watch in watches {
expected.insert(Event {
path: PathBuf::from(watch),
event_type: EventType::DebugWatch,
});
}
let actual: HashSet<Event> = events.iter().cloned().collect();
for event in &actual {
if !expected.contains(event) {
panic!("Unexpected event: {:?}", event);
}
}
for event in &expected {
if !actual.contains(event) {
panic!(
"Missing expected event: {:?}\nActual events: {:?}",
event, actual
);
}
}
self.tracker.lock().unwrap().clear();
}
async fn assert_no_events(&self) {
tokio::time::sleep(Duration::from_millis(500)).await;
let events = self.tracker.lock().unwrap();
assert_eq!(
events.len(),
0,
"Expected no events, but got: {:?}",
events
);
}
}
impl Drop for TestInstance {
fn drop(&mut self) {
if let Some(handle) = self.watcher_handle.take() {
handle.abort();
}
if self.test_dir.exists() {
let _ = std::fs::remove_dir_all(&self.test_dir);
}
}
}
#[tokio::test]
async fn test_file_create_update_delete() {
let test = TestInstance::new("create_update_delete", |b| b.add_include("**/*")).await;
test.write_file("test.txt", "");
test.assert_events(&["test.txt"], &[], &["test.txt"], &[])
.await;
test.write_file("test.txt", "hello");
test.assert_events(&[], &[], &["test.txt"], &[]).await;
test.remove_file("test.txt");
test.assert_events(&[], &["test.txt"], &[], &[]).await;
}
#[tokio::test]
async fn test_directory_operations() {
let test = TestInstance::new("directory_operations", |b| b.add_include("**/*")).await;
test.create_dir("subdir");
test.assert_events(&["subdir"], &[], &[], &["subdir"]).await;
test.write_file("subdir/file.txt", "");
test.assert_events(&["subdir/file.txt"], &[], &["subdir/file.txt"], &[])
.await;
}
#[tokio::test]
async fn test_move_operations() {
let test = TestInstance::new("move_operations", |b| b.add_include("**/*")).await;
test.write_file("old.txt", "content");
test.assert_events(&["old.txt"], &[], &["old.txt"], &[])
.await;
test.rename("old.txt", "new.txt");
test.assert_events(&["new.txt"], &["old.txt"], &[], &[])
.await;
}
#[tokio::test]
async fn test_event_filtering() {
let test = TestInstance::new("event_filtering", |b| {
b.add_include("**/*")
.watch_create(true)
.watch_delete(false)
.watch_update(false)
})
.await;
test.write_file("test.txt", "");
test.assert_events(&["test.txt"], &[], &[], &[]).await;
test.write_file("test.txt", "hello");
test.assert_no_events().await;
test.remove_file("test.txt");
test.assert_no_events().await;
}
#[tokio::test]
async fn test_pattern_matching() {
let test = TestInstance::new("pattern_matching", |b| b.add_include("**/*.txt")).await;
test.write_file("test.txt", "");
test.assert_events(&["test.txt"], &[], &["test.txt"], &[])
.await;
test.write_file("test.rs", "");
test.assert_no_events().await;
}
#[tokio::test]
async fn test_matching_stops_at_depth() {
let test = TestInstance::new("matching_stops_at_depth", |b| b.add_include("*/xyz/*.*")).await;
test.write_file("test.txt", "");
test.assert_no_events().await;
test.create_dir("abc/xyz");
test.assert_events(&[], &[], &[], &["abc", "abc/xyz"]).await;
test.create_dir("abc/hjk/a.b");
test.assert_no_events().await;
test.create_dir("abc/xyz/a.b");
test.assert_events(&["abc/xyz/a.b"], &[], &[], &[]).await;
test.create_dir("abc/xyz/a.b/x.y");
test.assert_events(&[], &[], &[], &[]).await;
}
#[tokio::test]
async fn test_exclude_prevents_watching() {
let test = TestInstance::new("exclude_prevents_watch", |b| {
b.add_include("**/*").add_exclude("node_modules/**")
})
.await;
test.create_dir("node_modules");
tokio::time::sleep(Duration::from_millis(200)).await;
test.write_file("node_modules/package.json", "");
test.assert_no_events().await;
test.write_file("test.txt", "");
test.assert_events(&["test.txt"], &[], &["test.txt"], &[])
.await;
}
#[tokio::test]
async fn test_pattern_file() {
let test_dir = std::env::current_dir()
.unwrap()
.join(".file-watcher-test-pattern_file");
if test_dir.exists() {
std::fs::remove_dir_all(&test_dir).unwrap();
}
std::fs::create_dir(&test_dir).unwrap();
std::fs::write(
test_dir.join(".watchignore"),
"# Comment line\nignored/**\n",
)
.unwrap();
let tracker = Arc::new(Mutex::new(Vec::<Event>::new()));
let tracker_clone = tracker.clone();
let test_dir_clone = test_dir.clone();
let watcher_handle = tokio::spawn(async move {
let _ = Watcher::new()
.set_base_dir(&test_dir_clone)
.debug_watches(true)
.add_include("**/*")
.add_ignore_file(".watchignore")
.run(move |event_type, path| {
tracker_clone.lock().unwrap().push(Event {
path: path.clone(),
event_type: match event_type {
WatchEvent::Create => EventType::Create,
WatchEvent::Delete => EventType::Delete,
WatchEvent::Update => EventType::Update,
WatchEvent::Initial => EventType::Initial,
WatchEvent::DebugWatch => EventType::DebugWatch,
},
});
})
.await;
});
tokio::time::sleep(Duration::from_millis(100)).await;
tracker.lock().unwrap().clear();
std::fs::create_dir(test_dir.join("ignored")).unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
std::fs::write(test_dir.join("ignored/test.txt"), "").unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
{
let events = tracker.lock().unwrap();
let has_ignored_events = events.iter().any(|e| {
e.path.to_string_lossy().contains("ignored")
&& e.event_type != EventType::DebugWatch
});
assert!(
!has_ignored_events,
"Expected no events for ignored files, but got: {:?}",
events
);
}
tracker.lock().unwrap().clear();
std::fs::write(test_dir.join("normal.txt"), "").unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
{
let events = tracker.lock().unwrap();
let has_normal = events
.iter()
.any(|e| e.path == PathBuf::from("normal.txt"));
assert!(has_normal, "Expected event for normal.txt, got: {:?}", events);
}
watcher_handle.abort();
let _ = std::fs::remove_dir_all(&test_dir);
}
#[tokio::test]
async fn test_watch_initial() {
let test_dir = std::env::current_dir()
.unwrap()
.join(".file-watcher-test-watch_initial");
if test_dir.exists() {
std::fs::remove_dir_all(&test_dir).unwrap();
}
std::fs::create_dir(&test_dir).unwrap();
std::fs::write(test_dir.join("existing1.txt"), "content1").unwrap();
std::fs::write(test_dir.join("existing2.txt"), "content2").unwrap();
std::fs::create_dir(test_dir.join("subdir")).unwrap();
std::fs::write(test_dir.join("subdir/nested.txt"), "nested").unwrap();
std::fs::write(test_dir.join("ignored.rs"), "should be ignored").unwrap();
let tracker = Arc::new(Mutex::new(Vec::<Event>::new()));
let tracker_clone = tracker.clone();
let test_dir_clone = test_dir.clone();
let watcher_handle = tokio::spawn(async move {
let _ = Watcher::new()
.set_base_dir(&test_dir_clone)
.add_include("**/*.txt")
.watch_initial(true)
.run(move |event_type, path| {
tracker_clone.lock().unwrap().push(Event {
path: path.clone(),
event_type: match event_type {
WatchEvent::Create => EventType::Create,
WatchEvent::Delete => EventType::Delete,
WatchEvent::Update => EventType::Update,
WatchEvent::Initial => EventType::Initial,
WatchEvent::DebugWatch => EventType::DebugWatch,
},
});
})
.await;
});
tokio::time::sleep(Duration::from_millis(200)).await;
{
let events = tracker.lock().unwrap();
let initial_events: Vec<_> = events
.iter()
.filter(|e| e.event_type == EventType::Initial)
.collect();
assert_eq!(
initial_events.len(),
3,
"Expected 3 Initial events, got: {:?}",
initial_events
);
let paths: HashSet<_> = initial_events.iter().map(|e| e.path.clone()).collect();
assert!(paths.contains(&PathBuf::from("existing1.txt")));
assert!(paths.contains(&PathBuf::from("existing2.txt")));
assert!(paths.contains(&PathBuf::from("subdir/nested.txt")));
assert!(!events.iter().any(|e| e.path.to_string_lossy().contains("ignored.rs")));
}
tracker.lock().unwrap().clear();
std::fs::write(test_dir.join("new.txt"), "new content").unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
{
let events = tracker.lock().unwrap();
let has_create = events
.iter()
.any(|e| e.path == PathBuf::from("new.txt") && e.event_type == EventType::Create);
assert!(has_create, "Expected Create event for new.txt, got: {:?}", events);
}
watcher_handle.abort();
let _ = std::fs::remove_dir_all(&test_dir);
}
#[tokio::test]
async fn test_watch_initial_with_dirs() {
let test_dir = std::env::current_dir()
.unwrap()
.join(".file-watcher-test-watch_initial_dirs");
if test_dir.exists() {
std::fs::remove_dir_all(&test_dir).unwrap();
}
std::fs::create_dir(&test_dir).unwrap();
std::fs::write(test_dir.join("file.txt"), "content").unwrap();
std::fs::create_dir(test_dir.join("mydir")).unwrap();
let tracker = Arc::new(Mutex::new(Vec::<Event>::new()));
let tracker_clone = tracker.clone();
let test_dir_clone = test_dir.clone();
let watcher_handle = tokio::spawn(async move {
let _ = Watcher::new()
.set_base_dir(&test_dir_clone)
.add_include("**/*")
.watch_initial(true)
.match_files(true)
.match_dirs(false) .run(move |event_type, path| {
tracker_clone.lock().unwrap().push(Event {
path: path.clone(),
event_type: match event_type {
WatchEvent::Create => EventType::Create,
WatchEvent::Delete => EventType::Delete,
WatchEvent::Update => EventType::Update,
WatchEvent::Initial => EventType::Initial,
WatchEvent::DebugWatch => EventType::DebugWatch,
},
});
})
.await;
});
tokio::time::sleep(Duration::from_millis(200)).await;
{
let events = tracker.lock().unwrap();
let initial_events: Vec<_> = events
.iter()
.filter(|e| e.event_type == EventType::Initial)
.collect();
assert_eq!(
initial_events.len(),
1,
"Expected 1 Initial event (file only), got: {:?}",
initial_events
);
assert_eq!(initial_events[0].path, PathBuf::from("file.txt"));
}
watcher_handle.abort();
let _ = std::fs::remove_dir_all(&test_dir);
}
#[tokio::test]
async fn test_watch_initial_disabled_by_default() {
let test_dir = std::env::current_dir()
.unwrap()
.join(".file-watcher-test-watch_initial_disabled");
if test_dir.exists() {
std::fs::remove_dir_all(&test_dir).unwrap();
}
std::fs::create_dir(&test_dir).unwrap();
std::fs::write(test_dir.join("existing.txt"), "content").unwrap();
let tracker = Arc::new(Mutex::new(Vec::<Event>::new()));
let tracker_clone = tracker.clone();
let test_dir_clone = test_dir.clone();
let watcher_handle = tokio::spawn(async move {
let _ = Watcher::new()
.set_base_dir(&test_dir_clone)
.add_include("**/*.txt")
.run(move |event_type, path| {
tracker_clone.lock().unwrap().push(Event {
path: path.clone(),
event_type: match event_type {
WatchEvent::Create => EventType::Create,
WatchEvent::Delete => EventType::Delete,
WatchEvent::Update => EventType::Update,
WatchEvent::Initial => EventType::Initial,
WatchEvent::DebugWatch => EventType::DebugWatch,
},
});
})
.await;
});
tokio::time::sleep(Duration::from_millis(200)).await;
{
let events = tracker.lock().unwrap();
let initial_events: Vec<_> = events
.iter()
.filter(|e| e.event_type == EventType::Initial)
.collect();
assert_eq!(
initial_events.len(),
0,
"Expected no Initial events when watch_initial is disabled, got: {:?}",
initial_events
);
}
watcher_handle.abort();
let _ = std::fs::remove_dir_all(&test_dir);
}
}