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}