Merge pull request #3850 from Byron/review-and-refactor

Watcher adjustments
This commit is contained in:
Kiril Videlov 2024-05-24 16:38:18 +02:00 committed by GitHub
commit 319550d7b5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 1967 additions and 242 deletions

63
Cargo.lock generated
View File

@ -1228,6 +1228,15 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "deser-hjson"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30e1ab99fef4d11b2de312a0650bbf312fb48aa11a00084f35b27bf8c57d4cad"
dependencies = [
"serde",
]
[[package]]
name = "diff"
version = "0.1.13"
@ -1847,6 +1856,12 @@ version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004"
[[package]]
name = "futures-timer"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24"
[[package]]
name = "futures-util"
version = "0.3.30"
@ -2211,13 +2226,20 @@ dependencies = [
"anyhow",
"backoff",
"crossbeam-channel",
"deser-hjson",
"file-id",
"futures",
"gitbutler-core",
"gitbutler-watcher",
"gix",
"itertools 0.12.1",
"mock_instant",
"notify",
"parking_lot 0.12.1",
"pretty_assertions",
"rand 0.8.5",
"rstest",
"serde",
"thiserror",
"tokio",
"tokio-util",
@ -3986,6 +4008,12 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "mock_instant"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9366861eb2a2c436c20b12c8dbec5f798cea6b47ad99216be0282942e2c81ea0"
[[package]]
name = "native-tls"
version = "0.2.11"
@ -5183,6 +5211,12 @@ version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56"
[[package]]
name = "relative-path"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2"
[[package]]
name = "rend"
version = "0.4.2"
@ -5369,6 +5403,35 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rstest"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97eeab2f3c0a199bc4be135c36c924b6590b88c377d416494288c14f2db30199"
dependencies = [
"futures",
"futures-timer",
"rstest_macros",
"rustc_version",
]
[[package]]
name = "rstest_macros"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d428f8247852f894ee1be110b375111b586d4fa431f6c46e64ba5a0dcccbe605"
dependencies = [
"cfg-if",
"glob",
"proc-macro2",
"quote",
"regex",
"relative-path",
"rustc_version",
"syn 2.0.58",
"unicode-ident",
]
[[package]]
name = "rusqlite"
version = "0.29.0"

View File

@ -7,6 +7,9 @@ publish = false
[lib]
doctest = false
[features]
mock_instant = ["dep:mock_instant"]
[dependencies]
gitbutler-core.workspace = true
thiserror.workspace = true
@ -25,6 +28,16 @@ walkdir = "2.2.2"
crossbeam-channel = "0.5.12"
itertools = "0.12"
mock_instant = { version = "0.3.0", optional = true }
[dev-dependencies]
gitbutler-watcher = { path = ".", features = ["mock_instant"] }
pretty_assertions = "1.3.0"
rstest = "0.18"
serde = { version = "1.0.89", features = ["derive"] }
deser-hjson = "1.1.1"
rand = "0.8.5"
[lints.clippy]
all = "deny"
perf = "deny"

View File

@ -1,3 +1,33 @@
// Note that this file contains substantial portions of code
// from https://github.com/notify-rs/notify/blob/main/notify-debouncer-full/src/cache.rs,
// and what follows is a reproduction of its license.
//
// Copyright (c) 2023 Notify Contributors
//
// Permission is hereby granted, free of charge, to any
// person obtaining a copy of this software and associated
// documentation files (the "Software"), to deal in the
// Software without restriction, including without
// limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software
// is furnished to do so, subject to the following
// conditions:
//
// The above copyright notice and this permission notice
// shall be included in all copies or substantial portions
// of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
// ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
// TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
// SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
use std::{
collections::HashMap,
path::{Path, PathBuf},
@ -19,7 +49,7 @@ pub trait FileIdCache {
/// Add a new path to the cache or update its value.
///
/// This will be called if a new file or directory is created or if an existing file is overridden.
fn add_path(&mut self, path: &Path);
fn add_path(&mut self, path: &Path, recursive_mode: RecursiveMode);
/// Remove a path from the cache.
///
@ -29,7 +59,11 @@ pub trait FileIdCache {
/// Re-scan all paths.
///
/// This will be called if the notification back-end has dropped events.
fn rescan(&mut self);
fn rescan(&mut self, roots: &[(PathBuf, RecursiveMode)]) {
for (root, recursive_mode) in roots {
self.add_path(root, *recursive_mode);
}
}
}
/// A cache to hold the file system IDs of all watched files.
@ -39,39 +73,9 @@ pub trait FileIdCache {
#[derive(Debug, Clone, Default)]
pub struct FileIdMap {
paths: HashMap<PathBuf, FileId>,
roots: Vec<(PathBuf, RecursiveMode)>,
}
impl FileIdMap {
/// Construct an empty cache.
pub fn new() -> Self {
Default::default()
}
/// Add a path to the cache.
///
/// If `recursive_mode` is `Recursive`, all children will be added to the cache as well
/// and all paths will be kept up-to-date in case of changes like new files being added,
/// files being removed or renamed.
#[allow(dead_code)]
pub fn add_root(&mut self, path: impl Into<PathBuf>, recursive_mode: RecursiveMode) {
let path = path.into();
self.roots.push((path.clone(), recursive_mode));
self.add_path(&path);
}
/// Remove a path form the cache.
///
/// If the path was added with `Recursive` mode, all children will also be removed from the cache.
#[allow(dead_code)]
pub fn remove_root(&mut self, path: impl AsRef<Path>) {
self.roots.retain(|(root, _)| !root.starts_with(&path));
self.remove_path(path.as_ref());
}
fn dir_scan_depth(is_recursive: bool) -> usize {
if is_recursive {
usize::max_value()
@ -86,18 +90,8 @@ impl FileIdCache for FileIdMap {
self.paths.get(path)
}
fn add_path(&mut self, path: &Path) {
let is_recursive = self
.roots
.iter()
.find_map(|(root, recursive_mode)| {
if path.starts_with(root) {
Some(*recursive_mode == RecursiveMode::Recursive)
} else {
None
}
})
.unwrap_or_default();
fn add_path(&mut self, path: &Path, recursive_mode: RecursiveMode) {
let is_recursive = recursive_mode == RecursiveMode::Recursive;
for (path, file_id) in WalkDir::new(path)
.follow_links(true)
@ -116,27 +110,4 @@ impl FileIdCache for FileIdMap {
fn remove_path(&mut self, path: &Path) {
self.paths.retain(|p, _| !p.starts_with(path));
}
fn rescan(&mut self) {
for (root, _) in self.roots.clone() {
self.add_path(&root);
}
}
}
/// An implementation of the `FileIdCache` trait that doesn't hold any data.
///
/// This pseudo cache can be used to disable the file tracking using file system IDs.
pub struct NoCache;
impl FileIdCache for NoCache {
fn cached_file_id(&self, _path: &Path) -> Option<&FileId> {
None
}
fn add_path(&mut self, _path: &Path) {}
fn remove_path(&mut self, _path: &Path) {}
fn rescan(&mut self) {}
}

View File

@ -0,0 +1,77 @@
// Note that this file contains substantial portions of code
// from https://github.com/notify-rs/notify/blob/main/notify-types/src/debouncer_full.rs,
// and what follows is a reproduction of its license.
//
// Copyright (c) 2023 Notify Contributors
//
// Permission is hereby granted, free of charge, to any
// person obtaining a copy of this software and associated
// documentation files (the "Software"), to deal in the
// Software without restriction, including without
// limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software
// is furnished to do so, subject to the following
// conditions:
//
// The above copyright notice and this permission notice
// shall be included in all copies or substantial portions
// of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
// ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
// TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
// SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
use std::ops::{Deref, DerefMut};
#[cfg(feature = "mock_instant")]
use mock_instant::Instant;
#[cfg(not(feature = "mock_instant"))]
use std::time::Instant;
use notify::Event;
/// A debounced event is emitted after a short delay.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DebouncedEvent {
/// The original event.
pub event: Event,
/// The time at which the event occurred.
pub time: Instant,
}
impl DebouncedEvent {
pub fn new(event: Event, time: Instant) -> Self {
Self { event, time }
}
}
impl Deref for DebouncedEvent {
type Target = Event;
fn deref(&self) -> &Self::Target {
&self.event
}
}
impl DerefMut for DebouncedEvent {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.event
}
}
impl From<Event> for DebouncedEvent {
fn from(event: Event) -> Self {
Self {
event,
time: Instant::now(),
}
}
}

View File

@ -1,67 +1,33 @@
//! A debouncer for [notify] that is optimized for ease of use.
//!
//! * Only emits a single `Rename` event if the rename `From` and `To` events can be matched
//! * Merges multiple `Rename` events
//! * Takes `Rename` events into account and updates paths for events that occurred before the rename event, but which haven't been emitted, yet
//! * Optionally keeps track of the file system IDs all files and stiches rename events together (FSevents, Windows)
//! * Emits only one `Remove` event when deleting a directory (inotify)
//! * Doesn't emit duplicate create events
//! * Doesn't emit `Modify` events after a `Create` event
//!
//! # Installation
//!
//! ```toml
//! [dependencies]
//! notify-debouncer-full = "0.3.1"
//! ```
//!
//! In case you want to select specific features of notify,
//! specify notify as dependency explicitly in your dependencies.
//! Otherwise you can just use the re-export of notify from debouncer-full.
//!
//! ```toml
//! notify-debouncer-full = "0.3.1"
//! notify = { version = "..", features = [".."] }
//! ```
//!
//! # Examples
//!
//! ```rust,no_run
//! # use std::path::Path;
//! # use std::time::Duration;
//! use notify_debouncer_full::{notify::*, new_debouncer, DebounceEventResult};
//!
//! // Select recommended watcher for debouncer.
//! // Using a callback here, could also be a channel.
//! let mut debouncer = new_debouncer(Duration::from_secs(2), None, |result: DebounceEventResult| {
//! match result {
//! Ok(events) => events.iter().for_each(|event| println!("{event:?}")),
//! Err(errors) => errors.iter().for_each(|error| println!("{error:?}")),
//! }
//! }).unwrap();
//!
//! // Add a path to be watched. All files and directories at that path and
//! // below will be monitored for changes.
//! debouncer.watcher().watch(Path::new("."), RecursiveMode::Recursive).unwrap();
//!
//! // Add the same path to the file ID cache. The cache uses unique file IDs
//! // provided by the file system and is used to stich together rename events
//! // in case the notification back-end doesn't emit rename cookies.
//! debouncer.cache().add_root(Path::new("."), RecursiveMode::Recursive);
//! ```
//!
//! # Features
//!
//! The following crate features can be turned on or off in your cargo dependency config:
//!
//! - `crossbeam` enabled by default, adds [`DebounceEventHandler`](DebounceEventHandler) support for crossbeam channels.
//! Also enables crossbeam-channel in the re-exported notify. You may want to disable this when using the tokio async runtime.
//! - `serde` enables serde support for events.
//!
//! # Caveats
//!
//! As all file events are sourced from notify, the [known problems](https://docs.rs/notify/latest/notify/#known-problems) section applies here too.
// Note that this file contains substantial portions of code
// from https://github.com/notify-rs/notify/blob/main/notify-debouncer-full/src/lib.rs,
// and what follows is a reproduction of its license.
//
// Copyright (c) 2023 Notify Contributors
//
// Permission is hereby granted, free of charge, to any
// person obtaining a copy of this software and associated
// documentation files (the "Software"), to deal in the
// Software without restriction, including without
// limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software
// is furnished to do so, subject to the following
// conditions:
//
// The above copyright notice and this permission notice
// shall be included in all copies or substantial portions
// of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
// ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
// TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
// SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
use std::path::Path;
use std::{
collections::{HashMap, VecDeque},
path::PathBuf,
@ -69,41 +35,30 @@ use std::{
atomic::{AtomicBool, Ordering},
Arc,
},
time::{Duration, Instant},
time::Duration,
};
#[cfg(feature = "mock_instant")]
use mock_instant::Instant;
#[cfg(not(feature = "mock_instant"))]
use std::time::Instant;
use file_id::FileId;
use notify::{
event::{ModifyKind, RemoveKind, RenameMode},
Error, ErrorKind, Event, EventKind, RecommendedWatcher, Watcher,
Error, ErrorKind, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher,
};
use parking_lot::Mutex;
use crate::{
debouncer_cache::{FileIdCache, FileIdMap},
debouncer_event::DebouncedEvent,
};
pub(crate) mod cache;
mod event;
#[cfg(test)]
mod testing;
use cache::{FileIdCache, FileIdMap};
use event::DebouncedEvent;
/// The set of requirements for watcher debounce event handling functions.
///
/// # Example implementation
///
/// ```rust,no_run
/// # use notify::{Event, Result, EventHandler};
/// # use notify_debouncer_full::{DebounceEventHandler, DebounceEventResult};
///
/// /// Prints received events
/// struct EventPrinter;
///
/// impl DebounceEventHandler for EventPrinter {
/// fn handle_event(&mut self, result: DebounceEventResult) {
/// match result {
/// Ok(events) => events.iter().for_each(|event| println!("{event:?}")),
/// Err(errors) => errors.iter().for_each(|error| println!("{error:?}")),
/// }
/// }
/// }
/// ```
pub trait DebounceEventHandler: Send + 'static {
/// Handles an event.
fn handle_event(&mut self, event: DebounceEventResult);
@ -169,6 +124,7 @@ impl Queue {
#[derive(Debug)]
pub(crate) struct DebounceDataInner<T> {
queues: HashMap<PathBuf, Queue>,
roots: Vec<(PathBuf, RecursiveMode)>,
cache: T,
rename_event: Option<(DebouncedEvent, Option<FileId>)>,
rescan_event: Option<DebouncedEvent>,
@ -180,6 +136,7 @@ impl<T: FileIdCache> DebounceDataInner<T> {
pub(crate) fn new(cache: T, timeout: Duration) -> Self {
Self {
queues: HashMap::new(),
roots: Vec::new(),
cache,
rename_event: None,
rescan_event: None,
@ -206,7 +163,7 @@ impl<T: FileIdCache> DebounceDataInner<T> {
for (path, mut queue) in self.queues.drain() {
let mut kind_index = HashMap::new();
tracing::debug!("Checking path: {:?}", path);
tracing::trace!("Checking path: {:?}", path);
while let Some(event) = queue.events.pop_front() {
if now.saturating_duration_since(event.time) >= self.timeout {
// remove previous event of the same kind
@ -224,7 +181,7 @@ impl<T: FileIdCache> DebounceDataInner<T> {
events_expired.push(event);
} else if flush_all {
tracing::debug!("Flushing event! {:?}", event.event);
tracing::trace!("Flushing event! {:?}", event.event);
events_expired.push(event);
} else {
queue.events.push_front(event);
@ -250,7 +207,7 @@ impl<T: FileIdCache> DebounceDataInner<T> {
});
for event in &events_expired {
tracing::debug!("Dispatching event: {:?}", event.event);
tracing::trace!("Dispatching event: {:?}", event.event);
}
events_expired
@ -270,10 +227,10 @@ impl<T: FileIdCache> DebounceDataInner<T> {
/// Add new event to debouncer cache
pub fn add_event(&mut self, event: Event) {
tracing::debug!("Received event: {:?}", event);
tracing::trace!("Received event: {:?}", event);
if event.need_rescan() {
self.cache.rescan();
self.cache.rescan(&self.roots);
self.rescan_event = Some(event.into());
return;
}
@ -282,8 +239,7 @@ impl<T: FileIdCache> DebounceDataInner<T> {
match &event.kind {
EventKind::Create(_) => {
self.cache.add_path(path);
self.cache.add_path(path, self.recursive_mode(path));
self.push_event(event, Instant::now());
}
EventKind::Modify(ModifyKind::Name(rename_mode)) => {
@ -317,7 +273,7 @@ impl<T: FileIdCache> DebounceDataInner<T> {
}
_ => {
if self.cache.cached_file_id(path).is_none() {
self.cache.add_path(path);
self.cache.add_path(path, self.recursive_mode(path));
}
self.push_event(event, Instant::now());
@ -325,6 +281,19 @@ impl<T: FileIdCache> DebounceDataInner<T> {
}
}
fn recursive_mode(&self, path: &Path) -> RecursiveMode {
self.roots
.iter()
.find_map(|(root, recursive_mode)| {
if path.starts_with(root) {
Some(*recursive_mode)
} else {
None
}
})
.unwrap_or(RecursiveMode::NonRecursive)
}
fn handle_rename_from(&mut self, event: Event) {
let time = Instant::now();
let path = &event.paths[0];
@ -339,7 +308,8 @@ impl<T: FileIdCache> DebounceDataInner<T> {
}
fn handle_rename_to(&mut self, event: Event) {
self.cache.add_path(&event.paths[0]);
let path = &event.paths[0];
self.cache.add_path(path, self.recursive_mode(path));
let trackers_match = self
.rename_event
@ -359,7 +329,7 @@ impl<T: FileIdCache> DebounceDataInner<T> {
.and_then(|(_, id)| id.as_ref())
.and_then(|from_file_id| {
self.cache
.cached_file_id(&event.paths[0])
.cached_file_id(path)
.map(|to_file_id| from_file_id == to_file_id)
})
.unwrap_or_default();
@ -534,15 +504,36 @@ impl<T: Watcher, C: FileIdCache> Debouncer<T, C> {
self.stop.store(true, Ordering::Relaxed);
}
fn add_root(&mut self, path: impl Into<PathBuf>, recursive_mode: RecursiveMode) {
let path = path.into();
let mut data = self.data.lock();
// skip, if the root has already been added
if data.roots.iter().any(|(p, _)| p == &path) {
return;
}
data.roots.push((path.clone(), recursive_mode));
data.cache.add_path(&path, recursive_mode);
}
// Note that code for unwatching/remove_root is available in the history.
pub fn watch(
&mut self,
path: impl AsRef<Path>,
recursive_mode: RecursiveMode,
) -> notify::Result<()> {
self.watcher.watch(path.as_ref(), recursive_mode)?;
self.add_root(path.as_ref(), recursive_mode);
Ok(())
}
/// Indicates that on the next tick of the debouncer thread, all events should be emitted.
pub fn flush_nonblocking(&self) {
self.flush.store(true, Ordering::Relaxed);
}
/// Access to the internally used notify Watcher backend
pub fn watcher(&mut self) -> &mut T {
&mut self.watcher
}
}
impl<T: Watcher, C: FileIdCache> Drop for Debouncer<T, C> {
@ -632,7 +623,7 @@ pub fn new_debouncer_opt<F: DebounceEventHandler, T: Watcher, C: FileIdCache + S
}
if !send_data.is_empty() {
if should_flush {
tracing::debug!("Flushed {} events", send_data.len());
tracing::trace!("Flushed {} events", send_data.len());
}
event_handler.handle_event(Ok(send_data));
@ -683,7 +674,7 @@ pub fn new_debouncer<F: DebounceEventHandler>(
tick_rate,
flush_after,
event_handler,
FileIdMap::new(),
FileIdMap::default(),
notify::Config::default(),
)
}

View File

@ -0,0 +1,210 @@
// Note that this file contains substantial portions of code
// from https://github.com/notify-rs/notify/blob/main/notify-debouncer-full/src/testing.rs,
// and what follows is a reproduction of its license.
//
// Copyright (c) 2023 Notify Contributors
//
// Permission is hereby granted, free of charge, to any
// person obtaining a copy of this software and associated
// documentation files (the "Software"), to deal in the
// Software without restriction, including without
// limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software
// is furnished to do so, subject to the following
// conditions:
//
// The above copyright notice and this permission notice
// shall be included in all copies or substantial portions
// of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
// ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
// TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
// SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
use std::{path::Path, time::Duration};
use mock_instant::Instant;
pub(crate) use schema::TestCase;
use mock_instant::MockClock;
use notify::RecursiveMode;
use pretty_assertions::assert_eq;
use rstest::rstest;
use std::path::PathBuf;
#[rstest]
fn state(
#[values(
"add_create_event",
"add_create_event_after_remove_event",
"add_create_dir_event_twice",
"add_modify_content_event_after_create_event",
"add_rename_from_event",
"add_rename_from_event_after_create_event",
"add_rename_from_event_after_modify_event",
"add_rename_from_event_after_create_and_modify_event",
"add_rename_from_event_after_rename_from_event",
"add_rename_to_event",
"add_rename_to_dir_event",
"add_rename_from_and_to_event",
"add_rename_from_and_to_event_after_create",
"add_rename_from_and_to_event_after_rename",
"add_rename_from_and_to_event_after_modify_content",
"add_rename_from_and_to_event_override_created",
"add_rename_from_and_to_event_override_modified",
"add_rename_from_and_to_event_override_removed",
"add_rename_from_and_to_event_with_file_ids",
"add_rename_from_and_to_event_with_different_file_ids",
"add_rename_from_and_to_event_with_different_tracker",
"add_rename_both_event",
"add_remove_event",
"add_remove_event_after_create_event",
"add_remove_event_after_modify_event",
"add_remove_event_after_create_and_modify_event",
"add_remove_parent_event_after_remove_child_event",
"add_errors",
"emit_continuous_modify_content_events",
"emit_events_in_chronological_order",
"emit_events_with_a_prepended_rename_event",
"emit_close_events_only_once",
"emit_modify_event_after_close_event",
"emit_needs_rescan_event",
"read_file_id_without_create_event"
)]
file_name: &str,
) {
let file_content =
std::fs::read_to_string(Path::new(&format!("tests/fixtures/{file_name}.hjson"))).unwrap();
let mut test_case = deser_hjson::from_str::<TestCase>(&file_content).unwrap();
MockClock::set_time(Duration::default());
let time = Instant::now();
let mut state = test_case.state.into_debounce_data_inner(time);
state.roots = vec![(PathBuf::from("/"), RecursiveMode::Recursive)];
for event in test_case.events {
let event = event.into_debounced_event(time, None);
MockClock::set_time(event.time - time);
state.add_event(event.event);
}
for error in test_case.errors {
let error = error.into_notify_error();
state.add_error(error);
}
let expected_errors = std::mem::take(&mut test_case.expected.errors);
let expected_events = std::mem::take(&mut test_case.expected.events);
let expected_state = test_case.expected.into_debounce_data_inner(time);
assert_eq!(
state.queues, expected_state.queues,
"queues not as expected"
);
assert_eq!(
state.rename_event, expected_state.rename_event,
"rename event not as expected"
);
assert_eq!(
state.rescan_event, expected_state.rescan_event,
"rescan event not as expected"
);
assert_eq!(
state.cache.paths, expected_state.cache.paths,
"cache not as expected"
);
assert_eq!(
state
.errors
.iter()
.map(|e| format!("{:?}", e))
.collect::<Vec<_>>(),
expected_errors
.iter()
.map(|e| format!("{:?}", e.clone().into_notify_error()))
.collect::<Vec<_>>(),
"errors not as expected"
);
let backup_time = Instant::now().duration_since(time);
let backup_queues = state.queues.clone();
for (delay, events) in expected_events {
MockClock::set_time(backup_time);
state.queues.clone_from(&backup_queues);
match delay.as_str() {
"none" => {}
"short" => MockClock::advance(Duration::from_millis(10)),
"long" => MockClock::advance(Duration::from_millis(100)),
_ => {
if let Ok(ts) = delay.parse::<u64>() {
let ts = time + Duration::from_millis(ts);
MockClock::set_time(ts - time);
}
}
}
let events = events
.into_iter()
.map(|event| event.into_debounced_event(time, None))
.collect::<Vec<_>>();
assert_eq!(
state.debounced_events(false),
events,
"debounced events after a `{delay}` delay"
);
}
}
mod schema;
mod utils {
use crate::debouncer::FileIdCache;
use file_id::FileId;
use notify::RecursiveMode;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct TestCache {
pub paths: HashMap<PathBuf, FileId>,
pub file_system: HashMap<PathBuf, FileId>,
}
impl TestCache {
pub fn new(paths: HashMap<PathBuf, FileId>, file_system: HashMap<PathBuf, FileId>) -> Self {
Self { paths, file_system }
}
}
impl FileIdCache for TestCache {
fn cached_file_id(&self, path: &Path) -> Option<&FileId> {
self.paths.get(path)
}
fn add_path(&mut self, path: &Path, recursive_mode: RecursiveMode) {
for (file_path, file_id) in &self.file_system {
if file_path == path
|| (file_path.starts_with(path) && recursive_mode == RecursiveMode::Recursive)
{
self.paths.insert(file_path.clone(), *file_id);
}
}
}
fn remove_path(&mut self, path: &Path) {
self.paths.remove(path);
}
}
}

View File

@ -0,0 +1,297 @@
// Note that this file contains substantial portions of code
// from https://github.com/notify-rs/notify/blob/main/notify-debouncer-full/src/testing.rs,
// and what follows is a reproduction of its license.
//
// Copyright (c) 2023 Notify Contributors
//
// Permission is hereby granted, free of charge, to any
// person obtaining a copy of this software and associated
// documentation files (the "Software"), to deal in the
// Software without restriction, including without
// limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software
// is furnished to do so, subject to the following
// conditions:
//
// The above copyright notice and this permission notice
// shall be included in all copies or substantial portions
// of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
// ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
// TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
// SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
// IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
use crate::debouncer::{DebounceDataInner, DebouncedEvent};
use file_id::FileId;
use mock_instant::Instant;
use notify::event::{
AccessKind, AccessMode, CreateKind, DataChange, Flag, MetadataKind, ModifyKind, RemoveKind,
RenameMode,
};
use notify::{ErrorKind, EventKind};
use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
use std::time::Duration;
use super::utils::TestCache;
use serde::Deserialize;
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub(crate) struct Error {
/// The error kind is parsed by `into_notify_error`
pub kind: String,
/// The error paths
#[serde(default)]
pub paths: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub(crate) struct Event {
/// The timestamp the event occurred
#[serde(default)]
pub time: u64,
/// The event kind is parsed by `into_notify_event`
pub kind: String,
/// The event paths
#[serde(default)]
pub paths: Vec<String>,
/// The event flags
#[serde(default)]
pub flags: Vec<String>,
/// The event tracker
pub tracker: Option<usize>,
/// The event info
pub info: Option<String>,
/// The file id for the file associated with the event
///
/// Only used for the rename event.
pub file_id: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub(crate) struct Queue {
pub events: Vec<Event>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub(crate) struct State {
/// Timeout for the debouncer
///
/// Only used for the initial state.
pub timeout: Option<u64>,
/// The event queues for each file
#[serde(default)]
pub queues: HashMap<String, Queue>,
/// Cached file ids
#[serde(default)]
pub cache: HashMap<String, u64>,
/// A map of file ids, used instead of accessing the file system
#[serde(default)]
pub file_system: HashMap<String, u64>,
/// Current rename event
pub rename_event: Option<Event>,
/// Current rescan event
pub rescan_event: Option<Event>,
/// Debounced events
///
/// Only used for the expected state.
#[serde(default)]
pub events: HashMap<String, Vec<Event>>,
/// Errors
#[serde(default)]
pub errors: Vec<Error>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub(crate) struct TestCase {
/// Initial state
pub state: State,
/// Events that are added during the test
#[serde(default)]
pub events: Vec<Event>,
/// Errors that are added during the test
#[serde(default)]
pub errors: Vec<Error>,
/// Expected state after the test
pub expected: State,
}
impl Error {
pub fn into_notify_error(self) -> notify::Error {
let kind = match &*self.kind {
"path-not-found" => ErrorKind::PathNotFound,
"watch-not-found" => ErrorKind::WatchNotFound,
"max-files-watch" => ErrorKind::MaxFilesWatch,
_ => panic!("unknown error type `{}`", self.kind),
};
let mut error = notify::Error::new(kind);
for p in self.paths {
error = error.add_path(PathBuf::from(p));
}
error
}
}
impl Event {
#[rustfmt::skip]
pub fn into_debounced_event(self, time: Instant, path: Option<&str>) -> DebouncedEvent {
let kind = match &*self.kind {
"any" => EventKind::Any,
"other" => EventKind::Other,
"access-any" => EventKind::Access(AccessKind::Any),
"access-read" => EventKind::Access(AccessKind::Read),
"access-open-any" => EventKind::Access(AccessKind::Open(AccessMode::Any)),
"access-open-execute" => EventKind::Access(AccessKind::Open(AccessMode::Execute)),
"access-open-read" => EventKind::Access(AccessKind::Open(AccessMode::Read)),
"access-open-write" => EventKind::Access(AccessKind::Open(AccessMode::Write)),
"access-open-other" => EventKind::Access(AccessKind::Open(AccessMode::Other)),
"access-close-any" => EventKind::Access(AccessKind::Close(AccessMode::Any)),
"access-close-execute" => EventKind::Access(AccessKind::Close(AccessMode::Execute)),
"access-close-read" => EventKind::Access(AccessKind::Close(AccessMode::Read)),
"access-close-write" => EventKind::Access(AccessKind::Close(AccessMode::Write)),
"access-close-other" => EventKind::Access(AccessKind::Close(AccessMode::Other)),
"access-other" => EventKind::Access(AccessKind::Other),
"create-any" => EventKind::Create(CreateKind::Any),
"create-file" => EventKind::Create(CreateKind::File),
"create-folder" => EventKind::Create(CreateKind::Folder),
"create-other" => EventKind::Create(CreateKind::Other),
"modify-any" => EventKind::Modify(ModifyKind::Any),
"modify-other" => EventKind::Modify(ModifyKind::Other),
"modify-data-any" => EventKind::Modify(ModifyKind::Data(DataChange::Any)),
"modify-data-size" => EventKind::Modify(ModifyKind::Data(DataChange::Size)),
"modify-data-content" => EventKind::Modify(ModifyKind::Data(DataChange::Content)),
"modify-data-other" => EventKind::Modify(ModifyKind::Data(DataChange::Other)),
"modify-metadata-any" => EventKind::Modify(ModifyKind::Metadata(MetadataKind::Any)),
"modify-metadata-access-time" => EventKind::Modify(ModifyKind::Metadata(MetadataKind::AccessTime)),
"modify-metadata-write-time" => EventKind::Modify(ModifyKind::Metadata(MetadataKind::WriteTime)),
"modify-metadata-permissions" => EventKind::Modify(ModifyKind::Metadata(MetadataKind::Permissions)),
"modify-metadata-ownership" => EventKind::Modify(ModifyKind::Metadata(MetadataKind::Ownership)),
"modify-metadata-extended" => EventKind::Modify(ModifyKind::Metadata(MetadataKind::Extended)),
"modify-metadata-other" => EventKind::Modify(ModifyKind::Metadata(MetadataKind::Other)),
"rename-any" => EventKind::Modify(ModifyKind::Name(RenameMode::Any)),
"rename-from" => EventKind::Modify(ModifyKind::Name(RenameMode::From)),
"rename-to" => EventKind::Modify(ModifyKind::Name(RenameMode::To)),
"rename-both" => EventKind::Modify(ModifyKind::Name(RenameMode::Both)),
"rename-other" => EventKind::Modify(ModifyKind::Name(RenameMode::Other)),
"remove-any" => EventKind::Remove(RemoveKind::Any),
"remove-file" => EventKind::Remove(RemoveKind::File),
"remove-folder" => EventKind::Remove(RemoveKind::Folder),
"remove-other" => EventKind::Remove(RemoveKind::Other),
_ => panic!("unknown event type `{}`", self.kind),
};
let mut event = notify::Event::new(kind);
for p in self.paths {
event = event.add_path(if p == "*" {
PathBuf::from(path.expect("cannot replace `*`"))
} else {
PathBuf::from(p)
});
if let Some(tracker) = self.tracker {
event = event.set_tracker(tracker);
}
if let Some(info) = &self.info {
event = event.set_info(info.as_str());
}
}
for f in self.flags {
let flag = match &*f {
"rescan" => Flag::Rescan,
_ => panic!("unknown event flag `{f}`"),
};
event = event.set_flag(flag);
}
DebouncedEvent { event, time: time + Duration::from_millis(self.time) }
}
}
impl State {
pub(crate) fn into_debounce_data_inner(self, time: Instant) -> DebounceDataInner<TestCache> {
let queues = self
.queues
.into_iter()
.map(|(path, queue)| {
let queue = crate::debouncer::Queue {
events: queue
.events
.into_iter()
.map(|event| event.into_debounced_event(time, Some(&path)))
.collect::<VecDeque<_>>(),
};
(path.into(), queue)
})
.collect();
let cache = self
.cache
.into_iter()
.map(|(path, id)| {
let path = PathBuf::from(path);
let id = FileId::new_inode(id, id);
(path, id)
})
.collect::<HashMap<_, _>>();
let file_system = self
.file_system
.into_iter()
.map(|(path, id)| {
let path = PathBuf::from(path);
let id = FileId::new_inode(id, id);
(path, id)
})
.collect::<HashMap<_, _>>();
let cache = TestCache::new(cache, file_system);
let rename_event = self.rename_event.map(|e| {
let file_id = e.file_id.map(|id| FileId::new_inode(id, id));
let event = e.into_debounced_event(time, None);
(event, file_id)
});
let rescan_event = self
.rescan_event
.map(|e| e.into_debounced_event(time, None));
DebounceDataInner {
queues,
roots: Vec::new(),
cache,
rename_event,
rescan_event,
errors: Vec::new(),
timeout: Duration::from_millis(self.timeout.unwrap_or(50)),
}
}
}

View File

@ -1,53 +0,0 @@
use std::ops::{Deref, DerefMut};
use std::time::Instant;
use notify::Event;
/// A debounced event is emitted after a short delay.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DebouncedEvent {
/// The original event.
pub event: Event,
/// The time at which the event occurred.
pub time: Instant,
}
impl DebouncedEvent {
pub fn new(event: Event, time: Instant) -> Self {
Self { event, time }
}
}
impl Deref for DebouncedEvent {
type Target = Event;
fn deref(&self) -> &Self::Target {
&self.event
}
}
impl DerefMut for DebouncedEvent {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.event
}
}
impl Default for DebouncedEvent {
fn default() -> Self {
Self {
event: Default::default(),
time: Instant::now(),
}
}
}
impl From<Event> for DebouncedEvent {
fn from(event: Event) -> Self {
Self {
event,
time: Instant::now(),
}
}
}

View File

@ -1,14 +1,14 @@
use std::collections::HashSet;
use std::path::Path;
use std::time::Duration;
use std::{collections::HashSet, sync::Arc};
use crate::debouncer::cache::FileIdMap;
use crate::debouncer::Debouncer;
use crate::debouncer_cache::FileIdMap;
use crate::{debouncer::new_debouncer, events::InternalEvent};
use anyhow::{anyhow, Context, Result};
use gitbutler_core::ops::OPLOG_FILE_NAME;
use gitbutler_core::projects::ProjectId;
use notify::{RecommendedWatcher, Watcher};
use notify::RecommendedWatcher;
use tokio::task;
use tracing::Level;
@ -55,7 +55,7 @@ pub fn spawn(
project_id: ProjectId,
worktree_path: &std::path::Path,
out: tokio::sync::mpsc::UnboundedSender<InternalEvent>,
) -> Result<Arc<Debouncer<RecommendedWatcher, FileIdMap>>> {
) -> Result<Debouncer<RecommendedWatcher, FileIdMap>> {
let (notify_tx, notify_rx) = std::sync::mpsc::channel();
let mut debouncer = new_debouncer(
DEBOUNCE_TIMEOUT,
@ -88,12 +88,11 @@ pub fn spawn(
// Start the watcher, but retry if there are transient errors.
backoff::retry(policy, || {
let watcher = debouncer.watcher();
watcher
debouncer
.watch(worktree_path, notify::RecursiveMode::Recursive)
.and_then(|()| {
if let Some(git_dir) = extra_git_dir_to_watch {
watcher.watch(git_dir, notify::RecursiveMode::Recursive)
debouncer.watch(git_dir, notify::RecursiveMode::Recursive)
} else {
Ok(())
}
@ -228,7 +227,7 @@ pub fn spawn(
}
}
});
Ok(debouncer.into())
Ok(debouncer)
}
#[cfg(target_family = "unix")]

View File

@ -7,14 +7,7 @@ mod events;
use events::InternalEvent;
pub use events::{Action, Change};
mod debouncer;
mod debouncer_cache;
mod debouncer_event;
mod file_monitor;
mod handler;
pub use handler::Handler;
use std::path::Path;
use anyhow::{Context, Result};
@ -25,6 +18,10 @@ use tokio::{
};
use tokio_util::sync::CancellationToken;
mod debouncer;
mod file_monitor;
mod handler;
/// An abstraction over a link to the spawned watcher, which runs in the background.
pub struct WatcherHandle {
/// A way to post events and interact with the actual handler in the background.

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,20 @@
// https://github.com/spacedriveapp/spacedrive/blob/90a350946914be7f91ba692887ca03db659d530a/core/src/location/manager/watcher/macos.rs
//
// This is a MacOS specific event that happens when a folder is created trough Finder.
// It creates a folder but 2 events are triggered in FSEvents.
{
state: {}
events: [
{ kind: "create-folder", paths: ["/watch/dir"] }
{ kind: "create-folder", paths: ["/watch/dir"] }
]
expected: {
queues: {
/watch/dir: {
events: [
{ kind: "create-folder", paths: ["*"] }
]
}
}
}
}

View File

@ -0,0 +1,29 @@
{
state: {
file_system: {
/watch/file: 1
}
}
events: [
{ kind: "create-any", paths: ["/watch/file"] }
]
expected: {
queues: {
/watch/file: {
events: [
{ kind: "create-any", paths: ["*"] }
]
}
}
cache: {
/watch/file: 1
}
events: {
none: []
short: []
long: [
{ kind: "create-any", paths: ["/watch/file"] }
]
}
}
}

View File

@ -0,0 +1,24 @@
{
state: {
queues: {
/watch/file: {
events: [
{ kind: "remove-any", paths: ["*"] }
]
}
}
}
events: [
{ kind: "create-any", paths: ["/watch/file"] }
]
expected: {
queues: {
/watch/file: {
events: [
{ kind: "remove-any", paths: ["*"] }
{ kind: "create-any", paths: ["*"] }
]
}
}
}
}

View File

@ -0,0 +1,13 @@
{
state: {}
errors: [
{ kind: "max-files-watch" }
{ kind: "path-not-found", paths: ["/watch/file"] }
]
expected: {
errors: [
{ kind: "max-files-watch" }
{ kind: "path-not-found", paths: ["/watch/file"] }
]
}
}

View File

@ -0,0 +1,26 @@
// https://github.com/spacedriveapp/spacedrive/blob/90a350946914be7f91ba692887ca03db659d530a/core/src/location/manager/watcher/macos.rs
// MacOS emits a Create File and then an Update Content event when a file is created.
{
state: {
queues: {
/watch/file: {
events: [
{ kind: "create-file", paths: ["*"] }
]
}
}
}
events: [
{ kind: "modify-data-content", paths: ["/watch/file"] }
]
expected: {
queues: {
/watch/file: {
events: [
{ kind: "create-file", paths: ["*"] }
]
}
}
}
}

View File

@ -0,0 +1,15 @@
{
state: {}
events: [
{ kind: "remove-any", paths: ["/watch/file"] }
]
expected: {
queues: {
/watch/file: {
events: [
{ kind: "remove-any", paths: ["*"] }
]
}
}
}
}

View File

@ -0,0 +1,16 @@
{
state: {
queues: {
/watch/file: {
events: [
{ kind: "create-any", paths: ["*"] }
{ kind: "modify-data-any", paths: ["*"] }
]
}
}
}
events: [
{ kind: "remove-any", paths: ["/watch/file"] }
]
expected: {}
}

View File

@ -0,0 +1,15 @@
{
state: {
queues: {
/watch/file: {
events: [
{ kind: "create-any", paths: ["*"] }
]
}
}
}
events: [
{ kind: "remove-any", paths: ["/watch/file"] }
]
expected: {}
}

View File

@ -0,0 +1,23 @@
{
state: {
queues: {
/watch/file: {
events: [
{ kind: "modify-data-any", paths: ["*"] }
]
}
}
}
events: [
{ kind: "remove-any", paths: ["/watch/file"] }
]
expected: {
queues: {
/watch/file: {
events: [
{ kind: "remove-any", paths: ["*"] }
]
}
}
}
}

View File

@ -0,0 +1,25 @@
{
state: {
cache: {
/watch: 1
/watch/parent: 2
/watch/parent/child: 3
}
}
events: [
{ kind: "remove-any", paths: ["/watch/parent/child"] }
{ kind: "remove-any", paths: ["/watch/parent"] }
]
expected: {
queues: {
/watch/parent: {
events: [
{ kind: "remove-any", paths: ["*"] }
]
}
}
cache: {
/watch: 1
}
}
}

View File

@ -0,0 +1,7 @@
{
state: {}
events: [
{ kind: "rename-both", paths: ["/watch/source", "/watch/target"] }
]
expected: {}
}

View File

@ -0,0 +1,16 @@
{
state: {}
events: [
{ kind: "rename-from", paths: ["/watch/source"], tracker: 1 }
{ kind: "rename-to", paths: ["/watch/target"], tracker: 1 }
]
expected: {
queues: {
/watch/target: {
events: [
{ kind: "rename-both", paths: ["/watch/source", "/watch/target"], tracker: 1 }
]
}
}
}
}

View File

@ -0,0 +1,24 @@
{
state: {
queues: {
/watch/source: {
events: [
{ kind: "create-any", paths: ["*"] }
]
}
}
}
events: [
{ kind: "rename-from", paths: ["/watch/source"], tracker: 1 }
{ kind: "rename-to", paths: ["/watch/target"], tracker: 1 }
]
expected: {
queues: {
/watch/target: {
events: [
{ kind: "create-any", paths: ["*"] }
]
}
}
}
}

View File

@ -0,0 +1,25 @@
{
state: {
queues: {
/watch/source: {
events: [
{ kind: "modify-data-content", paths: ["*"] }
]
}
}
}
events: [
{ kind: "rename-from", paths: ["/watch/source"], tracker: 1 }
{ kind: "rename-to", paths: ["/watch/target"], tracker: 1 }
]
expected: {
queues: {
/watch/target: {
events: [
{ kind: "rename-both", paths: ["/watch/source", "/watch/target"], tracker: 1 }
{ kind: "modify-data-content", paths: ["*"] }
]
}
}
}
}

View File

@ -0,0 +1,24 @@
{
state: {
queues: {
/watch/temp: {
events: [
{ kind: "rename-both", paths: ["/watch/source", "/watch/temp"], tracker: 1, time: 1 }
]
}
}
}
events: [
{ kind: "rename-from", paths: ["/watch/temp"], tracker: 2, time: 2 }
{ kind: "rename-to", paths: ["/watch/target"], tracker: 2, time: 3 }
]
expected: {
queues: {
/watch/target: {
events: [
{ kind: "rename-both", paths: ["/watch/source", "/watch/target"], tracker: 2, time: 1 }
]
}
}
}
}

View File

@ -0,0 +1,24 @@
{
state: {
queues: {
/watch/target: {
events: [
{ kind: "create-any", paths: ["*"] }
]
}
}
}
events: [
{ kind: "rename-from", paths: ["/watch/source"], tracker: 1 }
{ kind: "rename-to", paths: ["/watch/target"], tracker: 1 }
]
expected: {
queues: {
/watch/target: {
events: [
{ kind: "rename-both", paths: ["/watch/source", "/watch/target"], tracker: 1 }
]
}
}
}
}

View File

@ -0,0 +1,35 @@
{
state: {
queues: {
/watch/target: {
events: [
{ kind: "modify-data-any", paths: ["*"] }
]
}
}
cache: {
/watch/target: 1
/watch/source: 2
}
file_system: {
/watch/target: 2
}
}
events: [
{ kind: "rename-from", paths: ["/watch/source"], tracker: 1 }
{ kind: "rename-to", paths: ["/watch/target"], tracker: 1 }
]
expected: {
queues: {
/watch/target: {
events: [
{ kind: "remove-any", paths: ["*"], info: "override" }
{ kind: "rename-both", paths: ["/watch/source", "/watch/target"], tracker: 1 }
]
}
}
cache: {
/watch/target: 2
}
}
}

View File

@ -0,0 +1,25 @@
{
state: {
queues: {
/watch/target: {
events: [
{ kind: "remove-any", paths: ["*"] }
]
}
}
}
events: [
{ kind: "rename-from", paths: ["/watch/source"], tracker: 1 }
{ kind: "rename-to", paths: ["/watch/target"], tracker: 1 }
]
expected: {
queues: {
/watch/target: {
events: [
{ kind: "remove-any", paths: ["*"] }
{ kind: "rename-both", paths: ["/watch/source", "/watch/target"], tracker: 1 }
]
}
}
}
}

View File

@ -0,0 +1,31 @@
{
state: {
cache: {
/watch/source: 1
}
file_system: {
/watch/target: 2
}
}
events: [
{ kind: "rename-from", paths: ["/watch/source"] }
{ kind: "rename-to", paths: ["/watch/target"] }
]
expected: {
queues: {
/watch/source: {
events: [
{ kind: "rename-from", paths: ["*"] }
]
}
/watch/target: {
events: [
{ kind: "rename-to", paths: ["*"] }
]
}
}
cache: {
/watch/target: 2
}
}
}

View File

@ -0,0 +1,21 @@
{
state: {}
events: [
{ kind: "rename-from", paths: ["/watch/source"], tracker: 1 }
{ kind: "rename-to", paths: ["/watch/target"], tracker: 2 }
]
expected: {
queues: {
/watch/source: {
events: [
{ kind: "rename-from", paths: ["*"], tracker: 1 }
]
}
/watch/target: {
events: [
{ kind: "rename-to", paths: ["*"], tracker: 2 }
]
}
}
}
}

View File

@ -0,0 +1,26 @@
{
state: {
cache: {
/watch/source: 1
}
file_system: {
/watch/target: 1
}
}
events: [
{ kind: "rename-from", paths: ["/watch/source"] }
{ kind: "rename-to", paths: ["/watch/target"] }
]
expected: {
queues: {
/watch/target: {
events: [
{ kind: "rename-both", paths: ["/watch/source", "/watch/target"] }
]
}
}
cache: {
/watch/target: 1
}
}
}

View File

@ -0,0 +1,16 @@
{
state: {}
events: [
{ kind: "rename-from", paths: ["/watch/source"] }
]
expected: {
queues: {
/watch/source: {
events: [
{ kind: "rename-from", paths: ["*"] }
]
}
}
rename_event: { kind: "rename-from", paths: ["/watch/source"] }
}
}

View File

@ -0,0 +1,27 @@
{
state: {
queues: {
/watch/file: {
events: [
{ kind: "create-any", paths: ["*"] }
{ kind: "modify-data-any", paths: ["*"] }
]
}
}
}
events: [
{ kind: "rename-from", paths: ["/watch/file"] }
]
expected: {
queues: {
/watch/file: {
events: [
{ kind: "create-any", paths: ["*"] }
{ kind: "modify-data-any", paths: ["*"] }
{ kind: "rename-from", paths: ["*"] }
]
}
}
rename_event: { kind: "rename-from", paths: ["/watch/file"] }
}
}

View File

@ -0,0 +1,25 @@
{
state: {
queues: {
/watch/file: {
events: [
{ kind: "create-any", paths: ["*"] }
]
}
}
}
events: [
{ kind: "rename-from", paths: ["/watch/file"] }
]
expected: {
queues: {
/watch/file: {
events: [
{ kind: "create-any", paths: ["*"] }
{ kind: "rename-from", paths: ["*"] }
]
}
}
rename_event: { kind: "rename-from", paths: ["/watch/file"] }
}
}

View File

@ -0,0 +1,25 @@
{
state: {
queues: {
/watch/file: {
events: [
{ kind: "modify-data-any", paths: ["*"] }
]
}
}
}
events: [
{ kind: "rename-from", paths: ["/watch/file"] }
]
expected: {
queues: {
/watch/file: {
events: [
{ kind: "modify-data-any", paths: ["*"] }
{ kind: "rename-from", paths: ["*"] }
]
}
}
rename_event: { kind: "rename-from", paths: ["/watch/file"] }
}
}

View File

@ -0,0 +1,30 @@
{
state: {
queues: {
/watch/file-a: {
events: [
{ kind: "rename-from", paths: ["*"] }
]
}
}
rename_event: { kind: "rename-from", paths: ["/watch/file-a"] }
}
events: [
{ kind: "rename-from", paths: ["/watch/file-b"] }
]
expected: {
queues: {
/watch/file-a: {
events: [
{ kind: "rename-from", paths: ["*"] }
]
}
/watch/file-b: {
events: [
{ kind: "rename-from", paths: ["*"] }
]
}
}
rename_event: { kind: "rename-from", paths: ["/watch/file-b"] }
}
}

View File

@ -0,0 +1,24 @@
{
state: {
file_system: {
/watch/parent: 1
/watch/parent/child: 2
}
}
events: [
{ kind: "rename-to", paths: ["/watch/parent"] }
]
expected: {
queues: {
/watch/parent: {
events: [
{ kind: "rename-to", paths: ["*"] }
]
}
}
cache: {
/watch/parent: 1
/watch/parent/child: 2
}
}
}

View File

@ -0,0 +1,15 @@
{
state: {}
events: [
{ kind: "rename-to", paths: ["/watch/target"] }
]
expected: {
queues: {
/watch/target: {
events: [
{ kind: "rename-to", paths: ["*"] }
]
}
}
}
}

View File

@ -0,0 +1,28 @@
{
state: {}
events: [
{ kind: "modify-data-any", paths: ["/watch/file"], time: 1 }
{ kind: "access-close-write", paths: ["/watch/file"], time: 2 }
{ kind: "modify-data-any", paths: ["/watch/file"], time: 3 }
{ kind: "access-close-write", paths: ["/watch/file"], time: 4 }
]
expected: {
queues: {
/watch/file: {
events: [
{ kind: "modify-data-any", paths: ["*"], time: 1 }
{ kind: "access-close-write", paths: ["*"], time: 2 }
{ kind: "modify-data-any", paths: ["*"], time: 3 }
{ kind: "access-close-write", paths: ["*"], time: 4 }
]
}
}
events: {
short: []
long: [
{ kind: "modify-data-any", paths: ["/watch/file"], time: 3 }
{ kind: "access-close-write", paths: ["/watch/file"], time: 4 }
]
}
}
}

View File

@ -0,0 +1,49 @@
{
state: {
timeout: 5
}
events: [
{ kind: "modify-data-content", paths: ["/watch/file"], time: 1 }
{ kind: "modify-data-content", paths: ["/watch/file"], time: 2 }
{ kind: "modify-data-content", paths: ["/watch/file"], time: 3 }
]
expected: {
queues: {
/watch/file: {
events: [
{ kind: "modify-data-content", paths: ["*"], time: 1 }
{ kind: "modify-data-content", paths: ["*"], time: 2 }
{ kind: "modify-data-content", paths: ["*"], time: 3 }
]
}
}
events: {
1: []
2: []
3: []
4: []
5: []
6: [
{ kind: "modify-data-content", paths: ["/watch/file"], time: 1 }
]
7: [
{ kind: "modify-data-content", paths: ["/watch/file"], time: 2 }
]
8: [
{ kind: "modify-data-content", paths: ["/watch/file"], time: 3 }
]
9: [
{ kind: "modify-data-content", paths: ["/watch/file"], time: 3 }
]
10: [
{ kind: "modify-data-content", paths: ["/watch/file"], time: 3 }
]
100: [
{ kind: "modify-data-content", paths: ["/watch/file"], time: 3 }
]
1000: [
{ kind: "modify-data-content", paths: ["/watch/file"], time: 3 }
]
}
}
}

View File

@ -0,0 +1,45 @@
{
state: {
timeout: 5
}
events: [
{ kind: "modify-data-content", paths: ["/watch/file-a"], time: 1 }
{ kind: "modify-data-content", paths: ["/watch/file-b"], time: 3 }
{ kind: "modify-data-content", paths: ["/watch/file-c"], time: 4 }
{ kind: "modify-metadata-write-time", paths: ["/watch/file-b"], time: 7 }
{ kind: "modify-metadata-write-time", paths: ["/watch/file-c"], time: 8 }
{ kind: "modify-metadata-write-time", paths: ["/watch/file-a"], time: 9 }
]
expected: {
queues: {
/watch/file-a: {
events: [
{ kind: "modify-data-content", paths: ["*"], time: 1 }
{ kind: "modify-metadata-write-time", paths: ["*"], time: 9 }
]
}
/watch/file-b: {
events: [
{ kind: "modify-data-content", paths: ["*"], time: 3 }
{ kind: "modify-metadata-write-time", paths: ["*"], time: 7 }
]
}
/watch/file-c: {
events: [
{ kind: "modify-data-content", paths: ["*"], time: 4 }
{ kind: "modify-metadata-write-time", paths: ["*"], time: 8 }
]
}
}
events: {
long: [
{ kind: "modify-data-content", paths: ["/watch/file-a"], time: 1 }
{ kind: "modify-data-content", paths: ["/watch/file-b"], time: 3 }
{ kind: "modify-data-content", paths: ["/watch/file-c"], time: 4 }
{ kind: "modify-metadata-write-time", paths: ["/watch/file-b"], time: 7 }
{ kind: "modify-metadata-write-time", paths: ["/watch/file-c"], time: 8 }
{ kind: "modify-metadata-write-time", paths: ["/watch/file-a"], time: 9 }
]
}
}
}

View File

@ -0,0 +1,36 @@
{
state: {
timeout: 5
}
events: [
{ kind: "modify-data-content", paths: ["/watch/source"], time: 1 }
{ kind: "modify-data-content", paths: ["/watch/source"], time: 4 }
{ kind: "rename-from", paths: ["/watch/source"], tracker: 1, time: 7 }
{ kind: "rename-to", paths: ["/watch/target"], tracker: 1, time: 8 }
{ kind: "modify-metadata-write-time", paths: ["/watch/target"], time: 9 }
]
expected: {
queues: {
/watch/target: {
events: [
{ kind: "rename-both", paths: ["/watch/source", "/watch/target"], tracker: 1, time: 7 }
{ kind: "modify-data-content", paths: ["*"], time: 1 }
{ kind: "modify-data-content", paths: ["*"], time: 4 }
{ kind: "modify-metadata-write-time", paths: ["*"], time: 9 }
]
}
}
events: {
11: []
12: [
{ kind: "rename-both", paths: ["/watch/source", "/watch/target"], tracker: 1, time: 7 }
{ kind: "modify-data-content", paths: ["/watch/target"], time: 4 }
]
14: [
{ kind: "rename-both", paths: ["/watch/source", "/watch/target"], tracker: 1, time: 7 }
{ kind: "modify-data-content", paths: ["/watch/target"], time: 4 }
{ kind: "modify-metadata-write-time", paths: ["/watch/target"], time: 9 }
]
}
}
}

View File

@ -0,0 +1,30 @@
{
state: {}
events: [
{ kind: "modify-data-any", paths: ["/watch/file"], time: 1 }
{ kind: "access-close-write", paths: ["/watch/file"], time: 2 }
{ kind: "modify-data-any", paths: ["/watch/file"], time: 3 }
{ kind: "access-close-write", paths: ["/watch/file"], time: 4 }
{ kind: "modify-data-any", paths: ["/watch/file"], time: 5 }
]
expected: {
queues: {
/watch/file: {
events: [
{ kind: "modify-data-any", paths: ["*"], time: 1 }
{ kind: "access-close-write", paths: ["*"], time: 2 }
{ kind: "modify-data-any", paths: ["*"], time: 3 }
{ kind: "access-close-write", paths: ["*"], time: 4 }
{ kind: "modify-data-any", paths: ["*"], time: 5 }
]
}
}
events: {
short: []
long: [
{ kind: "access-close-write", paths: ["/watch/file"], time: 4 }
{ kind: "modify-data-any", paths: ["/watch/file"], time: 5 }
]
}
}
}

View File

@ -0,0 +1,56 @@
{
state: {
queues: {
/watch/file-a: {
events: [
{ kind: "create-any", paths: ["*"], time: 1 }
]
}
/watch/file-b: {
events: [
{ kind: "create-any", paths: ["*"], time: 2 }
]
}
}
cache: {
/watch/file-a: 1
/watch/file-b: 2
}
file_system: {
/watch/file-a: 1
/watch/file-b: 2
/watch/file-c: 3
}
}
events: [
{ kind: "other", flags: ["rescan"], time: 3 }
]
expected: {
queues: {
/watch/file-a: {
events: [
{ kind: "create-any", paths: ["*"], time: 1 }
]
}
/watch/file-b: {
events: [
{ kind: "create-any", paths: ["*"], time: 2 }
]
}
}
rescan_event: { kind: "other", flags: ["rescan"], time: 3 }
cache: {
/watch/file-a: 1
/watch/file-b: 2
/watch/file-c: 3
}
events: {
short: []
long: [
{ kind: "create-any", paths: ["/watch/file-a"], time: 1 }
{ kind: "create-any", paths: ["/watch/file-b"], time: 2 }
{ kind: "other", flags: ["rescan"], time: 3 }
]
}
}
}

View File

@ -0,0 +1,22 @@
{
state: {
file_system: {
/watch/file: 1
}
}
events: [
{ kind: "modify-data-any", paths: ["/watch/file"] }
]
expected: {
queues: {
/watch/file: {
events: [
{ kind: "modify-data-any", paths: ["*"] }
]
}
}
cache: {
/watch/file: 1
}
}
}

View File

@ -0,0 +1,267 @@
use file_id::FileId;
use mock_instant::Instant;
use notify::event::{
AccessKind, AccessMode, CreateKind, DataChange, Flag, MetadataKind, ModifyKind, RemoveKind,
RenameMode,
};
use notify::{ErrorKind, EventKind};
use std::collections::{HashMap, VecDeque};
use std::path::PathBuf;
use std::time::Duration;
use crate::{schema, TestCache};
use serde::Deserialize;
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub(crate) struct Error {
/// The error kind is parsed by `into_notify_error`
pub kind: String,
/// The error paths
#[serde(default)]
pub paths: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub(crate) struct Event {
/// The timestamp the event occurred
#[serde(default)]
pub time: u64,
/// The event kind is parsed by `into_notify_event`
pub kind: String,
/// The event paths
#[serde(default)]
pub paths: Vec<String>,
/// The event flags
#[serde(default)]
pub flags: Vec<String>,
/// The event tracker
pub tracker: Option<usize>,
/// The event info
pub info: Option<String>,
/// The file id for the file associated with the event
///
/// Only used for the rename event.
pub file_id: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub(crate) struct Queue {
pub events: Vec<Event>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub(crate) struct State {
/// Timeout for the debouncer
///
/// Only used for the initial state.
pub timeout: Option<u64>,
/// The event queues for each file
#[serde(default)]
pub queues: HashMap<String, Queue>,
/// Cached file ids
#[serde(default)]
pub cache: HashMap<String, u64>,
/// A map of file ids, used instead of accessing the file system
#[serde(default)]
pub file_system: HashMap<String, u64>,
/// Current rename event
pub rename_event: Option<Event>,
/// Current rescan event
pub rescan_event: Option<Event>,
/// Debounced events
///
/// Only used for the expected state.
#[serde(default)]
pub events: HashMap<String, Vec<Event>>,
/// Errors
#[serde(default)]
pub errors: Vec<Error>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub(crate) struct TestCase {
/// Initial state
pub state: State,
/// Events that are added during the test
#[serde(default)]
pub events: Vec<Event>,
/// Errors that are added during the test
#[serde(default)]
pub errors: Vec<Error>,
/// Expected state after the test
pub expected: State,
}
impl Error {
pub fn into_notify_error(self) -> notify::Error {
let kind = match &*self.kind {
"path-not-found" => ErrorKind::PathNotFound,
"watch-not-found" => ErrorKind::WatchNotFound,
"max-files-watch" => ErrorKind::MaxFilesWatch,
_ => panic!("unknown error type `{}`", self.kind),
};
let mut error = notify::Error::new(kind);
for p in self.paths {
error = error.add_path(PathBuf::from(p));
}
error
}
}
impl Event {
#[rustfmt::skip]
pub fn into_debounced_event(self, time: Instant, path: Option<&str>) -> DebouncedEvent {
let kind = match &*self.kind {
"any" => EventKind::Any,
"other" => EventKind::Other,
"access-any" => EventKind::Access(AccessKind::Any),
"access-read" => EventKind::Access(AccessKind::Read),
"access-open-any" => EventKind::Access(AccessKind::Open(AccessMode::Any)),
"access-open-execute" => EventKind::Access(AccessKind::Open(AccessMode::Execute)),
"access-open-read" => EventKind::Access(AccessKind::Open(AccessMode::Read)),
"access-open-write" => EventKind::Access(AccessKind::Open(AccessMode::Write)),
"access-open-other" => EventKind::Access(AccessKind::Open(AccessMode::Other)),
"access-close-any" => EventKind::Access(AccessKind::Close(AccessMode::Any)),
"access-close-execute" => EventKind::Access(AccessKind::Close(AccessMode::Execute)),
"access-close-read" => EventKind::Access(AccessKind::Close(AccessMode::Read)),
"access-close-write" => EventKind::Access(AccessKind::Close(AccessMode::Write)),
"access-close-other" => EventKind::Access(AccessKind::Close(AccessMode::Other)),
"access-other" => EventKind::Access(AccessKind::Other),
"create-any" => EventKind::Create(CreateKind::Any),
"create-file" => EventKind::Create(CreateKind::File),
"create-folder" => EventKind::Create(CreateKind::Folder),
"create-other" => EventKind::Create(CreateKind::Other),
"modify-any" => EventKind::Modify(ModifyKind::Any),
"modify-other" => EventKind::Modify(ModifyKind::Other),
"modify-data-any" => EventKind::Modify(ModifyKind::Data(DataChange::Any)),
"modify-data-size" => EventKind::Modify(ModifyKind::Data(DataChange::Size)),
"modify-data-content" => EventKind::Modify(ModifyKind::Data(DataChange::Content)),
"modify-data-other" => EventKind::Modify(ModifyKind::Data(DataChange::Other)),
"modify-metadata-any" => EventKind::Modify(ModifyKind::Metadata(MetadataKind::Any)),
"modify-metadata-access-time" => EventKind::Modify(ModifyKind::Metadata(MetadataKind::AccessTime)),
"modify-metadata-write-time" => EventKind::Modify(ModifyKind::Metadata(MetadataKind::WriteTime)),
"modify-metadata-permissions" => EventKind::Modify(ModifyKind::Metadata(MetadataKind::Permissions)),
"modify-metadata-ownership" => EventKind::Modify(ModifyKind::Metadata(MetadataKind::Ownership)),
"modify-metadata-extended" => EventKind::Modify(ModifyKind::Metadata(MetadataKind::Extended)),
"modify-metadata-other" => EventKind::Modify(ModifyKind::Metadata(MetadataKind::Other)),
"rename-any" => EventKind::Modify(ModifyKind::Name(RenameMode::Any)),
"rename-from" => EventKind::Modify(ModifyKind::Name(RenameMode::From)),
"rename-to" => EventKind::Modify(ModifyKind::Name(RenameMode::To)),
"rename-both" => EventKind::Modify(ModifyKind::Name(RenameMode::Both)),
"rename-other" => EventKind::Modify(ModifyKind::Name(RenameMode::Other)),
"remove-any" => EventKind::Remove(RemoveKind::Any),
"remove-file" => EventKind::Remove(RemoveKind::File),
"remove-folder" => EventKind::Remove(RemoveKind::Folder),
"remove-other" => EventKind::Remove(RemoveKind::Other),
_ => panic!("unknown event type `{}`", self.kind),
};
let mut event = notify::Event::new(kind);
for p in self.paths {
event = event.add_path(if p == "*" {
PathBuf::from(path.expect("cannot replace `*`"))
} else {
PathBuf::from(p)
});
if let Some(tracker) = self.tracker {
event = event.set_tracker(tracker);
}
if let Some(info) = &self.info {
event = event.set_info(info.as_str());
}
}
for f in self.flags {
let flag = match &*f {
"rescan" => Flag::Rescan,
_ => panic!("unknown event flag `{f}`"),
};
event = event.set_flag(flag);
}
DebouncedEvent { event, time: time + Duration::from_millis(self.time) }
}
}
impl State {
pub(crate) fn into_debounce_data_inner(self, time: Instant) -> DebounceDataInner<TestCache> {
let queues = self
.queues
.into_iter()
.map(|(path, queue)| {
let queue = Queue {
events: queue
.events
.into_iter()
.map(|event| event.into_debounced_event(time, Some(&path)))
.collect::<VecDeque<_>>(),
};
(path.into(), queue)
})
.collect();
let cache = self
.cache
.into_iter()
.map(|(path, id)| {
let path = PathBuf::from(path);
let id = FileId::new_inode(id, id);
(path, id)
})
.collect::<HashMap<_, _>>();
let file_system = self
.file_system
.into_iter()
.map(|(path, id)| {
let path = PathBuf::from(path);
let id = FileId::new_inode(id, id);
(path, id)
})
.collect::<HashMap<_, _>>();
let cache = TestCache::new(cache, file_system);
let rename_event = self.rename_event.map(|e| {
let file_id = e.file_id.map(|id| FileId::new_inode(id, id));
let event = e.into_debounced_event(time, None);
(event, file_id)
});
let rescan_event = self
.rescan_event
.map(|e| e.into_debounced_event(time, None));
DebounceDataInner {
queues,
roots: Vec::new(),
cache,
rename_event,
rescan_event,
errors: Vec::new(),
timeout: Duration::from_millis(self.timeout.unwrap_or(50)),
}
}
}