include_exclude_watcher/
lib.rs

1//! Async file watcher with glob-based include/exclude patterns.
2//!
3//! This crate provides an efficient recursive file watcher using Linux's inotify,
4//! with built-in support for glob patterns to filter events. Unlike most file
5//! watchers that require you to filter events after receiving them, this watcher
6//! only sets up watches on directories that could potentially match your patterns,
7//! reducing resource usage on large directory trees.
8//!
9//! # Features
10//!
11//! - **Selective directory watching**: Only watches directories that could match your include patterns
12//! - **Glob patterns**: Supports `*`, `**`, `?`, and character classes like `[a-z]`
13//! - **Include/exclude filtering**: Gitignore-style pattern matching with exclude taking precedence
14//! - **Pattern files**: Load patterns from `.gitignore`-style files
15//! - **Event filtering**: Watch only creates, deletes, updates, or any combination
16//! - **Type filtering**: Match only files, only directories, or both
17//! - **Debouncing**: Built-in debounce support to batch rapid changes
18//! - **Async/await**: Native tokio integration
19//!
20//! # Platform Support
21//!
22//! **Linux only** (uses inotify directly). PRs welcome for other platforms.
23//!
24//! # Quick Start
25//!
26//! ```no_run
27//! use include_exclude_watcher::{WatchBuilder, WatchEvent};
28//!
29//! #[tokio::main]
30//! async fn main() -> std::io::Result<()> {
31//!     WatchBuilder::new()
32//!         .set_base_dir("./src")
33//!         .add_include("**/*.rs")
34//!         .add_exclude("**/target/**")
35//!         .run(|event, path| {
36//!             println!("{:?}: {}", event, path.display());
37//!         })
38//!         .await
39//! }
40//! ```
41//!
42//! # Debounced Watching
43//!
44//! When files change rapidly (e.g., during a build), you often want to wait
45//! for changes to settle before taking action:
46//!
47//! ```no_run
48//! use include_exclude_watcher::WatchBuilder;
49//!
50//! #[tokio::main]
51//! async fn main() -> std::io::Result<()> {
52//!     WatchBuilder::new()
53//!         .set_base_dir("./src")
54//!         .add_include("**/*.rs")
55//!         .run_debounced(500, || {
56//!             println!("Files changed!");
57//!         })
58//!         .await
59//! }
60//! ```
61//!
62//! # Pattern Syntax
63//!
64//! Patterns use glob syntax similar to `.gitignore`:
65//!
66//! | Pattern | Matches |
67//! |---------|---------|
68//! | `*` | Any characters except `/` |
69//! | `**` | Any characters including `/` (matches across directories) |
70//! | `?` | Any single character except `/` |
71//! | `[abc]` | Any character in the set |
72//! | `[a-z]` | Any character in the range |
73//!
74//! ## Pattern Behavior
75//!
76//! - Patterns **without** `/` match anywhere in the tree (like gitignore).
77//!   For example, `*.rs` matches `foo.rs` and `src/bar.rs`.
78//! - Patterns **with** `/` are anchored to the base directory.
79//!   For example, `src/*.rs` matches `src/main.rs` but not `src/sub/lib.rs`.
80//!
81//! ## Examples
82//!
83//! | Pattern | Description |
84//! |---------|-------------|
85//! | `*.rs` | All Rust files anywhere |
86//! | `src/*.rs` | Rust files directly in `src/` |
87//! | `**/test_*.rs` | Test files anywhere |
88//! | `target/**` | Everything under `target/` |
89//! | `*.{rs,toml}` | Rust and TOML files (character class) |
90//!
91//! # Loading Patterns from Files
92//!
93//! You can load exclude patterns from gitignore-style files:
94//!
95//! ```no_run
96//! use include_exclude_watcher::WatchBuilder;
97//!
98//! #[tokio::main]
99//! async fn main() -> std::io::Result<()> {
100//!     WatchBuilder::new()
101//!         .set_base_dir("/project")
102//!         .add_include("**/*")
103//!         .add_ignore_file(".gitignore")
104//!         .add_ignore_file(".watchignore")
105//!         .run(|event, path| {
106//!             println!("{:?}: {}", event, path.display());
107//!         })
108//!         .await
109//! }
110//! ```
111//!
112//! Pattern file format:
113//! - Lines starting with `#` are comments
114//! - Empty lines are ignored
115//! - All other lines are exclude patterns
116//! - **Note**: `!` negation patterns are not supported (excludes always take precedence)
117//!
118//! # Filtering Events
119//!
120//! You can filter which events to receive and what types to match:
121//!
122//! ```no_run
123//! use include_exclude_watcher::WatchBuilder;
124//!
125//! # async fn example() -> std::io::Result<()> {
126//! WatchBuilder::new()
127//!     .add_include("**/*.rs")
128//!     .watch_create(true)   // Receive create events
129//!     .watch_delete(true)   // Receive delete events
130//!     .watch_update(false)  // Ignore modifications
131//!     .match_files(true)    // Match regular files
132//!     .match_dirs(false)    // Ignore directories
133//!     .run(|event, path| {
134//!         // Only file creates and deletes
135//!     })
136//!     .await
137//! # }
138//! ```
139
140use std::collections::{HashMap, HashSet};
141use std::ffi::CString;
142use std::fs;
143use std::io::{BufRead, BufReader, Result};
144use std::os::unix::ffi::OsStrExt;
145use std::os::unix::io::AsRawFd;
146use std::path::{Path, PathBuf};
147use std::time::Duration;
148use tokio::io::unix::AsyncFd;
149
150// --- Pattern Parsing ---
151
152/// Simple glob pattern matcher for a single path component.
153/// Supports: * (any chars), ? (single char), [abc] (char class), [a-z] (range)
154#[derive(Debug, Clone)]
155struct GlobPattern {
156    pattern: String,
157}
158
159impl PartialEq for GlobPattern {
160    fn eq(&self, other: &Self) -> bool {
161        self.pattern == other.pattern
162    }
163}
164
165impl GlobPattern {
166    fn new(pattern: &str) -> Self {
167        Self {
168            pattern: pattern.to_string(),
169        }
170    }
171
172    fn matches(&self, text: &str) -> bool {
173        Self::match_recursive(self.pattern.as_bytes(), text.as_bytes())
174    }
175
176    fn match_recursive(pattern: &[u8], text: &[u8]) -> bool {
177        let mut p = 0;
178        let mut t = 0;
179
180        // For backtracking on '*'
181        let mut star_p = None;
182        let mut star_t = None;
183
184        while t < text.len() {
185            if p < pattern.len() {
186                match pattern[p] {
187                    b'*' => {
188                        // '*' matches zero or more characters
189                        star_p = Some(p);
190                        star_t = Some(t);
191                        p += 1;
192                        continue;
193                    }
194                    b'?' => {
195                        // '?' matches exactly one character
196                        p += 1;
197                        t += 1;
198                        continue;
199                    }
200                    b'[' => {
201                        // Character class
202                        if let Some((matched, end_pos)) =
203                            Self::match_char_class(&pattern[p..], text[t])
204                        {
205                            if matched {
206                                p += end_pos;
207                                t += 1;
208                                continue;
209                            }
210                        }
211                        // Fall through to backtrack
212                    }
213                    c => {
214                        // Literal character match
215                        if c == text[t] {
216                            p += 1;
217                            t += 1;
218                            continue;
219                        }
220                        // Fall through to backtrack
221                    }
222                }
223            }
224
225            // No match at current position, try backtracking
226            if let (Some(sp), Some(st)) = (star_p, star_t) {
227                // Backtrack: make '*' match one more character
228                p = sp + 1;
229                star_t = Some(st + 1);
230                t = st + 1;
231            } else {
232                return false;
233            }
234        }
235
236        // Consume any trailing '*' in pattern
237        while p < pattern.len() && pattern[p] == b'*' {
238            p += 1;
239        }
240
241        p == pattern.len()
242    }
243
244    /// Match a character class like [abc] or [a-z] or [!abc]
245    /// Returns (matched, bytes_consumed) if valid class, None if invalid
246    fn match_char_class(pattern: &[u8], ch: u8) -> Option<(bool, usize)> {
247        if pattern.is_empty() || pattern[0] != b'[' {
248            return None;
249        }
250
251        let mut i = 1;
252        let mut matched = false;
253        let negated = i < pattern.len() && (pattern[i] == b'!' || pattern[i] == b'^');
254        if negated {
255            i += 1;
256        }
257
258        while i < pattern.len() {
259            if pattern[i] == b']' && i > 1 + (negated as usize) {
260                // End of character class
261                return Some((matched != negated, i + 1));
262            }
263
264            // Check for range: a-z
265            if i + 2 < pattern.len() && pattern[i + 1] == b'-' && pattern[i + 2] != b']' {
266                let start = pattern[i];
267                let end = pattern[i + 2];
268                if ch >= start && ch <= end {
269                    matched = true;
270                }
271                i += 3;
272            } else {
273                // Single character
274                if pattern[i] == ch {
275                    matched = true;
276                }
277                i += 1;
278            }
279        }
280
281        // No closing bracket found
282        None
283    }
284}
285
286#[derive(Debug, Clone, PartialEq)]
287enum Segment {
288    Exact(String),
289    Wildcard(GlobPattern),
290    DoubleWildcard, // **
291}
292
293#[derive(Debug, Clone)]
294struct Pattern {
295    segments: Vec<Segment>,
296}
297
298impl Pattern {
299    fn parse(pattern: &str) -> Self {
300        let mut segments = Vec::new();
301
302        // Patterns without / match anywhere in the tree (like gitignore)
303        let effective_pattern = if !pattern.contains('/') {
304            format!("**/{}", pattern)
305        } else {
306            pattern.trim_start_matches('/').to_string()
307        };
308
309        let normalized = effective_pattern.replace("//", "/");
310
311        for part in normalized.split('/') {
312            if part.is_empty() || part == "." {
313                continue;
314            }
315
316            if part == "**" {
317                segments.push(Segment::DoubleWildcard);
318            } else if part.contains('*') || part.contains('?') || part.contains('[') {
319                segments.push(Segment::Wildcard(GlobPattern::new(part)));
320            } else {
321                segments.push(Segment::Exact(part.to_string()));
322            }
323        }
324
325        Pattern { segments }
326    }
327
328    fn check(&self, path_segments: &[String], allow_prefix: bool) -> bool {
329        let pattern_segments = &self.segments;
330        let mut path_index = 0;
331
332        for pattern_index in 0..pattern_segments.len() {
333            let pattern_segment = &pattern_segments[pattern_index];
334
335            if path_index >= path_segments.len() {
336                // We ran out of path elements
337                if pattern_segment == &Segment::DoubleWildcard && pattern_index == pattern_segments.len() - 1
338                {
339                    // The only pattern segment we still need to match is **. We'll consider that a match for the parent.
340                    return true;
341                }
342                // Something within this path could potentially match.
343                return allow_prefix;
344            }
345
346            match &pattern_segment {
347                Segment::Exact(s) => {
348                    if s != &path_segments[path_index] {
349                        return false;
350                    }
351                    path_index += 1;
352                }
353                Segment::Wildcard(p) => {
354                    if !p.matches(&path_segments[path_index]) {
355                        return false;
356                    }
357                    path_index += 1;
358                }
359                Segment::DoubleWildcard => {
360                    if allow_prefix {
361                        // If we're matching a **, there can always be some deeply nested dir structure that
362                        // will match the rest of our pattern. So for prefix matching, the answer is always true.
363                        return true;
364                    }
365
366                    let patterns_left = pattern_segments.len() - (pattern_index + 1);
367                    let next_path_index = path_segments.len() - patterns_left;
368                    if next_path_index < path_index {
369                        return false;
370                    }
371                    path_index = next_path_index;
372                }
373            }
374        }
375
376        // If there are spurious path elements, this is not a match.
377        if path_index < path_segments.len() {
378            return false;
379        }
380
381        // We have an exact match. However when in allow_prefix mode, that means this directory is the target
382        // and its contents does not need to be watched.
383        return !allow_prefix;
384    }
385}
386
387// --- Inotify Wrapper ---
388
389struct Inotify {
390    fd: AsyncFd<i32>,
391}
392
393impl Inotify {
394    fn new() -> Result<Self> {
395        let fd = unsafe { libc::inotify_init1(libc::IN_NONBLOCK | libc::IN_CLOEXEC) };
396        if fd < 0 {
397            return Err(std::io::Error::last_os_error());
398        }
399        Ok(Self {
400            fd: AsyncFd::new(fd)?,
401        })
402    }
403
404    fn add_watch(&self, path: &Path, mask: u32) -> Result<i32> {
405        let c_path = CString::new(path.as_os_str().as_bytes())
406            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
407        let wd = unsafe { libc::inotify_add_watch(self.fd.as_raw_fd(), c_path.as_ptr(), mask) };
408        if wd < 0 {
409            return Err(std::io::Error::last_os_error());
410        }
411        Ok(wd)
412    }
413
414    async fn read_events(&self, buffer: &mut [u8]) -> Result<usize> {
415        loop {
416            let mut guard = self.fd.readable().await?;
417            match guard.try_io(|inner| {
418                let res = unsafe {
419                    libc::read(
420                        inner.as_raw_fd(),
421                        buffer.as_mut_ptr() as *mut _,
422                        buffer.len(),
423                    )
424                };
425                if res < 0 {
426                    Err(std::io::Error::last_os_error())
427                } else {
428                    Ok(res as usize)
429                }
430            }) {
431                Ok(Ok(len)) => return Ok(len),
432                Ok(Err(e)) => {
433                    if e.kind() == std::io::ErrorKind::WouldBlock {
434                        continue;
435                    }
436                    return Err(e);
437                }
438                Err(_) => continue,
439            }
440        }
441    }
442}
443
444impl Drop for Inotify {
445    fn drop(&mut self) {
446        unsafe { libc::close(self.fd.as_raw_fd()) };
447    }
448}
449
450// --- Helper Functions ---
451
452fn resolve_base_dir(base_dir: PathBuf) -> PathBuf {
453    if base_dir.is_absolute() {
454        base_dir
455    } else {
456        std::env::current_dir()
457            .unwrap_or_else(|_| PathBuf::from("/"))
458            .join(base_dir)
459    }
460}
461
462fn path_to_segments(path: &Path) -> Vec<String> {
463    let path_str = path.to_string_lossy();
464    let path_str = path_str.replace("//", "/");
465    path_str
466        .split('/')
467        .filter(|s| !s.is_empty())
468        .map(|s| s.to_string())
469        .collect()
470}
471
472fn should_watch(
473    relative_path: &Path,
474    include_patterns: &[Pattern],
475    exclude_patterns: &[Pattern],
476    is_dir: bool,
477) -> bool {
478    let segments = path_to_segments(relative_path);
479    
480    if exclude_patterns.iter().any(|p| p.check(&segments, false)) {
481        return false;
482    }
483
484    include_patterns.iter().any(|p| p.check(&segments, is_dir))
485}
486
487const INOTIFY_MASK: u32 = libc::IN_MODIFY
488    | libc::IN_CLOSE_WRITE
489    | libc::IN_CREATE
490    | libc::IN_DELETE
491    | libc::IN_MOVED_FROM
492    | libc::IN_MOVED_TO
493    | libc::IN_DONT_FOLLOW;
494
495
496fn add_watch_recursive<F>(
497    initial_path: PathBuf,
498    root: &Path,
499    inotify: &Inotify,
500    watches: &mut HashMap<i32, PathBuf>,
501    paths: &mut HashSet<PathBuf>,
502    include_patterns: &[Pattern],
503    exclude_patterns: &[Pattern],
504    debug_watches_enabled: bool,
505    return_absolute: bool,
506    callback: &mut F,
507) where
508    F: FnMut(WatchEvent, PathBuf),
509{
510    if paths.contains(&initial_path) {
511        return;
512    }
513
514    let mut stack = vec![initial_path];
515    while let Some(rel_path) = stack.pop() {
516        if !should_watch(&rel_path, include_patterns, exclude_patterns, true) {
517            continue;
518        }
519
520        let full_path = if rel_path.as_os_str().is_empty() {
521            root.to_path_buf()
522        } else {
523            root.join(&rel_path)
524        };
525
526        if !full_path.is_dir() {
527            continue;
528        }
529
530        let wd = match inotify.add_watch(&full_path, INOTIFY_MASK) {
531            Ok(wd) => wd,
532            Err(e) => {
533                eprintln!("Failed to add watch for {:?}: {}", full_path, e);
534                continue;
535            }
536        };
537
538        paths.insert(rel_path.clone());
539        watches.insert(wd, rel_path.clone());
540
541        if debug_watches_enabled {
542            let callback_path = if return_absolute {
543                full_path.clone()
544            } else {
545                rel_path.clone()
546            };
547            callback(WatchEvent::DebugWatch, callback_path);
548        }
549
550        if let Ok(entries) = std::fs::read_dir(&full_path) {
551            for entry in entries.flatten() {
552                if let Ok(ft) = entry.file_type() {
553                    if ft.is_dir() {
554                        let child_rel_path = rel_path.join(entry.file_name());
555                        if !paths.contains(&child_rel_path) {
556                            stack.push(child_rel_path);
557                        }
558                    }
559                }
560            }
561        }
562    }
563}
564
565fn parse_inotify_events(buffer: &[u8], len: usize) -> Vec<(i32, u32, String)> {
566    let mut events = Vec::new();
567    let mut ptr = buffer.as_ptr();
568    let end = unsafe { ptr.add(len) };
569
570    while ptr < end {
571        let event = unsafe { &*(ptr as *const libc::inotify_event) };
572        let name_len = event.len as usize;
573
574        if name_len > 0 {
575            let name_ptr = unsafe { ptr.add(std::mem::size_of::<libc::inotify_event>()) };
576            let name_slice =
577                unsafe { std::slice::from_raw_parts(name_ptr as *const u8, name_len) };
578            let name_str = String::from_utf8_lossy(name_slice)
579                .trim_matches(char::from(0))
580                .to_string();
581            events.push((event.wd, event.mask, name_str));
582        }
583
584        ptr = unsafe { ptr.add(std::mem::size_of::<libc::inotify_event>() + name_len) };
585    }
586
587    events
588}
589
590/// Type of file system event.
591///
592/// These events correspond to inotify events, but are simplified into three
593/// categories that cover most use cases.
594#[derive(Debug, Clone, Copy, PartialEq, Eq)]
595pub enum WatchEvent {
596    /// File or directory was created.
597    ///
598    /// Also triggered when a file/directory is moved *into* a watched directory.
599    Create,
600    /// File or directory was deleted.
601    ///
602    /// Also triggered when a file/directory is moved *out of* a watched directory.
603    Delete,
604    /// File content was modified.
605    ///
606    /// Triggered on `IN_MODIFY` (content changed) or `IN_CLOSE_WRITE` (file
607    /// opened for writing was closed). Directory content changes (files added/removed)
608    /// are reported as [`Create`](WatchEvent::Create)/[`Delete`](WatchEvent::Delete) instead.
609    Update,
610    /// Debug event: a watch was added on this directory.
611    ///
612    /// Only emitted when [`WatchBuilder::debug_watches`] is enabled. Useful for
613    /// understanding which directories are being watched based on your patterns.
614    DebugWatch,
615}
616
617/// Builder for configuring and running a file watcher.
618///
619/// Use method chaining to configure the watcher, then call [`run`](WatchBuilder::run)
620/// or [`run_debounced`](WatchBuilder::run_debounced) to start watching.
621///
622/// # Example
623///
624/// ```no_run
625/// use include_exclude_watcher::WatchBuilder;
626///
627/// # async fn example() -> std::io::Result<()> {
628/// WatchBuilder::new()
629///     .set_base_dir("/project")
630///     .add_include("src/**/*.rs")
631///     .add_include("Cargo.toml")
632///     .add_exclude("**/target/**")
633///     .run(|event, path| {
634///         println!("{:?}: {}", event, path.display());
635///     })
636///     .await
637/// # }
638/// ```
639pub struct WatchBuilder {
640    includes: Option<Vec<String>>,
641    excludes: Vec<String>,
642    base_dir: PathBuf,
643    watch_create: bool,
644    watch_delete: bool,
645    watch_update: bool,
646    match_files: bool,
647    match_dirs: bool,
648    return_absolute: bool,
649    debug_watches_enabled: bool,
650}
651
652impl Default for WatchBuilder {
653    fn default() -> Self {
654        Self::new()
655    }
656}
657
658impl WatchBuilder {
659    /// Create a new file watcher builder with default settings.
660    ///
661    /// Defaults:
662    /// - Base directory: current working directory
663    /// - Includes: none (must be added, or watches everything)
664    /// - Excludes: none
665    /// - Event types: create, delete, update all enabled
666    /// - Match types: both files and directories
667    /// - Path format: relative paths
668    pub fn new() -> Self {
669        WatchBuilder {
670            includes: Some(Vec::new()),
671            excludes: Vec::new(),
672            base_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")),
673            watch_create: true,
674            watch_delete: true,
675            watch_update: true,
676            match_files: true,
677            match_dirs: true,
678            return_absolute: false,
679            debug_watches_enabled: false,
680        }
681    }
682
683    /// Enable debug watch events.
684    ///
685    /// When enabled, [`WatchEvent::DebugWatch`] events will be emitted for each
686    /// directory that is watched. Useful for debugging pattern matching.
687    pub fn debug_watches(mut self, enabled: bool) -> Self {
688        self.debug_watches_enabled = enabled;
689        self
690    }
691
692    /// Add a single include pattern.
693    ///
694    /// Patterns use glob syntax:
695    /// - `*` matches any sequence of characters except `/`
696    /// - `**` matches any sequence of characters including `/`
697    /// - `?` matches any single character except `/`
698    /// - `[abc]` matches any character in the set
699    ///
700    /// Patterns without a `/` match anywhere in the tree (like gitignore).
701    /// For example, `*.rs` is equivalent to `**/*.rs`.
702    pub fn add_include(mut self, pattern: impl Into<String>) -> Self {
703        if self.includes.is_none() {
704            self.includes = Some(Vec::new());
705        }
706        self.includes.as_mut().unwrap().push(pattern.into());
707        self
708    }
709
710    /// Add multiple include patterns.
711    pub fn add_includes(mut self, patterns: impl IntoIterator<Item = impl Into<String>>) -> Self {
712        if self.includes.is_none() {
713            self.includes = Some(Vec::new());
714        }
715        self.includes
716            .as_mut()
717            .unwrap()
718            .extend(patterns.into_iter().map(|p| p.into()));
719        self
720    }
721
722    /// Add a single exclude pattern.
723    ///
724    /// Excludes take precedence over includes. Uses the same glob syntax as includes.
725    pub fn add_exclude(mut self, pattern: impl Into<String>) -> Self {
726        self.excludes.push(pattern.into());
727        self
728    }
729
730    /// Add multiple exclude patterns.
731    pub fn add_excludes(mut self, patterns: impl IntoIterator<Item = impl Into<String>>) -> Self {
732        self.excludes
733            .extend(patterns.into_iter().map(|p| p.into()));
734        self
735    }
736
737    /// Add patterns from a gitignore-style file.
738    ///
739    /// Lines starting with `#` are comments. All other non-empty lines are
740    /// exclude patterns. Note: `!` negation patterns are not supported (a
741    /// warning will be printed) because excludes always take precedence over
742    /// includes in this library.
743    ///
744    /// If the file doesn't exist, this method does nothing (no error).
745    ///
746    /// # Example
747    ///
748    /// ```no_run
749    /// use include_exclude_watcher::WatchBuilder;
750    ///
751    /// # async fn example() -> std::io::Result<()> {
752    /// WatchBuilder::new()
753    ///     .set_base_dir("/project")
754    ///     .add_include("*")
755    ///     .add_ignore_file(".gitignore")
756    ///     .add_ignore_file(".watchignore")
757    ///     .run(|event, path| {
758    ///         println!("{:?}: {}", event, path.display());
759    ///     })
760    ///     .await
761    /// # }
762    /// ```
763    pub fn add_ignore_file(mut self, path: impl AsRef<Path>) -> Self {
764        let path = path.as_ref();
765
766        // Resolve relative to base_dir
767        let full_path = if path.is_absolute() {
768            path.to_path_buf()
769        } else {
770            self.base_dir.join(path)
771        };
772
773        if let Ok(file) = fs::File::open(&full_path) {
774            let reader = BufReader::new(file);
775            let mut has_negation = false;
776            for line in reader.lines().map_while(Result::ok) {
777                let trimmed = line.trim();
778
779                // Skip empty lines and comments
780                if trimmed.is_empty() || trimmed.starts_with('#') {
781                    continue;
782                }
783
784                // Lines starting with ! are negations - not supported
785                if trimmed.starts_with('!') {
786                    has_negation = true;
787                } else {
788                    // Regular lines are exclude patterns
789                    self.excludes.push(trimmed.to_string());
790                }
791            }
792            if has_negation {
793                println!("Warning: negation patterns (!) in {} are ignored; excludes always take precedence over includes in this library", full_path.display());
794            }
795        }
796
797        self
798    }
799
800    /// Set the base directory for watching.
801    ///
802    /// All patterns are relative to this directory. Defaults to the current
803    /// working directory.
804    pub fn set_base_dir(mut self, base_dir: impl Into<PathBuf>) -> Self {
805        self.base_dir = base_dir.into();
806        self
807    }
808
809    /// Set whether to watch for file/directory creation events.
810    ///
811    /// Default: `true`
812    pub fn watch_create(mut self, enabled: bool) -> Self {
813        self.watch_create = enabled;
814        self
815    }
816
817    /// Set whether to watch for file/directory deletion events.
818    ///
819    /// Default: `true`
820    pub fn watch_delete(mut self, enabled: bool) -> Self {
821        self.watch_delete = enabled;
822        self
823    }
824
825    /// Set whether to watch for file modification events.
826    ///
827    /// Default: `true`
828    pub fn watch_update(mut self, enabled: bool) -> Self {
829        self.watch_update = enabled;
830        self
831    }
832
833    /// Set whether to match regular files.
834    ///
835    /// Default: `true`
836    pub fn match_files(mut self, enabled: bool) -> Self {
837        self.match_files = enabled;
838        self
839    }
840
841    /// Set whether to match directories.
842    ///
843    /// Default: `true`
844    pub fn match_dirs(mut self, enabled: bool) -> Self {
845        self.match_dirs = enabled;
846        self
847    }
848
849    /// Set whether to return absolute paths.
850    ///
851    /// When `false` (default), paths passed to the callback are relative to
852    /// the base directory. When `true`, paths are absolute.
853    pub fn return_absolute(mut self, enabled: bool) -> Self {
854        self.return_absolute = enabled;
855        self
856    }
857
858    /// Run the watcher with the provided callback.
859    ///
860    /// This method runs forever, calling the callback for each matching event.
861    /// The callback receives the event type and the path (relative or absolute
862    /// depending on configuration).
863    ///
864    /// If no include patterns are specified, watches everything.
865    pub async fn run<F>(self, callback: F) -> Result<()>
866    where
867        F: FnMut(WatchEvent, PathBuf),
868    {
869        self.run_internal(callback, None).await
870    }
871
872    /// Run the watcher with debouncing.
873    ///
874    /// Waits for file changes, then waits until no changes have occurred for
875    /// at least `ms` milliseconds before calling the callback. This is useful
876    /// for batching rapid changes (like when a build tool writes many files).
877    ///
878    /// The callback takes no arguments since the specific paths are not tracked
879    /// during debouncing.
880    pub async fn run_debounced<F>(self, ms: u64, mut callback: F) -> Result<()>
881    where
882        F: FnMut(),
883    {
884        self.run_internal(|_, _| callback(), Some(Duration::from_millis(ms))).await
885    }
886
887    async fn run_internal<F>(self, mut callback: F, debounce: Option<Duration>) -> Result<()>
888    where
889        F: FnMut(WatchEvent, PathBuf),
890    {
891        let includes = if let Some(includes) = self.includes {
892            includes
893        } else {
894            vec!["**".to_string()]
895        };
896
897        // If no includes are specified, just sleep forever
898        if includes.is_empty() {
899            loop {
900                tokio::time::sleep(Duration::from_secs(3600)).await;
901            }
902        }
903
904        let excludes = self.excludes;
905        let root = self.base_dir.clone();
906        let watch_create = self.watch_create;
907        let watch_delete = self.watch_delete;
908        let watch_update = self.watch_update;
909        let match_files = self.match_files;
910        let match_dirs = self.match_dirs;
911        let return_absolute = self.return_absolute;
912        let debug_watches_enabled = self.debug_watches_enabled;
913
914        let root = resolve_base_dir(root);
915
916        let include_patterns: Vec<Pattern> = includes.iter().map(|p| Pattern::parse(p)).collect();
917        let exclude_patterns: Vec<Pattern> = excludes.iter().map(|p| Pattern::parse(p)).collect();
918
919        let inotify = Inotify::new()?;
920        let mut watches = HashMap::<i32, PathBuf>::new();
921        let mut paths = HashSet::<PathBuf>::new();
922
923        // Initial scan
924        add_watch_recursive(
925            PathBuf::new(),
926            &root,
927            &inotify,
928            &mut watches,
929            &mut paths,
930            &include_patterns,
931            &exclude_patterns,
932            debug_watches_enabled,
933            return_absolute,
934            &mut callback,
935        );
936
937        // Debouncing state
938        let mut debounce_deadline: Option<tokio::time::Instant> = None;
939
940        // Event loop
941        let mut buffer = [0u8; 8192];
942        loop {
943            // Calculate timeout for debouncing
944            let read_future = inotify.read_events(&mut buffer);
945            
946            let read_result = if let Some(deadline) = debounce_deadline {
947                let now = tokio::time::Instant::now();
948                if deadline <= now {
949                    // Timer expired, fire callback and reset
950                    debounce_deadline = None;
951                    callback(WatchEvent::Update, PathBuf::new());
952                    continue;
953                }
954                // Wait with timeout
955                match tokio::time::timeout(deadline - now, read_future).await {
956                    Ok(result) => Some(result),
957                    Err(_) => {
958                        // Timeout expired, fire callback
959                        debounce_deadline = None;
960                        callback(WatchEvent::Update, PathBuf::new());
961                        continue;
962                    }
963                }
964            } else {
965                Some(read_future.await)
966            };
967
968            let Some(result) = read_result else { continue };
969            
970            match result {
971                Ok(len) => {
972                    let events = parse_inotify_events(&buffer, len);
973                    let mut had_matching_event = false;
974
975                    for (wd, mask, name_str) in events {
976                        if (mask & libc::IN_IGNORED as u32) != 0 {
977                            if let Some(path) = watches.remove(&wd) {
978                                paths.remove(&path);
979                            }
980                            continue;
981                        }
982
983                        let rel_path = if let Some(dir_path) = watches.get(&wd) {
984                            dir_path.join(&name_str)
985                        } else {
986                            println!("Warning: received event for unknown watch descriptor {}", wd);
987                            continue;
988                        };
989
990                        let is_dir = mask & libc::IN_ISDIR as u32 != 0;
991                        let is_create = (mask & libc::IN_CREATE as u32) != 0
992                            || (mask & libc::IN_MOVED_TO as u32) != 0;
993                        let is_delete = (mask & libc::IN_DELETE as u32) != 0
994                            || (mask & libc::IN_MOVED_FROM as u32) != 0;
995                        let is_update = (mask & libc::IN_MODIFY as u32) != 0
996                            || (mask & libc::IN_CLOSE_WRITE as u32) != 0;
997
998
999                        if is_dir && is_create {
1000                            // New directory created 
1001                            add_watch_recursive(
1002                                rel_path.clone(),
1003                                &root,
1004                                &inotify,
1005                                &mut watches,
1006                                &mut paths,
1007                                &include_patterns,
1008                                &exclude_patterns,
1009                                debug_watches_enabled,
1010                                return_absolute,
1011                                &mut callback,
1012                            );
1013                        }
1014
1015                        let event_type = if is_create && watch_create {
1016                            WatchEvent::Create
1017                        } else if is_delete && watch_delete {
1018                            WatchEvent::Delete
1019                        } else if is_update && watch_update {
1020                            WatchEvent::Update
1021                        } else {
1022                            continue
1023                        };
1024
1025                        if if is_dir { !match_dirs } else { !match_files } {
1026                            continue;
1027                        }
1028
1029                        if !should_watch(&rel_path, &include_patterns, &exclude_patterns, false) {
1030                            continue;
1031                        }
1032
1033                        had_matching_event = true;
1034                        
1035                        // Do callback if not in debounce mode
1036                        if debounce.is_none() {
1037                            let callback_path = if return_absolute {
1038                                if rel_path.as_os_str().is_empty() {
1039                                    root.clone()
1040                                } else {
1041                                    root.join(&rel_path)
1042                                }
1043                            } else {
1044                                rel_path
1045                            };
1046                            callback(event_type, callback_path);
1047                        }
1048                    }
1049                    
1050                    // If debouncing and we had events, reset the timer
1051                    if let Some(d) = debounce {
1052                        if had_matching_event {
1053                            debounce_deadline = Some(tokio::time::Instant::now() + d);
1054                        }
1055                    }
1056                }
1057                Err(e) => {
1058                    eprintln!("Error reading inotify events: {}", e);
1059                    tokio::time::sleep(Duration::from_millis(100)).await;
1060                }
1061            }
1062        }
1063    }
1064}
1065
1066#[cfg(test)]
1067mod tests {
1068    use super::*;
1069    use std::collections::HashSet;
1070    use std::sync::{Arc, Mutex};
1071    use tokio::task::JoinHandle;
1072
1073    #[derive(Debug, Clone, PartialEq, Eq, Hash)]
1074    enum EventType {
1075        Create,
1076        Delete,
1077        Update,
1078        DebugWatch,
1079    }
1080
1081    #[derive(Debug, Clone, PartialEq, Eq, Hash)]
1082    struct Event {
1083        path: PathBuf,
1084        event_type: EventType,
1085    }
1086
1087    type EventTracker = Arc<Mutex<Vec<Event>>>;
1088
1089    struct TestInstance {
1090        test_dir: PathBuf,
1091        tracker: EventTracker,
1092        watcher_handle: Option<JoinHandle<()>>,
1093    }
1094
1095    impl TestInstance {
1096        async fn new<F>(test_name: &str, configure: F) -> Self
1097        where
1098            F: FnOnce(WatchBuilder) -> WatchBuilder + Send + 'static,
1099        {
1100            let test_dir = std::env::current_dir()
1101                .unwrap()
1102                .join(format!(".file-watcher-test-{}", test_name));
1103
1104            if test_dir.exists() {
1105                std::fs::remove_dir_all(&test_dir).unwrap();
1106            }
1107            std::fs::create_dir(&test_dir).unwrap();
1108
1109            let tracker = Arc::new(Mutex::new(Vec::new()));
1110
1111            let tracker_clone = tracker.clone();
1112            let test_dir_clone = test_dir.clone();
1113
1114            let watcher_handle = tokio::spawn(async move {
1115                let builder = WatchBuilder::new()
1116                    .set_base_dir(&test_dir_clone)
1117                    .debug_watches(true);
1118
1119                let builder = configure(builder);
1120
1121                let _ = builder
1122                    .run(move |event_type, path| {
1123                        tracker_clone.lock().unwrap().push(Event {
1124                            path: path.clone(),
1125                            event_type: match event_type {
1126                                WatchEvent::Create => EventType::Create,
1127                                WatchEvent::Delete => EventType::Delete,
1128                                WatchEvent::Update => EventType::Update,
1129                                WatchEvent::DebugWatch => EventType::DebugWatch,
1130                            },
1131                        });
1132                    })
1133                    .await;
1134            });
1135
1136            tokio::time::sleep(Duration::from_millis(100)).await;
1137
1138            let instance = Self {
1139                test_dir,
1140                tracker,
1141                watcher_handle: Some(watcher_handle),
1142            };
1143
1144            instance.assert_events(&[], &[], &[], &[""]).await;
1145
1146            instance
1147        }
1148
1149        fn create_dir(&self, path: &str) {
1150            std::fs::create_dir_all(self.test_dir.join(path)).unwrap();
1151        }
1152
1153        fn write_file(&self, path: &str, content: &str) {
1154            let full_path = self.test_dir.join(path);
1155            if let Some(parent) = full_path.parent() {
1156                std::fs::create_dir_all(parent).unwrap();
1157            }
1158            std::fs::write(full_path, content).unwrap();
1159        }
1160
1161        fn remove_file(&self, path: &str) {
1162            std::fs::remove_file(self.test_dir.join(path)).unwrap();
1163        }
1164
1165        fn rename(&self, from: &str, to: &str) {
1166            std::fs::rename(self.test_dir.join(from), self.test_dir.join(to)).unwrap();
1167        }
1168
1169        async fn assert_events(
1170            &self,
1171            creates: &[&str],
1172            deletes: &[&str],
1173            updates: &[&str],
1174            watches: &[&str],
1175        ) {
1176            tokio::time::sleep(Duration::from_millis(200)).await;
1177
1178            let events = self.tracker.lock().unwrap().clone();
1179            let mut expected = HashSet::new();
1180
1181            for create in creates {
1182                expected.insert(Event {
1183                    path: PathBuf::from(create),
1184                    event_type: EventType::Create,
1185                });
1186            }
1187
1188            for delete in deletes {
1189                expected.insert(Event {
1190                    path: PathBuf::from(delete),
1191                    event_type: EventType::Delete,
1192                });
1193            }
1194
1195            for update in updates {
1196                expected.insert(Event {
1197                    path: PathBuf::from(update),
1198                    event_type: EventType::Update,
1199                });
1200            }
1201
1202            for watch in watches {
1203                expected.insert(Event {
1204                    path: PathBuf::from(watch),
1205                    event_type: EventType::DebugWatch,
1206                });
1207            }
1208
1209            let actual: HashSet<Event> = events.iter().cloned().collect();
1210
1211            for event in &actual {
1212                if !expected.contains(event) {
1213                    panic!("Unexpected event: {:?}", event);
1214                }
1215            }
1216
1217            for event in &expected {
1218                if !actual.contains(event) {
1219                    panic!(
1220                        "Missing expected event: {:?}\nActual events: {:?}",
1221                        event, actual
1222                    );
1223                }
1224            }
1225
1226            self.tracker.lock().unwrap().clear();
1227        }
1228
1229        async fn assert_no_events(&self) {
1230            tokio::time::sleep(Duration::from_millis(500)).await;
1231            let events = self.tracker.lock().unwrap();
1232            assert_eq!(
1233                events.len(),
1234                0,
1235                "Expected no events, but got: {:?}",
1236                events
1237            );
1238        }
1239    }
1240
1241    impl Drop for TestInstance {
1242        fn drop(&mut self) {
1243            if let Some(handle) = self.watcher_handle.take() {
1244                handle.abort();
1245            }
1246            if self.test_dir.exists() {
1247                let _ = std::fs::remove_dir_all(&self.test_dir);
1248            }
1249        }
1250    }
1251
1252    #[tokio::test]
1253    async fn test_file_create_update_delete() {
1254        let test = TestInstance::new("create_update_delete", |b| b.add_include("**/*")).await;
1255
1256        test.write_file("test.txt", "");
1257        test.assert_events(&["test.txt"], &[], &["test.txt"], &[])
1258            .await;
1259
1260        test.write_file("test.txt", "hello");
1261        test.assert_events(&[], &[], &["test.txt"], &[]).await;
1262
1263        test.remove_file("test.txt");
1264        test.assert_events(&[], &["test.txt"], &[], &[]).await;
1265    }
1266
1267    #[tokio::test]
1268    async fn test_directory_operations() {
1269        let test = TestInstance::new("directory_operations", |b| b.add_include("**/*")).await;
1270
1271        test.create_dir("subdir");
1272        test.assert_events(&["subdir"], &[], &[], &["subdir"]).await;
1273
1274        test.write_file("subdir/file.txt", "");
1275        test.assert_events(&["subdir/file.txt"], &[], &["subdir/file.txt"], &[])
1276            .await;
1277    }
1278
1279    #[tokio::test]
1280    async fn test_move_operations() {
1281        let test = TestInstance::new("move_operations", |b| b.add_include("**/*")).await;
1282
1283        test.write_file("old.txt", "content");
1284        test.assert_events(&["old.txt"], &[], &["old.txt"], &[])
1285            .await;
1286
1287        test.rename("old.txt", "new.txt");
1288        test.assert_events(&["new.txt"], &["old.txt"], &[], &[])
1289            .await;
1290    }
1291
1292    #[tokio::test]
1293    async fn test_event_filtering() {
1294        let test = TestInstance::new("event_filtering", |b| {
1295            b.add_include("**/*")
1296                .watch_create(true)
1297                .watch_delete(false)
1298                .watch_update(false)
1299        })
1300        .await;
1301
1302        test.write_file("test.txt", "");
1303        test.assert_events(&["test.txt"], &[], &[], &[]).await;
1304
1305        test.write_file("test.txt", "hello");
1306        test.assert_no_events().await;
1307
1308        test.remove_file("test.txt");
1309        test.assert_no_events().await;
1310    }
1311
1312    #[tokio::test]
1313    async fn test_pattern_matching() {
1314        let test = TestInstance::new("pattern_matching", |b| b.add_include("**/*.txt")).await;
1315
1316        test.write_file("test.txt", "");
1317        test.assert_events(&["test.txt"], &[], &["test.txt"], &[])
1318            .await;
1319
1320        test.write_file("test.rs", "");
1321        test.assert_no_events().await;
1322    }
1323
1324    #[tokio::test]
1325    async fn test_matching_stops_at_depth() {
1326        let test = TestInstance::new("matching_stops_at_depth", |b| b.add_include("*/xyz/*.*")).await;
1327
1328        test.write_file("test.txt", "");
1329        test.assert_no_events().await;
1330
1331        test.create_dir("abc/xyz");
1332        test.assert_events(&[], &[], &[], &["abc", "abc/xyz"]).await;
1333
1334        test.create_dir("abc/hjk/a.b");
1335        test.assert_no_events().await;
1336
1337        test.create_dir("abc/xyz/a.b");
1338        test.assert_events(&["abc/xyz/a.b"], &[], &[], &[]).await; // Should not watch the a.b dir
1339
1340        test.create_dir("abc/xyz/a.b/x.y");
1341        test.assert_events(&[], &[], &[], &[]).await;
1342    }
1343
1344    #[tokio::test]
1345    async fn test_exclude_prevents_watching() {
1346        let test = TestInstance::new("exclude_prevents_watch", |b| {
1347            b.add_include("**/*").add_exclude("node_modules/**")
1348        })
1349        .await;
1350
1351        test.create_dir("node_modules");
1352        tokio::time::sleep(Duration::from_millis(200)).await;
1353
1354        test.write_file("node_modules/package.json", "");
1355        test.assert_no_events().await;
1356
1357        test.write_file("test.txt", "");
1358        test.assert_events(&["test.txt"], &[], &["test.txt"], &[])
1359            .await;
1360    }
1361
1362    #[tokio::test]
1363    async fn test_pattern_file() {
1364        // Setup: create test directory manually and write pattern file first
1365        let test_dir = std::env::current_dir()
1366            .unwrap()
1367            .join(".file-watcher-test-pattern_file");
1368
1369        if test_dir.exists() {
1370            std::fs::remove_dir_all(&test_dir).unwrap();
1371        }
1372        std::fs::create_dir(&test_dir).unwrap();
1373
1374        // Write pattern file before starting watcher
1375        std::fs::write(
1376            test_dir.join(".watchignore"),
1377            "# Comment line\nignored/**\n",
1378        )
1379        .unwrap();
1380
1381        // Now create watcher with pattern file
1382        let tracker = Arc::new(Mutex::new(Vec::<Event>::new()));
1383        let tracker_clone = tracker.clone();
1384        let test_dir_clone = test_dir.clone();
1385
1386        let watcher_handle = tokio::spawn(async move {
1387            let _ = WatchBuilder::new()
1388                .set_base_dir(&test_dir_clone)
1389                .debug_watches(true)
1390                .add_include("**/*")
1391                .add_ignore_file(".watchignore")
1392                .run(move |event_type, path| {
1393                    tracker_clone.lock().unwrap().push(Event {
1394                        path: path.clone(),
1395                        event_type: match event_type {
1396                            WatchEvent::Create => EventType::Create,
1397                            WatchEvent::Delete => EventType::Delete,
1398                            WatchEvent::Update => EventType::Update,
1399                            WatchEvent::DebugWatch => EventType::DebugWatch,
1400                        },
1401                    });
1402                })
1403                .await;
1404        });
1405
1406        tokio::time::sleep(Duration::from_millis(100)).await;
1407        tracker.lock().unwrap().clear(); // Clear initial watch event
1408
1409        // Create ignored directory
1410        std::fs::create_dir(test_dir.join("ignored")).unwrap();
1411        tokio::time::sleep(Duration::from_millis(200)).await;
1412
1413        // Files in ignored/ should not trigger events (because of exclude)
1414        std::fs::write(test_dir.join("ignored/test.txt"), "").unwrap();
1415        tokio::time::sleep(Duration::from_millis(200)).await;
1416
1417        // Check no events for ignored files
1418        {
1419            let events = tracker.lock().unwrap();
1420            let has_ignored_events = events.iter().any(|e| {
1421                e.path.to_string_lossy().contains("ignored")
1422                    && e.event_type != EventType::DebugWatch
1423            });
1424            assert!(
1425                !has_ignored_events,
1426                "Expected no events for ignored files, but got: {:?}",
1427                events
1428            );
1429        }
1430        tracker.lock().unwrap().clear();
1431
1432        // Normal files should still work
1433        std::fs::write(test_dir.join("normal.txt"), "").unwrap();
1434        tokio::time::sleep(Duration::from_millis(200)).await;
1435
1436        {
1437            let events = tracker.lock().unwrap();
1438            let has_normal = events
1439                .iter()
1440                .any(|e| e.path == PathBuf::from("normal.txt"));
1441            assert!(has_normal, "Expected event for normal.txt, got: {:?}", events);
1442        }
1443
1444        // Cleanup
1445        watcher_handle.abort();
1446        let _ = std::fs::remove_dir_all(&test_dir);
1447    }
1448}