Skip to main content

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