1#![allow(dead_code)]
4
5use std::collections::{HashMap, HashSet};
9use std::path::Path;
10use std::time::{Duration, Instant};
11
12#[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#[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#[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#[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#[derive(Debug, Clone)]
228pub struct WatchEntry {
229 pub path: String,
230 pub recursive: bool,
231}
232
233#[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
247pub 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 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
568pub fn new_file_watcher() -> FileWatcherStub {
570 FileWatcherStub::new()
571}
572
573pub fn watch_paths(watcher: &mut FileWatcherStub, paths: &[&str]) {
575 for p in paths {
576 watcher.watch(p);
577 }
578}
579
580pub 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
587pub 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}