ailloli_ui_fs_local/source.rs
1//! Worker-owned local source, native-watch normalization, and stable identity.
2//!
3//! Native watches are deliberately non-recursive. Raw backend events wait for a
4//! 50-millisecond debounce window, rename halves are paired by tracker, and
5//! duplicate echo events are suppressed before bounded delivery.
6
7use std::collections::{HashMap, HashSet, VecDeque};
8use std::fs;
9use std::path::{Path, PathBuf};
10use std::sync::mpsc::{self, Receiver, TryRecvError};
11use std::time::{Duration, Instant};
12
13use ailloli_ui_fs::{
14 FileEntry, FileError, FileIdentity, FileProvider, FileTreeSource, FileTreeSourceFactory,
15 FileUri, WatchEvent, WatchEventKind,
16};
17use notify::event::{ModifyKind, RenameMode};
18use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
19
20use crate::LocalFileProvider;
21
22/// Minimum age of raw native events before a poll normalizes them.
23const WATCH_DEBOUNCE: Duration = Duration::from_millis(50);
24/// Retention window used to suppress self-watch echoes after rename.
25const WATCH_RENAME_ECHO_TTL: Duration = Duration::from_millis(500);
26/// Default maximum number of simultaneously watched local directories: 1,024.
27///
28/// The limit is an item count and each watch is non-recursive. It bounds native
29/// watcher resources independently of the runtime request/response queues.
30///
31/// # Examples
32///
33/// ```
34/// use ailloli_ui_fs_local::DEFAULT_LOCAL_FILE_TREE_MAX_WATCHERS;
35/// assert_eq!(DEFAULT_LOCAL_FILE_TREE_MAX_WATCHERS, 1_024);
36/// ```
37pub const DEFAULT_LOCAL_FILE_TREE_MAX_WATCHERS: usize = 1_024;
38
39/// Timestamped raw event or backend error from `notify`.
40type RawWatchEvent = (Instant, notify::Result<Event>);
41
42/// Thread-safe factory configuring each worker-owned local source.
43///
44/// Clones copy only the numeric watcher limit. Source construction and native
45/// watcher allocation happen later on the filesystem worker.
46///
47/// # Examples
48///
49/// ```
50/// use ailloli_ui_fs_local::LocalFileTreeSourceFactory;
51/// let factory = LocalFileTreeSourceFactory::new().max_watchers(64);
52/// let _: LocalFileTreeSourceFactory = factory;
53/// ```
54#[derive(Debug, Clone)]
55pub struct LocalFileTreeSourceFactory {
56 /// Maximum simultaneous non-recursive directory watches for new sources.
57 max_watchers: usize,
58}
59
60/// Builder operations for worker source configuration.
61impl LocalFileTreeSourceFactory {
62 /// Creates a factory with the 1,024-watcher default.
63 ///
64 /// No native watcher is allocated by this constructor.
65 ///
66 /// # Examples
67 ///
68 /// ```
69 /// use ailloli_ui_fs_local::LocalFileTreeSourceFactory;
70 /// let _: LocalFileTreeSourceFactory = LocalFileTreeSourceFactory::new();
71 /// ```
72 pub fn new() -> Self {
73 Self::default()
74 }
75
76 /// Replaces the per-source watcher ceiling.
77 ///
78 /// Zero is accepted and prevents every new watch while leaving file I/O
79 /// usable. Existing sources are unaffected because the factory is consumed.
80 ///
81 /// # Examples
82 ///
83 /// ```
84 /// use ailloli_ui_fs_local::LocalFileTreeSourceFactory;
85 /// let disabled = LocalFileTreeSourceFactory::new().max_watchers(0);
86 /// let _: LocalFileTreeSourceFactory = disabled;
87 /// ```
88 pub fn max_watchers(mut self, max_watchers: usize) -> Self {
89 self.max_watchers = max_watchers;
90 self
91 }
92}
93
94/// Selects [`DEFAULT_LOCAL_FILE_TREE_MAX_WATCHERS`] for new sources.
95impl Default for LocalFileTreeSourceFactory {
96 /// Constructs the conservative default factory without allocating a watcher.
97 fn default() -> Self {
98 Self {
99 max_watchers: DEFAULT_LOCAL_FILE_TREE_MAX_WATCHERS,
100 }
101 }
102}
103
104/// Allocates a configured local source on the calling worker thread.
105impl FileTreeSourceFactory for LocalFileTreeSourceFactory {
106 /// Returns a source or maps native watcher initialization to [`FileError`].
107 ///
108 /// # Errors
109 ///
110 /// Returns [`FileError::Io`] when the platform watcher cannot be created.
111 fn create(&self) -> Result<Box<dyn FileTreeSource>, FileError> {
112 Ok(Box::new(LocalFileTreeSource::with_max_watchers(
113 self.max_watchers,
114 )?))
115 }
116}
117
118/// Worker-owned local source with non-recursive native directory watches.
119///
120/// All methods are synchronous and the type is intended to stay on its single
121/// runtime worker. Event sequences and watch generations start at one and
122/// saturate at `u64::MAX`; a successful watch/unwatch increments generation.
123///
124/// # Examples
125///
126/// ```no_run
127/// use ailloli_ui_fs::FileTreeSource;
128/// use ailloli_ui_fs_local::LocalFileTreeSource;
129/// let source = LocalFileTreeSource::new()?;
130/// assert!(source.supports_native_watch());
131/// # Ok::<(), Box<dyn std::error::Error>>(())
132/// ```
133pub struct LocalFileTreeSource {
134 /// Stateless synchronous local provider used for filesystem calls.
135 provider: LocalFileProvider,
136 /// Platform-recommended native watcher.
137 watcher: RecommendedWatcher,
138 /// Raw callback channel drained without blocking.
139 receiver: Receiver<RawWatchEvent>,
140 /// Raw events waiting to become at least 50 milliseconds old.
141 pending: VecDeque<RawWatchEvent>,
142 /// Normalized events retained across bounded polls.
143 normalized: VecDeque<WatchEvent>,
144 /// Rename-source paths retained for 500-millisecond echo suppression.
145 recent_rename_sources: HashMap<PathBuf, Instant>,
146 /// Successfully watched host paths and their original URIs.
147 watched: HashMap<PathBuf, FileUri>,
148 /// Next saturating event sequence, initially one.
149 next_sequence: u64,
150 /// Current saturating watch generation, initially one.
151 generation: u64,
152 /// Inclusive ceiling checked before adding a new distinct watch.
153 max_watchers: usize,
154}
155
156/// Construction and raw-event normalization operations.
157impl LocalFileTreeSource {
158 /// Allocates a source with the default 1,024-watcher ceiling.
159 ///
160 /// # Errors
161 ///
162 /// Returns [`FileError::Io`] when the platform watcher cannot initialize.
163 ///
164 /// # Examples
165 ///
166 /// ```no_run
167 /// use ailloli_ui_fs_local::LocalFileTreeSource;
168 /// let _: LocalFileTreeSource = LocalFileTreeSource::new()?;
169 /// # Ok::<(), Box<dyn std::error::Error>>(())
170 /// ```
171 pub fn new() -> Result<Self, FileError> {
172 Self::with_max_watchers(DEFAULT_LOCAL_FILE_TREE_MAX_WATCHERS)
173 }
174
175 /// Allocates a source with an explicit simultaneous-watch ceiling.
176 ///
177 /// Zero disables watch registration. The value is not clamped and does not
178 /// affect filesystem reads or mutations.
179 ///
180 /// # Errors
181 ///
182 /// Returns [`FileError::Io`] when the platform watcher cannot initialize.
183 ///
184 /// # Examples
185 ///
186 /// ```no_run
187 /// use ailloli_ui_fs_local::LocalFileTreeSource;
188 /// let _: LocalFileTreeSource = LocalFileTreeSource::with_max_watchers(8)?;
189 /// # Ok::<(), Box<dyn std::error::Error>>(())
190 /// ```
191 pub fn with_max_watchers(max_watchers: usize) -> Result<Self, FileError> {
192 let (sender, receiver) = mpsc::channel();
193 let watcher = notify::recommended_watcher(move |event| {
194 let _ = sender.send((Instant::now(), event));
195 })
196 .map_err(notify_error)?;
197 Ok(Self {
198 provider: LocalFileProvider::new(),
199 watcher,
200 receiver,
201 pending: VecDeque::new(),
202 normalized: VecDeque::new(),
203 recent_rename_sources: HashMap::new(),
204 watched: HashMap::new(),
205 next_sequence: 1,
206 generation: 1,
207 max_watchers,
208 })
209 }
210
211 /// Allocates one normalized event with the current sequence/generation.
212 ///
213 /// Sequence increments saturatingly, so `u64::MAX` repeats after exhaustion.
214 fn next_event(
215 &mut self,
216 kind: WatchEventKind,
217 uri: FileUri,
218 previous_uri: Option<FileUri>,
219 ) -> WatchEvent {
220 let sequence = self.next_sequence;
221 self.next_sequence = self.next_sequence.saturating_add(1);
222 let mut event = WatchEvent::new(kind, uri, sequence, self.generation);
223 if let Some(previous_uri) = previous_uri {
224 event = event.with_previous_uri(previous_uri);
225 }
226 event
227 }
228
229 /// Maps one backend event into zero or more provider-neutral events.
230 ///
231 /// Access and unknown variants are ignored. `Other` becomes `Overflow` when
232 /// a path or watched fallback URI exists. Complete renames use the first two
233 /// paths and classify same-parent changes as rename, otherwise move.
234 ///
235 /// # Errors
236 ///
237 /// Returns [`FileError::InvalidUri`] when any native event path cannot be
238 /// represented as a local [`FileUri`].
239 fn map_event(&mut self, event: Event) -> Result<Vec<WatchEvent>, FileError> {
240 let paths = event.paths;
241 match event.kind {
242 EventKind::Create(_) => self.events_for_paths(WatchEventKind::Created, paths),
243 EventKind::Remove(_) => self.events_for_paths(WatchEventKind::Removed, paths),
244 EventKind::Modify(ModifyKind::Name(RenameMode::Both)) if paths.len() >= 2 => {
245 let from = FileUri::local(&paths[0])?;
246 let to = FileUri::local(&paths[1])?;
247 let kind = if paths[0].parent() == paths[1].parent() {
248 WatchEventKind::Renamed
249 } else {
250 WatchEventKind::Moved
251 };
252 Ok(vec![self.next_event(kind, to, Some(from))])
253 }
254 EventKind::Modify(ModifyKind::Name(RenameMode::From)) => {
255 self.events_for_paths(WatchEventKind::Removed, paths)
256 }
257 EventKind::Modify(ModifyKind::Name(RenameMode::To)) => {
258 self.events_for_paths(WatchEventKind::Created, paths)
259 }
260 EventKind::Modify(_) => self.events_for_paths(WatchEventKind::Modified, paths),
261 EventKind::Other => {
262 let uri = paths
263 .first()
264 .map(FileUri::local)
265 .transpose()?
266 .or_else(|| self.watched.values().next().cloned());
267 Ok(uri
268 .map(|uri| self.next_event(WatchEventKind::Overflow, uri, None))
269 .into_iter()
270 .collect())
271 }
272 EventKind::Access(_) => Ok(Vec::new()),
273 _ => Ok(Vec::new()),
274 }
275 }
276
277 /// Pairs rename halves and suppresses duplicate rename/remove metadata echoes.
278 ///
279 /// Output order follows the raw burst except paired halves emit at the first
280 /// encountered matching half. Conversion failure aborts the full burst.
281 ///
282 /// # Errors
283 ///
284 /// Propagates [`FileError::InvalidUri`] from any paired or ordinary native
285 /// path conversion; no partial normalized batch is returned.
286 fn normalize_events(&mut self, raw: Vec<Event>) -> Result<Vec<WatchEvent>, FileError> {
287 let now = Instant::now();
288 self.recent_rename_sources
289 .retain(|_, emitted_at| now.duration_since(*emitted_at) <= WATCH_RENAME_ECHO_TTL);
290 let mut rename_halves = HashMap::<usize, (Option<PathBuf>, Option<PathBuf>)>::new();
291 let mut complete_trackers = HashSet::new();
292 let mut renamed_from_paths = HashSet::new();
293 let mut renamed_paths = HashSet::new();
294 for event in &raw {
295 let Some(tracker) = event.tracker() else {
296 continue;
297 };
298 match event.kind {
299 EventKind::Modify(ModifyKind::Name(RenameMode::From)) => {
300 rename_halves.entry(tracker).or_default().0 = event.paths.first().cloned();
301 }
302 EventKind::Modify(ModifyKind::Name(RenameMode::To)) => {
303 rename_halves.entry(tracker).or_default().1 = event.paths.first().cloned();
304 }
305 EventKind::Modify(ModifyKind::Name(RenameMode::Both)) => {
306 complete_trackers.insert(tracker);
307 if let Some(from) = event.paths.first() {
308 renamed_from_paths.insert(from.clone());
309 }
310 renamed_paths.extend(event.paths.iter().cloned());
311 }
312 _ => {}
313 }
314 }
315 renamed_from_paths.extend(rename_halves.values().filter_map(|(from, to)| {
316 to.as_ref()?;
317 from.clone()
318 }));
319
320 let mut emitted_pairs = HashSet::new();
321 let mut normalized = Vec::new();
322 for event in raw {
323 if matches!(
324 event.kind,
325 EventKind::Remove(_) | EventKind::Modify(ModifyKind::Name(RenameMode::From))
326 ) && !event.paths.is_empty()
327 && event.paths.iter().all(|path| {
328 renamed_from_paths.contains(path)
329 || self.recent_rename_sources.contains_key(path)
330 })
331 {
332 // A non-recursive watch attached to the renamed directory may
333 // report its own old path as removed (or as an unpaired rename
334 // source) after the parent already emitted the complete pair.
335 // It is a self-watch echo, not a second filesystem mutation.
336 continue;
337 }
338 if matches!(event.kind, EventKind::Modify(ModifyKind::Metadata(_)))
339 && !event.paths.is_empty()
340 && event.paths.iter().all(|path| renamed_paths.contains(path))
341 {
342 // Linux may append an inode-metadata notification to the same
343 // debounced burst as a complete rename. The semantic rename
344 // already updates that retained node; treating this duplicate
345 // as a content modification would stale and reread its parent.
346 continue;
347 }
348 let tracker = event.tracker();
349 let rename_half = matches!(
350 event.kind,
351 EventKind::Modify(ModifyKind::Name(RenameMode::From | RenameMode::To))
352 );
353 if rename_half {
354 if tracker.is_some_and(|tracker| complete_trackers.contains(&tracker)) {
355 continue;
356 }
357 if let Some(tracker) = tracker {
358 if let Some((Some(from), Some(to))) = rename_halves.get(&tracker) {
359 if emitted_pairs.insert(tracker) {
360 let from = FileUri::local(from)?;
361 let to = FileUri::local(to)?;
362 let kind = if from.parent() == to.parent() {
363 WatchEventKind::Renamed
364 } else {
365 WatchEventKind::Moved
366 };
367 normalized.push(self.next_event(kind, to, Some(from)));
368 }
369 continue;
370 }
371 }
372 }
373 normalized.extend(self.map_event(event)?);
374 }
375 for event in &normalized {
376 if matches!(
377 event.kind(),
378 WatchEventKind::Renamed | WatchEventKind::Moved
379 ) {
380 if let Some(path) = event
381 .previous_uri()
382 .and_then(|uri| uri.to_local_path().ok())
383 {
384 self.recent_rename_sources.insert(path, now);
385 }
386 }
387 }
388 Ok(normalized)
389 }
390
391 /// Maps every supplied path to one event of `kind`, preserving path order.
392 ///
393 /// # Errors
394 ///
395 /// Returns [`FileError::InvalidUri`] when any path cannot become a local URI;
396 /// the complete collection is discarded.
397 fn events_for_paths(
398 &mut self,
399 kind: WatchEventKind,
400 paths: Vec<PathBuf>,
401 ) -> Result<Vec<WatchEvent>, FileError> {
402 paths
403 .into_iter()
404 .map(|path| Ok(self.next_event(kind, FileUri::local(path)?, None)))
405 .collect()
406 }
407}
408
409/// Adapts local I/O, stable identity, mutations, and native watch to the worker contract.
410impl FileTreeSource for LocalFileTreeSource {
411 /// Delegates one sorted, non-recursive directory read to the local provider.
412 ///
413 /// # Errors
414 ///
415 /// Propagates the URI, directory iteration, and metadata errors documented
416 /// by [`LocalFileProvider::read_dir`].
417 fn read_dir(&mut self, uri: &FileUri) -> Result<Vec<FileEntry>, FileError> {
418 self.provider.read_dir(uri)
419 }
420
421 /// Returns Unix device/inode identity or an unsupported error elsewhere.
422 ///
423 /// # Errors
424 ///
425 /// Returns [`FileError::InvalidUri`] for a non-local URI, a mapped metadata
426 /// error on Unix, or [`FileError::Unsupported`] on non-Unix targets.
427 fn identity(&mut self, uri: &FileUri) -> Result<Option<FileIdentity>, FileError> {
428 identity_for_path(&uri.to_local_path()?).map(Some)
429 }
430
431 /// Creates one directory without recursively creating parents.
432 ///
433 /// # Errors
434 ///
435 /// Propagates the URI and host creation errors documented by
436 /// [`LocalFileProvider::create_dir`].
437 fn create_directory(&mut self, uri: &FileUri) -> Result<(), FileError> {
438 self.provider.create_dir(uri)
439 }
440
441 /// Creates or truncates an empty file.
442 ///
443 /// # Errors
444 ///
445 /// Propagates the URI and host write errors documented by
446 /// [`LocalFileProvider::write_file`].
447 fn create_file(&mut self, uri: &FileUri) -> Result<(), FileError> {
448 self.provider.write_file(uri, &[])
449 }
450
451 /// Moves through the provider's rename-only policy; no cross-device fallback exists.
452 ///
453 /// # Errors
454 ///
455 /// Propagates invalid local URIs and host rename failures from the provider.
456 fn move_entry(&mut self, from: &FileUri, to: &FileUri) -> Result<(), FileError> {
457 self.provider.move_entry(from, to)
458 }
459
460 /// Chooses recursive or single-entry removal exactly from `recursive`.
461 ///
462 /// # Errors
463 ///
464 /// Propagates invalid local URIs and host traversal/removal failures from
465 /// the selected provider operation.
466 fn remove_entry(&mut self, uri: &FileUri, recursive: bool) -> Result<(), FileError> {
467 if recursive {
468 self.provider.remove_recursive(uri)
469 } else {
470 self.provider.remove(uri)
471 }
472 }
473
474 /// Adds one idempotent non-recursive native directory watch.
475 ///
476 /// A distinct watch at the configured ceiling returns `FileError::Other`.
477 /// Successful additions saturating-increment the watch generation.
478 ///
479 /// # Errors
480 ///
481 /// Returns [`FileError::InvalidUri`] for a non-local URI,
482 /// [`FileError::Other`] when the configured watcher ceiling is reached, or
483 /// [`FileError::Io`] when the native watcher rejects the path.
484 fn watch_directory(&mut self, uri: &FileUri) -> Result<(), FileError> {
485 let path = uri.to_local_path()?;
486 if self.watched.contains_key(&path) {
487 return Ok(());
488 }
489 if self.watched.len() >= self.max_watchers {
490 return Err(FileError::Other(format!(
491 "local filesystem watcher limit reached ({})",
492 self.max_watchers
493 )));
494 }
495 self.watcher
496 .watch(&path, RecursiveMode::NonRecursive)
497 .map_err(notify_error)?;
498 self.watched.insert(path, uri.clone());
499 self.generation = self.generation.saturating_add(1);
500 Ok(())
501 }
502
503 /// Removes a known watch idempotently and increments generation on success.
504 ///
505 /// Unknown paths return success without calling the native watcher.
506 ///
507 /// # Errors
508 ///
509 /// Returns [`FileError::InvalidUri`] for a non-local URI or [`FileError::Io`]
510 /// when the native watcher fails to remove a known path.
511 fn unwatch_directory(&mut self, uri: &FileUri) -> Result<(), FileError> {
512 let path = uri.to_local_path()?;
513 if self.watched.remove(&path).is_some() {
514 self.watcher.unwatch(&path).map_err(notify_error)?;
515 self.generation = self.generation.saturating_add(1);
516 }
517 Ok(())
518 }
519
520 /// Returns up to `limit` normalized events after the 50-millisecond debounce.
521 ///
522 /// Zero returns immediately without draining the raw channel. Later events
523 /// remain pending; normalized overflow remains queued for future calls.
524 /// Exact `(kind, uri, previous_uri)` duplicates are removed within each
525 /// returned batch. A disconnected callback channel is an I/O error.
526 ///
527 /// # Errors
528 ///
529 /// Returns [`FileError::Io`] for a disconnected callback channel or native
530 /// watcher error, and [`FileError::InvalidUri`] when normalizing an event path.
531 fn poll_watch(&mut self, limit: usize) -> Result<Vec<WatchEvent>, FileError> {
532 if limit == 0 {
533 return Ok(Vec::new());
534 }
535 loop {
536 match self.receiver.try_recv() {
537 Ok(event) => self.pending.push_back(event),
538 Err(TryRecvError::Empty) => break,
539 Err(TryRecvError::Disconnected) => {
540 return Err(FileError::Io("local watch channel disconnected".into()))
541 }
542 }
543 }
544 let cutoff = Instant::now()
545 .checked_sub(WATCH_DEBOUNCE)
546 .unwrap_or_else(Instant::now);
547 let mut raw = Vec::new();
548 loop {
549 let Some((received_at, _)) = self.pending.front() else {
550 break;
551 };
552 if *received_at > cutoff {
553 break;
554 }
555 let (_, event) = self.pending.pop_front().expect("front exists");
556 raw.push(event.map_err(notify_error)?);
557 }
558 let normalized = self.normalize_events(raw)?;
559 self.normalized.extend(normalized);
560
561 let mut events = Vec::new();
562 while events.len() < limit {
563 let Some(event) = self.normalized.pop_front() else {
564 break;
565 };
566 let duplicate = events.iter().any(|existing: &WatchEvent| {
567 existing.kind() == event.kind()
568 && existing.uri() == event.uri()
569 && existing.previous_uri() == event.previous_uri()
570 });
571 if !duplicate {
572 events.push(event);
573 }
574 }
575 Ok(events)
576 }
577
578 /// Always reports native non-recursive watch support.
579 fn supports_native_watch(&self) -> bool {
580 true
581 }
582}
583
584#[cfg(unix)]
585/// Builds a 16-byte little-endian `(device, inode)` identity without following links.
586///
587/// # Errors
588///
589/// Maps failure to read symlink-aware metadata through [`FileError::from_io`].
590fn identity_for_path(path: &Path) -> Result<FileIdentity, FileError> {
591 use std::os::unix::fs::MetadataExt;
592
593 let metadata = fs::symlink_metadata(path)
594 .map_err(|error| FileError::from_io(&error, path.display().to_string()))?;
595 let mut value = Vec::with_capacity(16);
596 value.extend_from_slice(&metadata.dev().to_le_bytes());
597 value.extend_from_slice(&metadata.ino().to_le_bytes());
598 Ok(FileIdentity::new("local-unix", value))
599}
600
601#[cfg(not(unix))]
602/// Reports that this implementation has no stable non-Unix local identity.
603///
604/// # Errors
605///
606/// Always returns [`FileError::Unsupported`] on non-Unix targets.
607fn identity_for_path(_path: &Path) -> Result<FileIdentity, FileError> {
608 Err(FileError::Unsupported(
609 "stable local file identity is not available on this target".into(),
610 ))
611}
612
613/// Redacts backend structure into the public filesystem-watch I/O context.
614fn notify_error(error: notify::Error) -> FileError {
615 FileError::Io(format!("filesystem watch: {error}"))
616}
617
618#[cfg(test)]
619/// Pure raw-event normalization regressions independent of live OS delivery.
620mod tests {
621 use super::*;
622
623 /// Constructs a tracked rename event with ordered synthetic paths.
624 fn rename_event(mode: RenameMode, tracker: usize, paths: &[&str]) -> Event {
625 let mut event = Event::new(EventKind::Modify(ModifyKind::Name(mode))).set_tracker(tracker);
626 for path in paths {
627 event = event.add_path(PathBuf::from(path));
628 }
629 event
630 }
631
632 #[test]
633 /// Verifies duplicate halves and a complete rename collapse to one rename.
634 fn rename_from_to_and_both_collapse_to_one_semantic_event() {
635 let mut source = LocalFileTreeSource::new().unwrap();
636 let events = source
637 .normalize_events(vec![
638 rename_event(RenameMode::From, 7, &["/tmp/foo"]),
639 rename_event(RenameMode::To, 7, &["/tmp/bar"]),
640 rename_event(RenameMode::Both, 7, &["/tmp/foo", "/tmp/bar"]),
641 ])
642 .unwrap();
643 assert_eq!(events.len(), 1);
644 assert_eq!(events[0].kind(), WatchEventKind::Renamed);
645 assert_eq!(events[0].previous_uri().unwrap().path(), "/tmp/foo");
646 assert_eq!(events[0].uri().path(), "/tmp/bar");
647 }
648
649 #[test]
650 /// Verifies paired halves become one cross-parent move without `Both`.
651 fn paired_halves_are_normalized_when_backend_omits_both() {
652 let mut source = LocalFileTreeSource::new().unwrap();
653 let events = source
654 .normalize_events(vec![
655 rename_event(RenameMode::From, 9, &["/tmp/a/foo"]),
656 rename_event(RenameMode::To, 9, &["/tmp/b/foo"]),
657 ])
658 .unwrap();
659 assert_eq!(events.len(), 1);
660 assert_eq!(events[0].kind(), WatchEventKind::Moved);
661 assert_eq!(events[0].previous_uri().unwrap().path(), "/tmp/a/foo");
662 assert_eq!(events[0].uri().path(), "/tmp/b/foo");
663 }
664
665 #[test]
666 /// Verifies metadata echo after a complete rename is suppressed.
667 fn metadata_echo_for_a_complete_rename_does_not_force_reconciliation() {
668 let mut source = LocalFileTreeSource::new().unwrap();
669 let metadata = Event::new(EventKind::Modify(ModifyKind::Metadata(
670 notify::event::MetadataKind::Any,
671 )))
672 .add_path(PathBuf::from("/tmp/bar"));
673 let events = source
674 .normalize_events(vec![
675 rename_event(RenameMode::Both, 11, &["/tmp/foo", "/tmp/bar"]),
676 metadata,
677 ])
678 .unwrap();
679 assert_eq!(events.len(), 1);
680 assert_eq!(events[0].kind(), WatchEventKind::Renamed);
681 }
682
683 #[test]
684 /// Verifies a watched old-path removal echo does not duplicate rename.
685 fn watched_path_remove_echo_for_a_complete_rename_is_suppressed() {
686 let from = PathBuf::from("/tmp/foo");
687 let to = PathBuf::from("/tmp/bar");
688 let mut source = LocalFileTreeSource::new().unwrap();
689 source
690 .watched
691 .insert(from.clone(), FileUri::local(&from).unwrap());
692 let events = source
693 .normalize_events(vec![
694 rename_event(
695 RenameMode::Both,
696 12,
697 &[from.to_str().unwrap(), to.to_str().unwrap()],
698 ),
699 Event::new(EventKind::Remove(notify::event::RemoveKind::Folder)).add_path(from),
700 ])
701 .unwrap();
702 assert_eq!(events.len(), 1);
703 assert_eq!(events[0].kind(), WatchEventKind::Renamed);
704 }
705}