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                if pattern_segment == &Segment::DoubleWildcard
337                    && pattern_index == pattern_segments.len() - 1
338                {
339                    return true;
340                }
341                return allow_prefix;
342            }
343
344            match &pattern_segment {
345                Segment::Exact(s) => {
346                    if s != &path_segments[path_index] {
347                        return false;
348                    }
349                    path_index += 1;
350                }
351                Segment::Wildcard(p) => {
352                    if !p.matches(&path_segments[path_index]) {
353                        return false;
354                    }
355                    path_index += 1;
356                }
357                Segment::DoubleWildcard => {
358                    if allow_prefix {
359                        return true;
360                    }
361
362                    let patterns_left = pattern_segments.len() - (pattern_index + 1);
363                    let next_path_index = path_segments.len() - patterns_left;
364                    if next_path_index < path_index {
365                        return false;
366                    }
367                    path_index = next_path_index;
368                }
369            }
370        }
371
372        allow_prefix || path_index == path_segments.len()
373    }
374}
375
376// --- Inotify Wrapper ---
377
378struct Inotify {
379    fd: AsyncFd<i32>,
380}
381
382impl Inotify {
383    fn new() -> Result<Self> {
384        let fd = unsafe { libc::inotify_init1(libc::IN_NONBLOCK | libc::IN_CLOEXEC) };
385        if fd < 0 {
386            return Err(std::io::Error::last_os_error());
387        }
388        Ok(Self {
389            fd: AsyncFd::new(fd)?,
390        })
391    }
392
393    fn add_watch(&self, path: &Path, mask: u32) -> Result<i32> {
394        let c_path = CString::new(path.as_os_str().as_bytes())
395            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
396        let wd = unsafe { libc::inotify_add_watch(self.fd.as_raw_fd(), c_path.as_ptr(), mask) };
397        if wd < 0 {
398            return Err(std::io::Error::last_os_error());
399        }
400        Ok(wd)
401    }
402
403    async fn read_events(&self, buffer: &mut [u8]) -> Result<usize> {
404        loop {
405            let mut guard = self.fd.readable().await?;
406            match guard.try_io(|inner| {
407                let res = unsafe {
408                    libc::read(
409                        inner.as_raw_fd(),
410                        buffer.as_mut_ptr() as *mut _,
411                        buffer.len(),
412                    )
413                };
414                if res < 0 {
415                    Err(std::io::Error::last_os_error())
416                } else {
417                    Ok(res as usize)
418                }
419            }) {
420                Ok(Ok(len)) => return Ok(len),
421                Ok(Err(e)) => {
422                    if e.kind() == std::io::ErrorKind::WouldBlock {
423                        continue;
424                    }
425                    return Err(e);
426                }
427                Err(_) => continue,
428            }
429        }
430    }
431}
432
433impl Drop for Inotify {
434    fn drop(&mut self) {
435        unsafe { libc::close(self.fd.as_raw_fd()) };
436    }
437}
438
439// --- Helper Functions ---
440
441fn resolve_base_dir(base_dir: PathBuf) -> PathBuf {
442    if base_dir.is_absolute() {
443        base_dir
444    } else {
445        std::env::current_dir()
446            .unwrap_or_else(|_| PathBuf::from("/"))
447            .join(base_dir)
448    }
449}
450
451fn path_to_segments(path: &Path) -> Vec<String> {
452    let path_str = path.to_string_lossy();
453    let path_str = path_str.replace("//", "/");
454    path_str
455        .split('/')
456        .filter(|s| !s.is_empty())
457        .map(|s| s.to_string())
458        .collect()
459}
460
461fn should_watch(
462    relative_path: &Path,
463    include_patterns: &[Pattern],
464    exclude_patterns: &[Pattern],
465    is_dir: bool,
466) -> bool {
467    let segments = path_to_segments(relative_path);
468
469    if exclude_patterns.iter().any(|p| p.check(&segments, false)) {
470        return false;
471    }
472
473    include_patterns.iter().any(|p| p.check(&segments, is_dir))
474}
475
476fn add_watch_recursive<F>(
477    start_rel_path: PathBuf,
478    root: &Path,
479    inotify: &Inotify,
480    watches: &mut HashMap<i32, PathBuf>,
481    paths: &mut HashSet<PathBuf>,
482    include_patterns: &[Pattern],
483    exclude_patterns: &[Pattern],
484    debug_watches_enabled: bool,
485    return_absolute: bool,
486    callback: &mut F,
487) where
488    F: FnMut(WatchEvent, PathBuf),
489{
490    let mut stack = vec![start_rel_path];
491    while let Some(rel_path) = stack.pop() {
492        if !should_watch(&rel_path, include_patterns, exclude_patterns, true) {
493            continue;
494        }
495
496        if paths.contains(&rel_path) {
497            continue;
498        }
499
500        let full_path = if rel_path.as_os_str().is_empty() {
501            root.to_path_buf()
502        } else {
503            root.join(&rel_path)
504        };
505
506        if !full_path.is_dir() {
507            continue;
508        }
509
510        let mask = libc::IN_MODIFY
511            | libc::IN_CLOSE_WRITE
512            | libc::IN_CREATE
513            | libc::IN_DELETE
514            | libc::IN_MOVED_FROM
515            | libc::IN_MOVED_TO
516            | libc::IN_DONT_FOLLOW;
517        match inotify.add_watch(&full_path, mask as u32) {
518            Ok(wd) => {
519                paths.insert(rel_path.clone());
520                watches.insert(wd, rel_path.clone());
521
522                if debug_watches_enabled {
523                    let callback_path = if return_absolute {
524                        full_path.clone()
525                    } else {
526                        rel_path.clone()
527                    };
528                    callback(WatchEvent::DebugWatch, callback_path);
529                }
530
531                if let Ok(entries) = std::fs::read_dir(&full_path) {
532                    for entry in entries.flatten() {
533                        if let Ok(ft) = entry.file_type() {
534                            if ft.is_dir() {
535                                let child_rel_path = if rel_path.as_os_str().is_empty() {
536                                    PathBuf::from(entry.file_name())
537                                } else {
538                                    rel_path.join(entry.file_name())
539                                };
540                                stack.push(child_rel_path);
541                            }
542                        }
543                    }
544                }
545            }
546            Err(e) => {
547                eprintln!("{}", e);
548            }
549        }
550    }
551}
552
553fn find_watch_start_dir(pattern: &Pattern, root: &Path) -> PathBuf {
554    let mut current_path = PathBuf::new();
555    let mut found_wildcard = false;
556
557    for segment in &pattern.segments {
558        match segment {
559            Segment::Exact(s) => {
560                if !found_wildcard {
561                    current_path.push(s);
562                }
563            }
564            _ => {
565                found_wildcard = true;
566                break;
567            }
568        }
569    }
570
571    if found_wildcard && !current_path.as_os_str().is_empty() {
572        current_path.pop();
573    }
574
575    if !found_wildcard && !current_path.as_os_str().is_empty() {
576        current_path.pop();
577    }
578
579    loop {
580        let full_path = if current_path.as_os_str().is_empty() {
581            root.to_path_buf()
582        } else {
583            root.join(&current_path)
584        };
585
586        if full_path.exists() && full_path.is_dir() {
587            break;
588        }
589
590        if current_path.as_os_str().is_empty() {
591            break;
592        }
593
594        current_path.pop();
595    }
596
597    current_path
598}
599
600fn parse_inotify_events(buffer: &[u8], len: usize) -> Vec<(i32, u32, String)> {
601    let mut events = Vec::new();
602    let mut ptr = buffer.as_ptr();
603    let end = unsafe { ptr.add(len) };
604
605    while ptr < end {
606        let event = unsafe { &*(ptr as *const libc::inotify_event) };
607        let name_len = event.len as usize;
608
609        if name_len > 0 {
610            let name_ptr = unsafe { ptr.add(std::mem::size_of::<libc::inotify_event>()) };
611            let name_slice =
612                unsafe { std::slice::from_raw_parts(name_ptr as *const u8, name_len) };
613            let name_str = String::from_utf8_lossy(name_slice)
614                .trim_matches(char::from(0))
615                .to_string();
616            events.push((event.wd, event.mask, name_str));
617        }
618
619        ptr = unsafe { ptr.add(std::mem::size_of::<libc::inotify_event>() + name_len) };
620    }
621
622    events
623}
624
625/// Type of file system event.
626///
627/// These events correspond to inotify events, but are simplified into three
628/// categories that cover most use cases.
629#[derive(Debug, Clone, Copy, PartialEq, Eq)]
630pub enum WatchEvent {
631    /// File or directory was created.
632    ///
633    /// Also triggered when a file/directory is moved *into* a watched directory.
634    Create,
635    /// File or directory was deleted.
636    ///
637    /// Also triggered when a file/directory is moved *out of* a watched directory.
638    Delete,
639    /// File content was modified.
640    ///
641    /// Triggered on `IN_MODIFY` (content changed) or `IN_CLOSE_WRITE` (file
642    /// opened for writing was closed). Directory content changes (files added/removed)
643    /// are reported as [`Create`](WatchEvent::Create)/[`Delete`](WatchEvent::Delete) instead.
644    Update,
645    /// Debug event: a watch was added on this directory.
646    ///
647    /// Only emitted when [`WatchBuilder::debug_watches`] is enabled. Useful for
648    /// understanding which directories are being watched based on your patterns.
649    DebugWatch,
650}
651
652/// Builder for configuring and running a file watcher.
653///
654/// Use method chaining to configure the watcher, then call [`run`](WatchBuilder::run)
655/// or [`run_debounced`](WatchBuilder::run_debounced) to start watching.
656///
657/// # Example
658///
659/// ```no_run
660/// use include_exclude_watcher::WatchBuilder;
661///
662/// # async fn example() -> std::io::Result<()> {
663/// WatchBuilder::new()
664///     .set_base_dir("/project")
665///     .add_include("src/**/*.rs")
666///     .add_include("Cargo.toml")
667///     .add_exclude("**/target/**")
668///     .run(|event, path| {
669///         println!("{:?}: {}", event, path.display());
670///     })
671///     .await
672/// # }
673/// ```
674pub struct WatchBuilder {
675    includes: Option<Vec<String>>,
676    excludes: Vec<String>,
677    base_dir: PathBuf,
678    watch_create: bool,
679    watch_delete: bool,
680    watch_update: bool,
681    match_files: bool,
682    match_dirs: bool,
683    return_absolute: bool,
684    debug_watches_enabled: bool,
685}
686
687impl Default for WatchBuilder {
688    fn default() -> Self {
689        Self::new()
690    }
691}
692
693impl WatchBuilder {
694    /// Create a new file watcher builder with default settings.
695    ///
696    /// Defaults:
697    /// - Base directory: current working directory
698    /// - Includes: none (must be added, or watches everything)
699    /// - Excludes: none
700    /// - Event types: create, delete, update all enabled
701    /// - Match types: both files and directories
702    /// - Path format: relative paths
703    pub fn new() -> Self {
704        WatchBuilder {
705            includes: Some(Vec::new()),
706            excludes: Vec::new(),
707            base_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")),
708            watch_create: true,
709            watch_delete: true,
710            watch_update: true,
711            match_files: true,
712            match_dirs: true,
713            return_absolute: false,
714            debug_watches_enabled: false,
715        }
716    }
717
718    /// Enable debug watch events.
719    ///
720    /// When enabled, [`WatchEvent::DebugWatch`] events will be emitted for each
721    /// directory that is watched. Useful for debugging pattern matching.
722    pub fn debug_watches(mut self, enabled: bool) -> Self {
723        self.debug_watches_enabled = enabled;
724        self
725    }
726
727    /// Add a single include pattern.
728    ///
729    /// Patterns use glob syntax:
730    /// - `*` matches any sequence of characters except `/`
731    /// - `**` matches any sequence of characters including `/`
732    /// - `?` matches any single character except `/`
733    /// - `[abc]` matches any character in the set
734    ///
735    /// Patterns without a `/` match anywhere in the tree (like gitignore).
736    /// For example, `*.rs` is equivalent to `**/*.rs`.
737    pub fn add_include(mut self, pattern: impl Into<String>) -> Self {
738        if self.includes.is_none() {
739            self.includes = Some(Vec::new());
740        }
741        self.includes.as_mut().unwrap().push(pattern.into());
742        self
743    }
744
745    /// Add multiple include patterns.
746    pub fn add_includes(mut self, patterns: impl IntoIterator<Item = impl Into<String>>) -> Self {
747        if self.includes.is_none() {
748            self.includes = Some(Vec::new());
749        }
750        self.includes
751            .as_mut()
752            .unwrap()
753            .extend(patterns.into_iter().map(|p| p.into()));
754        self
755    }
756
757    /// Add a single exclude pattern.
758    ///
759    /// Excludes take precedence over includes. Uses the same glob syntax as includes.
760    pub fn add_exclude(mut self, pattern: impl Into<String>) -> Self {
761        self.excludes.push(pattern.into());
762        self
763    }
764
765    /// Add multiple exclude patterns.
766    pub fn add_excludes(mut self, patterns: impl IntoIterator<Item = impl Into<String>>) -> Self {
767        self.excludes
768            .extend(patterns.into_iter().map(|p| p.into()));
769        self
770    }
771
772    /// Add patterns from a gitignore-style file.
773    ///
774    /// Lines starting with `#` are comments. All other non-empty lines are
775    /// exclude patterns. Note: `!` negation patterns are not supported (a
776    /// warning will be printed) because excludes always take precedence over
777    /// includes in this library.
778    ///
779    /// If the file doesn't exist, this method does nothing (no error).
780    ///
781    /// # Example
782    ///
783    /// ```no_run
784    /// use include_exclude_watcher::WatchBuilder;
785    ///
786    /// # async fn example() -> std::io::Result<()> {
787    /// WatchBuilder::new()
788    ///     .set_base_dir("/project")
789    ///     .add_include("*")
790    ///     .add_ignore_file(".gitignore")
791    ///     .add_ignore_file(".watchignore")
792    ///     .run(|event, path| {
793    ///         println!("{:?}: {}", event, path.display());
794    ///     })
795    ///     .await
796    /// # }
797    /// ```
798    pub fn add_ignore_file(mut self, path: impl AsRef<Path>) -> Self {
799        let path = path.as_ref();
800
801        // Resolve relative to base_dir
802        let full_path = if path.is_absolute() {
803            path.to_path_buf()
804        } else {
805            self.base_dir.join(path)
806        };
807
808        if let Ok(file) = fs::File::open(&full_path) {
809            let reader = BufReader::new(file);
810            let mut has_negation = false;
811            for line in reader.lines().map_while(Result::ok) {
812                let trimmed = line.trim();
813
814                // Skip empty lines and comments
815                if trimmed.is_empty() || trimmed.starts_with('#') {
816                    continue;
817                }
818
819                // Lines starting with ! are negations - not supported
820                if trimmed.starts_with('!') {
821                    has_negation = true;
822                } else {
823                    // Regular lines are exclude patterns
824                    self.excludes.push(trimmed.to_string());
825                }
826            }
827            if has_negation {
828                println!("Warning: negation patterns (!) in {} are ignored; excludes always take precedence over includes in this library", full_path.display());
829            }
830        }
831
832        self
833    }
834
835    /// Set the base directory for watching.
836    ///
837    /// All patterns are relative to this directory. Defaults to the current
838    /// working directory.
839    pub fn set_base_dir(mut self, base_dir: impl Into<PathBuf>) -> Self {
840        self.base_dir = base_dir.into();
841        self
842    }
843
844    /// Set whether to watch for file/directory creation events.
845    ///
846    /// Default: `true`
847    pub fn watch_create(mut self, enabled: bool) -> Self {
848        self.watch_create = enabled;
849        self
850    }
851
852    /// Set whether to watch for file/directory deletion events.
853    ///
854    /// Default: `true`
855    pub fn watch_delete(mut self, enabled: bool) -> Self {
856        self.watch_delete = enabled;
857        self
858    }
859
860    /// Set whether to watch for file modification events.
861    ///
862    /// Default: `true`
863    pub fn watch_update(mut self, enabled: bool) -> Self {
864        self.watch_update = enabled;
865        self
866    }
867
868    /// Set whether to match regular files.
869    ///
870    /// Default: `true`
871    pub fn match_files(mut self, enabled: bool) -> Self {
872        self.match_files = enabled;
873        self
874    }
875
876    /// Set whether to match directories.
877    ///
878    /// Default: `true`
879    pub fn match_dirs(mut self, enabled: bool) -> Self {
880        self.match_dirs = enabled;
881        self
882    }
883
884    /// Set whether to return absolute paths.
885    ///
886    /// When `false` (default), paths passed to the callback are relative to
887    /// the base directory. When `true`, paths are absolute.
888    pub fn return_absolute(mut self, enabled: bool) -> Self {
889        self.return_absolute = enabled;
890        self
891    }
892
893    /// Run the watcher with the provided callback.
894    ///
895    /// This method runs forever, calling the callback for each matching event.
896    /// The callback receives the event type and the path (relative or absolute
897    /// depending on configuration).
898    ///
899    /// If no include patterns are specified, watches everything.
900    pub async fn run<F>(self, callback: F) -> Result<()>
901    where
902        F: FnMut(WatchEvent, PathBuf),
903    {
904        self.run_internal(callback, None).await
905    }
906
907    /// Run the watcher with debouncing.
908    ///
909    /// Waits for file changes, then waits until no changes have occurred for
910    /// at least `ms` milliseconds before calling the callback. This is useful
911    /// for batching rapid changes (like when a build tool writes many files).
912    ///
913    /// The callback takes no arguments since the specific paths are not tracked
914    /// during debouncing.
915    pub async fn run_debounced<F>(self, ms: u64, mut callback: F) -> Result<()>
916    where
917        F: FnMut(),
918    {
919        self.run_internal(|_, _| callback(), Some(Duration::from_millis(ms))).await
920    }
921
922    async fn run_internal<F>(self, mut callback: F, debounce: Option<Duration>) -> Result<()>
923    where
924        F: FnMut(WatchEvent, PathBuf),
925    {
926        let includes = if let Some(includes) = self.includes {
927            includes
928        } else {
929            vec!["**".to_string()]
930        };
931
932        // If no includes are specified, just sleep forever
933        if includes.is_empty() {
934            loop {
935                tokio::time::sleep(Duration::from_secs(3600)).await;
936            }
937        }
938
939        let excludes = self.excludes;
940        let root = self.base_dir.clone();
941        let watch_create = self.watch_create;
942        let watch_delete = self.watch_delete;
943        let watch_update = self.watch_update;
944        let match_files = self.match_files;
945        let match_dirs = self.match_dirs;
946        let return_absolute = self.return_absolute;
947        let debug_watches_enabled = self.debug_watches_enabled;
948
949        let root = resolve_base_dir(root);
950
951        let include_patterns: Vec<Pattern> = includes.iter().map(|p| Pattern::parse(p)).collect();
952        let exclude_patterns: Vec<Pattern> = excludes.iter().map(|p| Pattern::parse(p)).collect();
953
954        let inotify = Inotify::new()?;
955        let mut watches = HashMap::<i32, PathBuf>::new();
956        let mut paths = HashSet::<PathBuf>::new();
957
958        // Initial scan
959        for pattern in &include_patterns {
960            let watch_dir = find_watch_start_dir(pattern, &root);
961            add_watch_recursive(
962                watch_dir,
963                &root,
964                &inotify,
965                &mut watches,
966                &mut paths,
967                &include_patterns,
968                &exclude_patterns,
969                debug_watches_enabled,
970                return_absolute,
971                &mut callback,
972            );
973        }
974
975        // Debouncing state
976        let mut debounce_deadline: Option<tokio::time::Instant> = None;
977
978        // Event loop
979        let mut buffer = [0u8; 8192];
980        loop {
981            // Calculate timeout for debouncing
982            let read_future = inotify.read_events(&mut buffer);
983            
984            let read_result = if let Some(deadline) = debounce_deadline {
985                let now = tokio::time::Instant::now();
986                if deadline <= now {
987                    // Timer expired, fire callback and reset
988                    debounce_deadline = None;
989                    callback(WatchEvent::Update, PathBuf::new());
990                    continue;
991                }
992                // Wait with timeout
993                match tokio::time::timeout(deadline - now, read_future).await {
994                    Ok(result) => Some(result),
995                    Err(_) => {
996                        // Timeout expired, fire callback
997                        debounce_deadline = None;
998                        callback(WatchEvent::Update, PathBuf::new());
999                        continue;
1000                    }
1001                }
1002            } else {
1003                Some(read_future.await)
1004            };
1005
1006            let Some(result) = read_result else { continue };
1007            
1008            match result {
1009                Ok(len) => {
1010                    let events = parse_inotify_events(&buffer, len);
1011                    let mut had_matching_event = false;
1012
1013                    for (wd, mask, name_str) in events {
1014                        let rel_path = {
1015                            if (mask & libc::IN_IGNORED as u32) != 0 {
1016                                if let Some(path) = watches.remove(&wd) {
1017                                    paths.remove(&path);
1018                                }
1019                                continue;
1020                            }
1021                            if let Some(dir_path) = watches.get(&wd) {
1022                                Some(dir_path.join(&name_str))
1023                            } else {
1024                                None
1025                            }
1026                        };
1027
1028                        if let Some(rel_path) = rel_path {
1029                            if (mask & libc::IN_ISDIR as u32) != 0 {
1030                                if (mask & libc::IN_CREATE as u32) != 0
1031                                    || (mask & libc::IN_MOVED_TO as u32) != 0
1032                                {
1033                                    add_watch_recursive(
1034                                        rel_path.clone(),
1035                                        &root,
1036                                        &inotify,
1037                                        &mut watches,
1038                                        &mut paths,
1039                                        &include_patterns,
1040                                        &exclude_patterns,
1041                                        debug_watches_enabled,
1042                                        return_absolute,
1043                                        &mut callback,
1044                                    );
1045                                }
1046                            }
1047
1048                            if should_watch(&rel_path, &include_patterns, &exclude_patterns, false)
1049                            {
1050                                let is_create = (mask & libc::IN_CREATE as u32) != 0
1051                                    || (mask & libc::IN_MOVED_TO as u32) != 0;
1052                                let is_delete = (mask & libc::IN_DELETE as u32) != 0
1053                                    || (mask & libc::IN_MOVED_FROM as u32) != 0;
1054                                let is_update = (mask & libc::IN_MODIFY as u32) != 0
1055                                    || (mask & libc::IN_CLOSE_WRITE as u32) != 0;
1056
1057                                let event_type = if is_create && watch_create {
1058                                    Some(WatchEvent::Create)
1059                                } else if is_delete && watch_delete {
1060                                    Some(WatchEvent::Delete)
1061                                } else if is_update && watch_update {
1062                                    Some(WatchEvent::Update)
1063                                } else {
1064                                    None
1065                                };
1066
1067                                if let Some(event_type) = event_type {
1068                                    let is_dir = (mask & libc::IN_ISDIR as u32) != 0;
1069                                    let should_match_type = if is_dir { match_dirs } else { match_files };
1070
1071                                    if should_match_type {
1072                                        had_matching_event = true;
1073                                        
1074                                        // Only call immediately if not debouncing
1075                                        if debounce.is_none() {
1076                                            let callback_path = if return_absolute {
1077                                                if rel_path.as_os_str().is_empty() {
1078                                                    root.clone()
1079                                                } else {
1080                                                    root.join(&rel_path)
1081                                                }
1082                                            } else {
1083                                                rel_path
1084                                            };
1085                                            callback(event_type, callback_path);
1086                                        }
1087                                    }
1088                                }
1089                            }
1090                        }
1091                    }
1092                    
1093                    // If debouncing and we had events, reset the timer
1094                    if let Some(d) = debounce {
1095                        if had_matching_event {
1096                            debounce_deadline = Some(tokio::time::Instant::now() + d);
1097                        }
1098                    }
1099                }
1100                Err(e) => {
1101                    eprintln!("Error reading inotify events: {}", e);
1102                    tokio::time::sleep(Duration::from_millis(100)).await;
1103                }
1104            }
1105        }
1106    }
1107}
1108
1109#[cfg(test)]
1110mod tests {
1111    use super::*;
1112    use std::collections::HashSet;
1113    use std::sync::{Arc, Mutex};
1114    use tokio::task::JoinHandle;
1115
1116    #[derive(Debug, Clone, PartialEq, Eq, Hash)]
1117    enum EventType {
1118        Create,
1119        Delete,
1120        Update,
1121        DebugWatch,
1122    }
1123
1124    #[derive(Debug, Clone, PartialEq, Eq, Hash)]
1125    struct Event {
1126        path: PathBuf,
1127        event_type: EventType,
1128    }
1129
1130    type EventTracker = Arc<Mutex<Vec<Event>>>;
1131
1132    struct TestInstance {
1133        test_dir: PathBuf,
1134        tracker: EventTracker,
1135        watcher_handle: Option<JoinHandle<()>>,
1136    }
1137
1138    impl TestInstance {
1139        async fn new<F>(test_name: &str, configure: F) -> Self
1140        where
1141            F: FnOnce(WatchBuilder) -> WatchBuilder + Send + 'static,
1142        {
1143            let test_dir = std::env::current_dir()
1144                .unwrap()
1145                .join(format!(".file-watcher-test-{}", test_name));
1146
1147            if test_dir.exists() {
1148                std::fs::remove_dir_all(&test_dir).unwrap();
1149            }
1150            std::fs::create_dir(&test_dir).unwrap();
1151
1152            let tracker = Arc::new(Mutex::new(Vec::new()));
1153
1154            let tracker_clone = tracker.clone();
1155            let test_dir_clone = test_dir.clone();
1156
1157            let watcher_handle = tokio::spawn(async move {
1158                let builder = WatchBuilder::new()
1159                    .set_base_dir(&test_dir_clone)
1160                    .debug_watches(true);
1161
1162                let builder = configure(builder);
1163
1164                let _ = builder
1165                    .run(move |event_type, path| {
1166                        tracker_clone.lock().unwrap().push(Event {
1167                            path: path.clone(),
1168                            event_type: match event_type {
1169                                WatchEvent::Create => EventType::Create,
1170                                WatchEvent::Delete => EventType::Delete,
1171                                WatchEvent::Update => EventType::Update,
1172                                WatchEvent::DebugWatch => EventType::DebugWatch,
1173                            },
1174                        });
1175                    })
1176                    .await;
1177            });
1178
1179            tokio::time::sleep(Duration::from_millis(100)).await;
1180
1181            let instance = Self {
1182                test_dir,
1183                tracker,
1184                watcher_handle: Some(watcher_handle),
1185            };
1186
1187            instance.assert_events(&[], &[], &[], &[""]).await;
1188
1189            instance
1190        }
1191
1192        fn create_dir(&self, path: &str) {
1193            std::fs::create_dir(self.test_dir.join(path)).unwrap();
1194        }
1195
1196        fn write_file(&self, path: &str, content: &str) {
1197            let full_path = self.test_dir.join(path);
1198            if let Some(parent) = full_path.parent() {
1199                std::fs::create_dir_all(parent).unwrap();
1200            }
1201            std::fs::write(full_path, content).unwrap();
1202        }
1203
1204        fn remove_file(&self, path: &str) {
1205            std::fs::remove_file(self.test_dir.join(path)).unwrap();
1206        }
1207
1208        fn rename(&self, from: &str, to: &str) {
1209            std::fs::rename(self.test_dir.join(from), self.test_dir.join(to)).unwrap();
1210        }
1211
1212        async fn assert_events(
1213            &self,
1214            creates: &[&str],
1215            deletes: &[&str],
1216            updates: &[&str],
1217            watches: &[&str],
1218        ) {
1219            tokio::time::sleep(Duration::from_millis(200)).await;
1220
1221            let events = self.tracker.lock().unwrap().clone();
1222            let mut expected = HashSet::new();
1223
1224            for create in creates {
1225                expected.insert(Event {
1226                    path: PathBuf::from(create),
1227                    event_type: EventType::Create,
1228                });
1229            }
1230
1231            for delete in deletes {
1232                expected.insert(Event {
1233                    path: PathBuf::from(delete),
1234                    event_type: EventType::Delete,
1235                });
1236            }
1237
1238            for update in updates {
1239                expected.insert(Event {
1240                    path: PathBuf::from(update),
1241                    event_type: EventType::Update,
1242                });
1243            }
1244
1245            for watch in watches {
1246                expected.insert(Event {
1247                    path: PathBuf::from(watch),
1248                    event_type: EventType::DebugWatch,
1249                });
1250            }
1251
1252            let actual: HashSet<Event> = events.iter().cloned().collect();
1253
1254            for event in &actual {
1255                if !expected.contains(event) {
1256                    panic!("Unexpected event: {:?}", event);
1257                }
1258            }
1259
1260            for event in &expected {
1261                if !actual.contains(event) {
1262                    panic!(
1263                        "Missing expected event: {:?}\nActual events: {:?}",
1264                        event, actual
1265                    );
1266                }
1267            }
1268
1269            self.tracker.lock().unwrap().clear();
1270        }
1271
1272        async fn assert_no_events(&self) {
1273            tokio::time::sleep(Duration::from_millis(500)).await;
1274            let events = self.tracker.lock().unwrap();
1275            assert_eq!(
1276                events.len(),
1277                0,
1278                "Expected no events, but got: {:?}",
1279                events
1280            );
1281        }
1282    }
1283
1284    impl Drop for TestInstance {
1285        fn drop(&mut self) {
1286            if let Some(handle) = self.watcher_handle.take() {
1287                handle.abort();
1288            }
1289            if self.test_dir.exists() {
1290                let _ = std::fs::remove_dir_all(&self.test_dir);
1291            }
1292        }
1293    }
1294
1295    #[tokio::test]
1296    async fn test_file_create_update_delete() {
1297        let test = TestInstance::new("create_update_delete", |b| b.add_include("**/*")).await;
1298
1299        test.write_file("test.txt", "");
1300        test.assert_events(&["test.txt"], &[], &["test.txt"], &[])
1301            .await;
1302
1303        test.write_file("test.txt", "hello");
1304        test.assert_events(&[], &[], &["test.txt"], &[]).await;
1305
1306        test.remove_file("test.txt");
1307        test.assert_events(&[], &["test.txt"], &[], &[]).await;
1308    }
1309
1310    #[tokio::test]
1311    async fn test_directory_operations() {
1312        let test = TestInstance::new("directory_operations", |b| b.add_include("**/*")).await;
1313
1314        test.create_dir("subdir");
1315        test.assert_events(&["subdir"], &[], &[], &["subdir"]).await;
1316
1317        test.write_file("subdir/file.txt", "");
1318        test.assert_events(&["subdir/file.txt"], &[], &["subdir/file.txt"], &[])
1319            .await;
1320    }
1321
1322    #[tokio::test]
1323    async fn test_move_operations() {
1324        let test = TestInstance::new("move_operations", |b| b.add_include("**/*")).await;
1325
1326        test.write_file("old.txt", "content");
1327        test.assert_events(&["old.txt"], &[], &["old.txt"], &[])
1328            .await;
1329
1330        test.rename("old.txt", "new.txt");
1331        test.assert_events(&["new.txt"], &["old.txt"], &[], &[])
1332            .await;
1333    }
1334
1335    #[tokio::test]
1336    async fn test_event_filtering() {
1337        let test = TestInstance::new("event_filtering", |b| {
1338            b.add_include("**/*")
1339                .watch_create(true)
1340                .watch_delete(false)
1341                .watch_update(false)
1342        })
1343        .await;
1344
1345        test.write_file("test.txt", "");
1346        test.assert_events(&["test.txt"], &[], &[], &[]).await;
1347
1348        test.write_file("test.txt", "hello");
1349        test.assert_no_events().await;
1350
1351        test.remove_file("test.txt");
1352        test.assert_no_events().await;
1353    }
1354
1355    #[tokio::test]
1356    async fn test_pattern_matching() {
1357        let test = TestInstance::new("pattern_matching", |b| b.add_include("**/*.txt")).await;
1358
1359        test.write_file("test.txt", "");
1360        test.assert_events(&["test.txt"], &[], &["test.txt"], &[])
1361            .await;
1362
1363        test.write_file("test.rs", "");
1364        test.assert_no_events().await;
1365    }
1366
1367    #[tokio::test]
1368    async fn test_exclude_prevents_watching() {
1369        let test = TestInstance::new("exclude_prevents_watch", |b| {
1370            b.add_include("**/*").add_exclude("node_modules/**")
1371        })
1372        .await;
1373
1374        test.create_dir("node_modules");
1375        tokio::time::sleep(Duration::from_millis(200)).await;
1376
1377        test.write_file("node_modules/package.json", "");
1378        test.assert_no_events().await;
1379
1380        test.write_file("test.txt", "");
1381        test.assert_events(&["test.txt"], &[], &["test.txt"], &[])
1382            .await;
1383    }
1384
1385    #[tokio::test]
1386    async fn test_pattern_file() {
1387        // Setup: create test directory manually and write pattern file first
1388        let test_dir = std::env::current_dir()
1389            .unwrap()
1390            .join(".file-watcher-test-pattern_file");
1391
1392        if test_dir.exists() {
1393            std::fs::remove_dir_all(&test_dir).unwrap();
1394        }
1395        std::fs::create_dir(&test_dir).unwrap();
1396
1397        // Write pattern file before starting watcher
1398        std::fs::write(
1399            test_dir.join(".watchignore"),
1400            "# Comment line\nignored/**\n",
1401        )
1402        .unwrap();
1403
1404        // Now create watcher with pattern file
1405        let tracker = Arc::new(Mutex::new(Vec::<Event>::new()));
1406        let tracker_clone = tracker.clone();
1407        let test_dir_clone = test_dir.clone();
1408
1409        let watcher_handle = tokio::spawn(async move {
1410            let _ = WatchBuilder::new()
1411                .set_base_dir(&test_dir_clone)
1412                .debug_watches(true)
1413                .add_include("**/*")
1414                .add_ignore_file(".watchignore")
1415                .run(move |event_type, path| {
1416                    tracker_clone.lock().unwrap().push(Event {
1417                        path: path.clone(),
1418                        event_type: match event_type {
1419                            WatchEvent::Create => EventType::Create,
1420                            WatchEvent::Delete => EventType::Delete,
1421                            WatchEvent::Update => EventType::Update,
1422                            WatchEvent::DebugWatch => EventType::DebugWatch,
1423                        },
1424                    });
1425                })
1426                .await;
1427        });
1428
1429        tokio::time::sleep(Duration::from_millis(100)).await;
1430        tracker.lock().unwrap().clear(); // Clear initial watch event
1431
1432        // Create ignored directory
1433        std::fs::create_dir(test_dir.join("ignored")).unwrap();
1434        tokio::time::sleep(Duration::from_millis(200)).await;
1435
1436        // Files in ignored/ should not trigger events (because of exclude)
1437        std::fs::write(test_dir.join("ignored/test.txt"), "").unwrap();
1438        tokio::time::sleep(Duration::from_millis(200)).await;
1439
1440        // Check no events for ignored files
1441        {
1442            let events = tracker.lock().unwrap();
1443            let has_ignored_events = events.iter().any(|e| {
1444                e.path.to_string_lossy().contains("ignored")
1445                    && e.event_type != EventType::DebugWatch
1446            });
1447            assert!(
1448                !has_ignored_events,
1449                "Expected no events for ignored files, but got: {:?}",
1450                events
1451            );
1452        }
1453        tracker.lock().unwrap().clear();
1454
1455        // Normal files should still work
1456        std::fs::write(test_dir.join("normal.txt"), "").unwrap();
1457        tokio::time::sleep(Duration::from_millis(200)).await;
1458
1459        {
1460            let events = tracker.lock().unwrap();
1461            let has_normal = events
1462                .iter()
1463                .any(|e| e.path == PathBuf::from("normal.txt"));
1464            assert!(has_normal, "Expected event for normal.txt, got: {:?}", events);
1465        }
1466
1467        // Cleanup
1468        watcher_handle.abort();
1469        let _ = std::fs::remove_dir_all(&test_dir);
1470    }
1471}