Skip to main content

oxihuman_core/
file_watcher_stub.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3#![allow(dead_code)]
4
5//! File system watcher with debouncing, glob filtering, event batching,
6//! recursive directory watching, and dynamic watch management.
7
8use std::collections::{HashMap, HashSet};
9use std::path::Path;
10use std::time::{Duration, Instant};
11
12/// Errors that can occur during file-watch operations.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum WatchError {
15    EmptyPath,
16    InvalidPath(String),
17    AlreadyWatched(String),
18    NotWatched(String),
19    InvalidGlob(String),
20}
21
22impl std::fmt::Display for WatchError {
23    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24        match self {
25            WatchError::EmptyPath => write!(f, "path must not be empty"),
26            WatchError::InvalidPath(p) => write!(f, "invalid path: {p}"),
27            WatchError::AlreadyWatched(p) => write!(f, "path already watched: {p}"),
28            WatchError::NotWatched(p) => write!(f, "path not watched: {p}"),
29            WatchError::InvalidGlob(g) => write!(f, "invalid glob pattern: {g}"),
30        }
31    }
32}
33
34impl std::error::Error for WatchError {}
35
36/// A file system event type.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum FsEvent {
39    Created(String),
40    Modified(String),
41    Deleted(String),
42    Renamed { from: String, to: String },
43}
44
45impl FsEvent {
46    pub fn path(&self) -> &str {
47        match self {
48            FsEvent::Created(p) | FsEvent::Modified(p) | FsEvent::Deleted(p) => p,
49            FsEvent::Renamed { from, .. } => from,
50        }
51    }
52
53    pub fn kind(&self) -> &'static str {
54        match self {
55            FsEvent::Created(_) => "created",
56            FsEvent::Modified(_) => "modified",
57            FsEvent::Deleted(_) => "deleted",
58            FsEvent::Renamed { .. } => "renamed",
59        }
60    }
61}
62
63/// A batch of events that occurred within the same debounce window.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct EventBatch {
66    events: Vec<FsEvent>,
67}
68
69impl EventBatch {
70    pub fn new(events: Vec<FsEvent>) -> Self {
71        Self { events }
72    }
73
74    pub fn events(&self) -> &[FsEvent] {
75        &self.events
76    }
77
78    pub fn into_events(self) -> Vec<FsEvent> {
79        self.events
80    }
81
82    pub fn len(&self) -> usize {
83        self.events.len()
84    }
85
86    pub fn is_empty(&self) -> bool {
87        self.events.is_empty()
88    }
89
90    pub fn unique_paths(&self) -> HashSet<&str> {
91        self.events.iter().map(|e| e.path()).collect()
92    }
93}
94
95/// A compiled glob pattern used for filtering watched paths.
96#[derive(Debug, Clone)]
97pub struct GlobPattern {
98    raw: String,
99    segments: Vec<GlobSegment>,
100}
101
102#[derive(Debug, Clone)]
103enum GlobSegment {
104    Star,
105    DoubleStar,
106    Question,
107    Literal(String),
108}
109
110impl GlobPattern {
111    pub fn new(pattern: &str) -> Result<Self, WatchError> {
112        if pattern.is_empty() {
113            return Err(WatchError::InvalidGlob("empty pattern".to_string()));
114        }
115        let segments = Self::parse(pattern);
116        Ok(Self {
117            raw: pattern.to_string(),
118            segments,
119        })
120    }
121
122    pub fn as_str(&self) -> &str {
123        &self.raw
124    }
125
126    fn parse(pattern: &str) -> Vec<GlobSegment> {
127        let mut segs = Vec::new();
128        let mut literal = String::new();
129        let chars: Vec<char> = pattern.chars().collect();
130        let len = chars.len();
131        let mut i = 0;
132        while i < len {
133            match chars[i] {
134                '*' => {
135                    if !literal.is_empty() {
136                        segs.push(GlobSegment::Literal(std::mem::take(&mut literal)));
137                    }
138                    if i + 1 < len && chars[i + 1] == '*' {
139                        segs.push(GlobSegment::DoubleStar);
140                        i += 2;
141                        if i < len && (chars[i] == '/' || chars[i] == '\\') {
142                            i += 1;
143                        }
144                    } else {
145                        segs.push(GlobSegment::Star);
146                        i += 1;
147                    }
148                }
149                '?' => {
150                    if !literal.is_empty() {
151                        segs.push(GlobSegment::Literal(std::mem::take(&mut literal)));
152                    }
153                    segs.push(GlobSegment::Question);
154                    i += 1;
155                }
156                c => {
157                    literal.push(c);
158                    i += 1;
159                }
160            }
161        }
162        if !literal.is_empty() {
163            segs.push(GlobSegment::Literal(literal));
164        }
165        segs
166    }
167
168    pub fn matches(&self, text: &str) -> bool {
169        Self::match_segments(&self.segments, text)
170    }
171
172    fn match_segments(segments: &[GlobSegment], text: &str) -> bool {
173        if segments.is_empty() {
174            return text.is_empty();
175        }
176        match &segments[0] {
177            GlobSegment::Literal(lit) => {
178                if let Some(rest) = text.strip_prefix(lit.as_str()) {
179                    Self::match_segments(&segments[1..], rest)
180                } else {
181                    false
182                }
183            }
184            GlobSegment::Question => {
185                let mut chars = text.chars();
186                match chars.next() {
187                    Some(c) if c != '/' && c != '\\' => {
188                        Self::match_segments(&segments[1..], chars.as_str())
189                    }
190                    _ => false,
191                }
192            }
193            GlobSegment::Star => {
194                let rest_segments = &segments[1..];
195                if Self::match_segments(rest_segments, text) {
196                    return true;
197                }
198                for (i, c) in text.char_indices() {
199                    if c == '/' || c == '\\' {
200                        break;
201                    }
202                    let after = &text[i + c.len_utf8()..];
203                    if Self::match_segments(rest_segments, after) {
204                        return true;
205                    }
206                }
207                false
208            }
209            GlobSegment::DoubleStar => {
210                let rest_segments = &segments[1..];
211                if Self::match_segments(rest_segments, text) {
212                    return true;
213                }
214                for (i, c) in text.char_indices() {
215                    let after = &text[i + c.len_utf8()..];
216                    if Self::match_segments(rest_segments, after) {
217                        return true;
218                    }
219                }
220                false
221            }
222        }
223    }
224}
225
226/// Configuration for a single watched path.
227#[derive(Debug, Clone)]
228pub struct WatchEntry {
229    pub path: String,
230    pub recursive: bool,
231}
232
233/// Configuration for the debouncing behaviour.
234#[derive(Debug, Clone)]
235pub struct DebounceConfig {
236    pub window: Duration,
237}
238
239impl Default for DebounceConfig {
240    fn default() -> Self {
241        Self {
242            window: Duration::from_millis(100),
243        }
244    }
245}
246
247/// File watcher with debouncing, glob filtering, event batching,
248/// recursive directory watching, and dynamic watch management.
249pub struct FileWatcherStub {
250    watched_paths: Vec<String>,
251    watch_entries: HashMap<String, WatchEntry>,
252    events: Vec<FsEvent>,
253    glob_filters: Vec<GlobPattern>,
254    debounce: DebounceConfig,
255    last_drain: Option<Instant>,
256    pending_debounce: Vec<(FsEvent, Instant)>,
257    batching_enabled: bool,
258    batches: Vec<EventBatch>,
259    #[allow(clippy::type_complexity)]
260    batch_callback: Option<Box<dyn Fn(&EventBatch) + Send + Sync>>,
261}
262
263impl std::fmt::Debug for FileWatcherStub {
264    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
265        f.debug_struct("FileWatcherStub")
266            .field("watched_paths", &self.watched_paths)
267            .field("events", &self.events)
268            .field("batching_enabled", &self.batching_enabled)
269            .finish()
270    }
271}
272
273impl FileWatcherStub {
274    pub fn new() -> Self {
275        FileWatcherStub {
276            watched_paths: Vec::new(),
277            watch_entries: HashMap::new(),
278            events: Vec::new(),
279            glob_filters: Vec::new(),
280            debounce: DebounceConfig::default(),
281            last_drain: None,
282            pending_debounce: Vec::new(),
283            batching_enabled: false,
284            batches: Vec::new(),
285            batch_callback: None,
286        }
287    }
288
289    pub fn with_debounce(window: Duration) -> Self {
290        let mut w = Self::new();
291        w.debounce = DebounceConfig { window };
292        w
293    }
294
295    pub fn watch(&mut self, path: &str) {
296        if !self.watched_paths.contains(&path.to_string()) {
297            self.watched_paths.push(path.to_string());
298            self.watch_entries.insert(
299                path.to_string(),
300                WatchEntry {
301                    path: path.to_string(),
302                    recursive: false,
303                },
304            );
305        }
306    }
307
308    pub fn watch_recursive(&mut self, path: &str, recursive: bool) -> Result<(), WatchError> {
309        Self::validate_path(path)?;
310        if self.watched_paths.contains(&path.to_string()) {
311            return Err(WatchError::AlreadyWatched(path.to_string()));
312        }
313        self.watched_paths.push(path.to_string());
314        self.watch_entries.insert(
315            path.to_string(),
316            WatchEntry {
317                path: path.to_string(),
318                recursive,
319            },
320        );
321        Ok(())
322    }
323
324    pub fn unwatch(&mut self, path: &str) {
325        self.watched_paths.retain(|p| p != path);
326        self.watch_entries.remove(path);
327    }
328
329    pub fn unwatch_checked(&mut self, path: &str) -> Result<(), WatchError> {
330        if !self.watched_paths.contains(&path.to_string()) {
331            return Err(WatchError::NotWatched(path.to_string()));
332        }
333        self.unwatch(path);
334        Ok(())
335    }
336
337    pub fn update_recursive(&mut self, path: &str, recursive: bool) -> Result<(), WatchError> {
338        match self.watch_entries.get_mut(path) {
339            Some(entry) => {
340                entry.recursive = recursive;
341                Ok(())
342            }
343            None => Err(WatchError::NotWatched(path.to_string())),
344        }
345    }
346
347    pub fn replace_watches(&mut self, paths: &[&str]) {
348        self.watched_paths.clear();
349        self.watch_entries.clear();
350        for p in paths {
351            self.watch(p);
352        }
353    }
354
355    pub fn watch_entry(&self, path: &str) -> Option<&WatchEntry> {
356        self.watch_entries.get(path)
357    }
358
359    pub fn watched_count(&self) -> usize {
360        self.watched_paths.len()
361    }
362
363    pub fn watched_paths(&self) -> &[String] {
364        &self.watched_paths
365    }
366
367    pub fn add_glob_filter(&mut self, pattern: &str) -> Result<(), WatchError> {
368        let g = GlobPattern::new(pattern)?;
369        self.glob_filters.push(g);
370        Ok(())
371    }
372
373    pub fn clear_glob_filters(&mut self) {
374        self.glob_filters.clear();
375    }
376
377    pub fn glob_filter_count(&self) -> usize {
378        self.glob_filters.len()
379    }
380
381    pub fn passes_glob_filter(&self, path: &str) -> bool {
382        if self.glob_filters.is_empty() {
383            return true;
384        }
385        self.glob_filters.iter().any(|g| g.matches(path))
386    }
387
388    pub fn set_debounce_window(&mut self, window: Duration) {
389        self.debounce.window = window;
390    }
391
392    pub fn debounce_window(&self) -> Duration {
393        self.debounce.window
394    }
395
396    pub fn inject_event(&mut self, event: FsEvent) {
397        if !self.passes_glob_filter(event.path()) {
398            return;
399        }
400        self.events.push(event);
401    }
402
403    pub fn inject_event_unfiltered(&mut self, event: FsEvent) {
404        self.events.push(event);
405    }
406
407    pub fn inject_event_timed(&mut self, event: FsEvent, when: Instant) {
408        if !self.passes_glob_filter(event.path()) {
409            return;
410        }
411        self.pending_debounce.push((event, when));
412    }
413
414    pub fn drain_events(&mut self) -> Vec<FsEvent> {
415        self.last_drain = Some(Instant::now());
416        std::mem::take(&mut self.events)
417    }
418
419    pub fn drain_events_debounced(&mut self) -> Vec<FsEvent> {
420        self.last_drain = Some(Instant::now());
421        if !self.pending_debounce.is_empty() {
422            // Coalesce non-timed events first, then append timed results
423            // (which are already per-window coalesced and must not be
424            // re-merged across windows).
425            let raw = std::mem::take(&mut self.events);
426            let mut result = Self::coalesce_events(raw);
427            let timed = std::mem::take(&mut self.pending_debounce);
428            let timed_events = Self::coalesce_timed_into(timed, self.debounce.window);
429            result.extend(timed_events);
430            return result;
431        }
432        let raw = std::mem::take(&mut self.events);
433        Self::coalesce_events(raw)
434    }
435
436    fn coalesce_timed_into(mut timed: Vec<(FsEvent, Instant)>, window: Duration) -> Vec<FsEvent> {
437        if timed.is_empty() {
438            return Vec::new();
439        }
440        timed.sort_by_key(|(_, t)| *t);
441        let mut groups: Vec<Vec<(FsEvent, Instant)>> = Vec::new();
442        let mut current_group: Vec<(FsEvent, Instant)> = Vec::new();
443        let mut group_start: Option<Instant> = None;
444        for item in timed {
445            let start = match group_start {
446                Some(s) => s,
447                None => {
448                    group_start = Some(item.1);
449                    current_group.push(item);
450                    continue;
451                }
452            };
453            if item.1.duration_since(start) <= window {
454                current_group.push(item);
455            } else {
456                groups.push(std::mem::take(&mut current_group));
457                group_start = Some(item.1);
458                current_group.push(item);
459            }
460        }
461        if !current_group.is_empty() {
462            groups.push(current_group);
463        }
464        let mut result = Vec::new();
465        for group in groups {
466            let mut latest: HashMap<String, FsEvent> = HashMap::new();
467            for (ev, _) in group {
468                latest.insert(ev.path().to_string(), ev);
469            }
470            result.extend(latest.into_values());
471        }
472        result
473    }
474
475    fn coalesce_events(events: Vec<FsEvent>) -> Vec<FsEvent> {
476        let mut seen: HashMap<String, usize> = HashMap::new();
477        let mut result: Vec<FsEvent> = Vec::new();
478        for ev in events {
479            let key = ev.path().to_string();
480            if let Some(&idx) = seen.get(&key) {
481                result[idx] = ev;
482            } else {
483                seen.insert(key, result.len());
484                result.push(ev);
485            }
486        }
487        result
488    }
489
490    pub fn set_batching(&mut self, enabled: bool) {
491        self.batching_enabled = enabled;
492    }
493
494    pub fn batching_enabled(&self) -> bool {
495        self.batching_enabled
496    }
497
498    pub fn set_batch_callback<F>(&mut self, cb: F)
499    where
500        F: Fn(&EventBatch) + Send + Sync + 'static,
501    {
502        self.batch_callback = Some(Box::new(cb));
503    }
504
505    pub fn clear_batch_callback(&mut self) {
506        self.batch_callback = None;
507    }
508
509    pub fn flush_batches(&mut self) -> Vec<EventBatch> {
510        let events = self.drain_events();
511        if events.is_empty() {
512            return Vec::new();
513        }
514        let batch = EventBatch::new(events);
515        if let Some(cb) = &self.batch_callback {
516            cb(&batch);
517        }
518        self.batches.push(batch.clone());
519        vec![batch]
520    }
521
522    pub fn flush_batches_debounced(&mut self) -> Vec<EventBatch> {
523        let events = self.drain_events_debounced();
524        if events.is_empty() {
525            return Vec::new();
526        }
527        let batch = EventBatch::new(events);
528        if let Some(cb) = &self.batch_callback {
529            cb(&batch);
530        }
531        self.batches.push(batch.clone());
532        vec![batch]
533    }
534
535    pub fn batches(&self) -> &[EventBatch] {
536        &self.batches
537    }
538
539    pub fn clear_batches(&mut self) {
540        self.batches.clear();
541    }
542
543    fn validate_path(path: &str) -> Result<(), WatchError> {
544        if path.is_empty() {
545            return Err(WatchError::EmptyPath);
546        }
547        if path.contains('\0') {
548            return Err(WatchError::InvalidPath(path.to_string()));
549        }
550        Ok(())
551    }
552
553    pub fn check_path(path: &str) -> Result<(), WatchError> {
554        Self::validate_path(path)
555    }
556
557    pub fn path_exists(path: &str) -> bool {
558        Path::new(path).exists()
559    }
560}
561
562impl Default for FileWatcherStub {
563    fn default() -> Self {
564        Self::new()
565    }
566}
567
568/// Create a new file watcher.
569pub fn new_file_watcher() -> FileWatcherStub {
570    FileWatcherStub::new()
571}
572
573/// Watch multiple paths at once.
574pub fn watch_paths(watcher: &mut FileWatcherStub, paths: &[&str]) {
575    for p in paths {
576        watcher.watch(p);
577    }
578}
579
580/// Drain and count events.
581pub fn drain_and_count(watcher: &mut FileWatcherStub) -> (Vec<FsEvent>, usize) {
582    let events = watcher.drain_events();
583    let n = events.len();
584    (events, n)
585}
586
587/// Check if a path is currently watched.
588pub fn is_watched(watcher: &FileWatcherStub, path: &str) -> bool {
589    watcher.watched_paths.contains(&path.to_string())
590}
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595
596    #[test]
597    fn test_new_watcher_empty() {
598        let w = FileWatcherStub::new();
599        assert_eq!(w.watched_count(), 0);
600    }
601
602    #[test]
603    fn test_watch_path() {
604        let mut w = new_file_watcher();
605        w.watch("/tmp/foo");
606        assert_eq!(w.watched_count(), 1);
607        assert!(is_watched(&w, "/tmp/foo"));
608    }
609
610    #[test]
611    fn test_unwatch_path() {
612        let mut w = new_file_watcher();
613        w.watch("/tmp/bar");
614        w.unwatch("/tmp/bar");
615        assert_eq!(w.watched_count(), 0);
616    }
617
618    #[test]
619    fn test_inject_and_drain_events() {
620        let mut w = new_file_watcher();
621        w.inject_event(FsEvent::Created("/tmp/x".to_string()));
622        let evs = w.drain_events();
623        assert_eq!(evs.len(), 1);
624        assert!(matches!(&evs[0], FsEvent::Created(p) if p == "/tmp/x"));
625    }
626
627    #[test]
628    fn test_drain_clears_events() {
629        let mut w = new_file_watcher();
630        w.inject_event(FsEvent::Deleted("/tmp/y".to_string()));
631        let _ = w.drain_events();
632        assert!(w.drain_events().is_empty());
633    }
634
635    #[test]
636    fn test_watch_multiple() {
637        let mut w = new_file_watcher();
638        watch_paths(&mut w, &["/a", "/b", "/c"]);
639        assert_eq!(w.watched_count(), 3);
640    }
641
642    #[test]
643    fn test_drain_and_count() {
644        let mut w = new_file_watcher();
645        w.inject_event(FsEvent::Modified("/tmp/z".to_string()));
646        w.inject_event(FsEvent::Modified("/tmp/z".to_string()));
647        let (_, n) = drain_and_count(&mut w);
648        assert_eq!(n, 2);
649    }
650
651    #[test]
652    fn test_rename_event() {
653        let mut w = new_file_watcher();
654        w.inject_event(FsEvent::Renamed {
655            from: "/a".to_string(),
656            to: "/b".to_string(),
657        });
658        let evs = w.drain_events();
659        assert!(matches!(&evs[0], FsEvent::Renamed { .. }));
660    }
661
662    #[test]
663    fn test_no_duplicate_watch() {
664        let mut w = new_file_watcher();
665        w.watch("/same");
666        w.watch("/same");
667        assert_eq!(w.watched_count(), 1);
668    }
669
670    #[test]
671    fn test_is_not_watched() {
672        let w = new_file_watcher();
673        assert!(!is_watched(&w, "/nonexistent"));
674    }
675
676    #[test]
677    fn test_glob_pattern_star() {
678        let g = GlobPattern::new("*.rs").expect("should succeed");
679        assert!(g.matches("main.rs"));
680        assert!(g.matches("lib.rs"));
681        assert!(!g.matches("main.py"));
682        assert!(!g.matches("src/main.rs"));
683    }
684
685    #[test]
686    fn test_glob_pattern_double_star() {
687        let g = GlobPattern::new("**/*.rs").expect("should succeed");
688        assert!(g.matches("src/main.rs"));
689        assert!(g.matches("a/b/c/lib.rs"));
690        assert!(!g.matches("main.py"));
691    }
692
693    #[test]
694    fn test_glob_pattern_question() {
695        let g = GlobPattern::new("?.rs").expect("should succeed");
696        assert!(g.matches("a.rs"));
697        assert!(!g.matches("ab.rs"));
698    }
699
700    #[test]
701    fn test_glob_filter_on_events() {
702        let mut w = new_file_watcher();
703        w.add_glob_filter("*.rs").expect("should succeed");
704        w.inject_event(FsEvent::Modified("main.rs".to_string()));
705        w.inject_event(FsEvent::Modified("main.py".to_string()));
706        let evs = w.drain_events();
707        assert_eq!(evs.len(), 1);
708        assert!(matches!(&evs[0], FsEvent::Modified(p) if p == "main.rs"));
709    }
710
711    #[test]
712    fn test_glob_filter_cleared() {
713        let mut w = new_file_watcher();
714        w.add_glob_filter("*.rs").expect("should succeed");
715        w.clear_glob_filters();
716        w.inject_event(FsEvent::Modified("main.py".to_string()));
717        assert_eq!(w.drain_events().len(), 1);
718    }
719
720    #[test]
721    fn test_invalid_glob() {
722        let r = GlobPattern::new("");
723        assert!(r.is_err());
724    }
725
726    #[test]
727    fn test_watch_recursive() {
728        let mut w = new_file_watcher();
729        w.watch_recursive("/src", true).expect("should succeed");
730        let entry = w.watch_entry("/src").expect("should succeed");
731        assert!(entry.recursive);
732    }
733
734    #[test]
735    fn test_watch_recursive_duplicate() {
736        let mut w = new_file_watcher();
737        w.watch_recursive("/src", true).expect("should succeed");
738        let r = w.watch_recursive("/src", false);
739        assert!(matches!(r, Err(WatchError::AlreadyWatched(_))));
740    }
741
742    #[test]
743    fn test_update_recursive() {
744        let mut w = new_file_watcher();
745        w.watch_recursive("/src", false).expect("should succeed");
746        w.update_recursive("/src", true).expect("should succeed");
747        assert!(w.watch_entry("/src").expect("should succeed").recursive);
748    }
749
750    #[test]
751    fn test_update_recursive_not_watched() {
752        let mut w = new_file_watcher();
753        let r = w.update_recursive("/nope", true);
754        assert!(matches!(r, Err(WatchError::NotWatched(_))));
755    }
756
757    #[test]
758    fn test_unwatch_checked_ok() {
759        let mut w = new_file_watcher();
760        w.watch("/a");
761        assert!(w.unwatch_checked("/a").is_ok());
762        assert_eq!(w.watched_count(), 0);
763    }
764
765    #[test]
766    fn test_unwatch_checked_err() {
767        let mut w = new_file_watcher();
768        assert!(matches!(
769            w.unwatch_checked("/nope"),
770            Err(WatchError::NotWatched(_))
771        ));
772    }
773
774    #[test]
775    fn test_replace_watches() {
776        let mut w = new_file_watcher();
777        w.watch("/old");
778        w.replace_watches(&["/new1", "/new2"]);
779        assert_eq!(w.watched_count(), 2);
780        assert!(!is_watched(&w, "/old"));
781        assert!(is_watched(&w, "/new1"));
782    }
783
784    #[test]
785    fn test_validate_empty_path() {
786        assert!(matches!(
787            FileWatcherStub::check_path(""),
788            Err(WatchError::EmptyPath)
789        ));
790    }
791
792    #[test]
793    fn test_validate_null_byte_path() {
794        assert!(matches!(
795            FileWatcherStub::check_path("/foo\0bar"),
796            Err(WatchError::InvalidPath(_))
797        ));
798    }
799
800    #[test]
801    fn test_debounce_coalescing() {
802        let mut w = FileWatcherStub::with_debounce(Duration::from_millis(200));
803        let now = Instant::now();
804        w.inject_event_timed(FsEvent::Modified("/a".to_string()), now);
805        w.inject_event_timed(
806            FsEvent::Modified("/a".to_string()),
807            now + Duration::from_millis(50),
808        );
809        let evs = w.drain_events_debounced();
810        assert_eq!(evs.len(), 1);
811    }
812
813    #[test]
814    fn test_debounce_separate_windows() {
815        let mut w = FileWatcherStub::with_debounce(Duration::from_millis(100));
816        let now = Instant::now();
817        w.inject_event_timed(FsEvent::Modified("/a".to_string()), now);
818        w.inject_event_timed(
819            FsEvent::Modified("/a".to_string()),
820            now + Duration::from_millis(200),
821        );
822        let evs = w.drain_events_debounced();
823        assert_eq!(evs.len(), 2);
824    }
825
826    #[test]
827    fn test_simple_coalesce() {
828        let mut w = new_file_watcher();
829        w.inject_event(FsEvent::Modified("/a".to_string()));
830        w.inject_event(FsEvent::Created("/a".to_string()));
831        let evs = w.drain_events_debounced();
832        assert_eq!(evs.len(), 1);
833        assert!(matches!(&evs[0], FsEvent::Created(_)));
834    }
835
836    #[test]
837    fn test_batching_flush() {
838        let mut w = new_file_watcher();
839        w.set_batching(true);
840        w.inject_event(FsEvent::Created("/x".to_string()));
841        w.inject_event(FsEvent::Modified("/y".to_string()));
842        let batches = w.flush_batches();
843        assert_eq!(batches.len(), 1);
844        assert_eq!(batches[0].len(), 2);
845    }
846
847    #[test]
848    fn test_batching_callback() {
849        use std::sync::{Arc, Mutex};
850        let seen = Arc::new(Mutex::new(Vec::new()));
851        let seen2 = Arc::clone(&seen);
852        let mut w = new_file_watcher();
853        w.set_batch_callback(move |batch| {
854            if let Ok(mut v) = seen2.lock() {
855                v.push(batch.len());
856            }
857        });
858        w.inject_event(FsEvent::Created("/a".to_string()));
859        let _ = w.flush_batches();
860        let locked = seen.lock().expect("should succeed");
861        assert_eq!(locked.len(), 1);
862        assert_eq!(locked[0], 1);
863    }
864
865    #[test]
866    fn test_batch_unique_paths() {
867        let batch = EventBatch::new(vec![
868            FsEvent::Modified("/a".to_string()),
869            FsEvent::Modified("/a".to_string()),
870            FsEvent::Created("/b".to_string()),
871        ]);
872        let paths = batch.unique_paths();
873        assert_eq!(paths.len(), 2);
874        assert!(paths.contains("/a"));
875        assert!(paths.contains("/b"));
876    }
877
878    #[test]
879    fn test_inject_unfiltered_bypasses_glob() {
880        let mut w = new_file_watcher();
881        w.add_glob_filter("*.rs").expect("should succeed");
882        w.inject_event_unfiltered(FsEvent::Modified("main.py".to_string()));
883        assert_eq!(w.drain_events().len(), 1);
884    }
885
886    #[test]
887    fn test_event_path_and_kind() {
888        let e = FsEvent::Renamed {
889            from: "/old".to_string(),
890            to: "/new".to_string(),
891        };
892        assert_eq!(e.path(), "/old");
893        assert_eq!(e.kind(), "renamed");
894    }
895
896    #[test]
897    fn test_flush_batches_debounced() {
898        let mut w = new_file_watcher();
899        w.inject_event(FsEvent::Modified("/a".to_string()));
900        w.inject_event(FsEvent::Modified("/a".to_string()));
901        let batches = w.flush_batches_debounced();
902        assert_eq!(batches.len(), 1);
903        assert_eq!(batches[0].len(), 1);
904    }
905
906    #[test]
907    fn test_watched_paths_snapshot() {
908        let mut w = new_file_watcher();
909        w.watch("/x");
910        w.watch("/y");
911        let paths = w.watched_paths();
912        assert_eq!(paths.len(), 2);
913    }
914
915    #[test]
916    fn test_default_impl() {
917        let w = FileWatcherStub::default();
918        assert_eq!(w.watched_count(), 0);
919    }
920
921    #[test]
922    fn test_debounce_window_setter() {
923        let mut w = new_file_watcher();
924        w.set_debounce_window(Duration::from_secs(1));
925        assert_eq!(w.debounce_window(), Duration::from_secs(1));
926    }
927
928    #[test]
929    fn test_clear_batch_callback() {
930        let mut w = new_file_watcher();
931        w.set_batch_callback(|_| {});
932        w.clear_batch_callback();
933        w.inject_event(FsEvent::Created("/a".to_string()));
934        let _ = w.flush_batches();
935    }
936
937    #[test]
938    fn test_clear_batches() {
939        let mut w = new_file_watcher();
940        w.inject_event(FsEvent::Created("/a".to_string()));
941        let _ = w.flush_batches();
942        assert_eq!(w.batches().len(), 1);
943        w.clear_batches();
944        assert!(w.batches().is_empty());
945    }
946
947    #[test]
948    fn test_watch_error_display() {
949        let e = WatchError::EmptyPath;
950        assert_eq!(format!("{e}"), "path must not be empty");
951        let e2 = WatchError::InvalidGlob("bad".to_string());
952        assert!(format!("{e2}").contains("bad"));
953    }
954
955    #[test]
956    fn test_multiple_glob_filters() {
957        let mut w = new_file_watcher();
958        w.add_glob_filter("*.rs").expect("should succeed");
959        w.add_glob_filter("*.toml").expect("should succeed");
960        assert_eq!(w.glob_filter_count(), 2);
961        assert!(w.passes_glob_filter("lib.rs"));
962        assert!(w.passes_glob_filter("Cargo.toml"));
963        assert!(!w.passes_glob_filter("main.py"));
964    }
965
966    #[test]
967    fn test_event_batch_into_events() {
968        let batch = EventBatch::new(vec![FsEvent::Created("/a".to_string())]);
969        let evs = batch.into_events();
970        assert_eq!(evs.len(), 1);
971    }
972
973    #[test]
974    fn test_empty_batch() {
975        let batch = EventBatch::new(vec![]);
976        assert!(batch.is_empty());
977        assert_eq!(batch.len(), 0);
978    }
979
980    #[test]
981    fn test_glob_no_filters_passes_everything() {
982        let w = new_file_watcher();
983        assert!(w.passes_glob_filter("anything.txt"));
984    }
985}