Files
termscp/src/system/watcher.rs
T
Christian Visintin afbc74113f
Deploy docs to GitHub Pages / deploy (push) Has been cancelled
Site / build-site (push) Has been cancelled
Install.sh / build (macos-latest) (push) Has been cancelled
Install.sh / build (ubuntu-latest) (push) Has been cancelled
CI / toolchain (push) Has been cancelled
CI / fmt (push) Has been cancelled
CI / install-scripts (push) Has been cancelled
CI / crates-ubuntu-latest (push) Has been cancelled
CI / crates-windows-latest (push) Has been cancelled
CI / doc (push) Has been cancelled
CI / deny (push) Has been cancelled
CI / crates-macos-latest (push) Has been cancelled
ci: migrate project automation to Just (#442)
* ci: migrate project automation to Just

Centralize build, test, release, dependency, hook, and website commands in Just recipes. Pin workflow tooling, use the repository toolchain, and run the complete validation set in CI.

* ci: codex being codex

* docs: update CLAUDE.md for just task runner migration

Reflect the switch to just recipes for build/test/clippy/fmt, note
dprint replacing raw rustfmt, and add a cross-platform code requirement.

* fix: resolve clippy warnings breaking CI on ubuntu and windows

Use clone() instead of implicit to_string() on already-owned String
values, gate the windows-only unused make_file_at import behind
cfg(posix), and fix unused mut / manual assign-op in the windows-only
localhost test.

* fix: fmt
2026-08-28 19:29:58 +02:00

470 lines
16 KiB
Rust

//! ## File system watcher
//!
//! A watcher for file system paths, which reports changes on local fs
mod change;
// -- export
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::mpsc::{Receiver, RecvTimeoutError, channel};
use std::time::Duration;
pub use change::FsChange;
use notify::{
Config, Error as WatcherError, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher,
};
use thiserror::Error;
use crate::utils::path as path_utils;
type FsWatcherResult<T> = Result<T, FsWatcherError>;
/// Describes an error returned by the `FsWatcher`
#[derive(Debug, Error)]
pub enum FsWatcherError {
#[error("unable to unwatch this path, since is not currently watched")]
PathNotWatched,
#[error("unable to watch path, since it's already watched")]
PathAlreadyWatched,
#[error("watcher event channel disconnected")]
Disconnected,
#[error("unknown event: {0}")]
UnknownEvent(&'static str),
#[error("worker error: {0}")]
WorkerError(WatcherError),
}
impl From<WatcherError> for FsWatcherError {
fn from(err: WatcherError) -> Self {
Self::WorkerError(err)
}
}
/// Describes an event that can be received from the `FsWatcher`
#[derive(Debug, Clone, PartialEq, Eq)]
enum FsWatcherEvent {
Rename { source: PathBuf, dest: PathBuf },
Remove(PathBuf),
Create(PathBuf),
Modify(PathBuf),
Other,
}
impl TryFrom<Event> for FsWatcherEvent {
type Error = &'static str;
fn try_from(ev: Event) -> Result<Self, Self::Error> {
match ev.kind {
EventKind::Any | EventKind::Access(_) | EventKind::Other => Ok(Self::Other),
EventKind::Create(_) => {
if ev.paths.len() == 2 {
Ok(Self::Rename {
source: ev.paths[0].clone(),
dest: ev.paths[1].clone(),
})
} else if let Some(p) = ev.paths.first() {
Ok(Self::Create(p.clone()))
} else {
Err("No path found")
}
}
EventKind::Modify(_) => {
if ev.paths.len() == 2 {
Ok(Self::Rename {
source: ev.paths[0].clone(),
dest: ev.paths[1].clone(),
})
} else if let Some(p) = ev.paths.first() {
Ok(Self::Modify(p.clone()))
} else {
Err("No path found")
}
}
EventKind::Remove(_) => {
if let Some(p) = ev.paths.first() {
Ok(Self::Remove(p.clone()))
} else {
Err("No path found")
}
}
}
}
}
/// File system watcher
pub struct FsWatcher {
paths: HashMap<PathBuf, PathBuf>,
receiver: Receiver<notify::Result<Event>>,
watcher: RecommendedWatcher,
}
impl FsWatcher {
/// Initialize a new `FsWatcher`
pub fn init(delay: Duration) -> FsWatcherResult<Self> {
let (tx, receiver) = channel();
Ok(Self {
paths: HashMap::default(),
receiver,
watcher: RecommendedWatcher::new(tx, Config::default().with_poll_interval(delay))?,
})
}
/// Poll searching for the first available disk change
pub fn poll(&self) -> FsWatcherResult<Option<FsChange>> {
let res = match self.receiver.recv_timeout(Duration::from_millis(1)) {
Ok(res) => res,
Err(RecvTimeoutError::Timeout) => return Ok(None),
Err(RecvTimeoutError::Disconnected) => return Err(FsWatcherError::Disconnected),
};
// convert event to FsChange
let event = res
.map(FsWatcherEvent::try_from)
.map_err(FsWatcherError::from)?
.map_err(FsWatcherError::UnknownEvent)?;
match event {
FsWatcherEvent::Rename { source, dest } => Ok(self.build_fs_move(source, dest)),
FsWatcherEvent::Remove(p) => Ok(self.build_fs_remove(p)),
FsWatcherEvent::Modify(p) | FsWatcherEvent::Create(p) => Ok(self.build_fs_update(p)),
FsWatcherEvent::Other => {
debug!("unknown event");
Ok(None)
}
}
}
/// Watch `local` path on localhost
pub fn watch(&mut self, local: &Path, remote: &Path) -> FsWatcherResult<()> {
// Start watcher if unwatched
if !self.watched(local) {
self.watcher.watch(local, RecursiveMode::Recursive)?;
// Insert new path to paths
self.paths.insert(local.to_path_buf(), remote.to_path_buf());
Ok(())
} else {
Err(FsWatcherError::PathAlreadyWatched)
}
}
/// Returns whether `path` is currently watched.
/// This method looks also in path ancestors.
///
/// Example:
/// if `/home` is watched, then if we call `watched("/home/foo/file.txt")` will return `true`
pub fn watched(&self, path: &Path) -> bool {
self.find_watched_path(path).is_some()
}
/// Returns the list of watched paths
pub fn watched_paths(&self) -> Vec<&Path> {
Vec::from_iter(self.paths.keys().map(PathBuf::as_path))
}
/// Unwatch provided path.
/// When unwatching the path, it searches for the ancestor watched path if any.
/// Returns the unwatched resolved path
pub fn unwatch(&mut self, path: &Path) -> FsWatcherResult<PathBuf> {
let watched_path = self.find_watched_path(path).map(|x| x.0.to_path_buf());
if let Some(watched_path) = watched_path {
self.watcher.unwatch(watched_path.as_path())?;
self.paths.remove(watched_path.as_path());
Ok(watched_path)
} else {
Err(FsWatcherError::PathNotWatched)
}
}
/// Given a certain path, returns the path data associated to the path which
/// is ancestor of that path in the current watched path
fn find_watched_path(&self, p: &Path) -> Option<(&Path, &Path)> {
self.paths
.iter()
.find(|(k, _)| path_utils::is_child_of(p, k))
.map(|(k, v)| (k.as_path(), v.as_path()))
}
/// Build `FsChange` from path to local `changed_file`
fn build_fs_move(&self, source: PathBuf, destination: PathBuf) -> Option<FsChange> {
if let Some((watched_local, watched_remote)) = self.find_watched_path(&source) {
Some(FsChange::mov(
source,
destination,
watched_local,
watched_remote,
))
} else {
None
}
}
/// Build `FsChange` from path to local `changed_file`
fn build_fs_remove(&self, removed_path: PathBuf) -> Option<FsChange> {
if let Some((watched_local, watched_remote)) = self.find_watched_path(&removed_path) {
Some(FsChange::remove(
removed_path,
watched_local,
watched_remote,
))
} else {
None
}
}
/// Build `FsChange` from path to local `changed_file`
fn build_fs_update(&self, changed_file: PathBuf) -> Option<FsChange> {
if let Some((watched_local, watched_remote)) = self.find_watched_path(&changed_file) {
Some(FsChange::update(
changed_file,
watched_local,
watched_remote,
))
} else {
None
}
}
}
#[cfg(test)]
mod test {
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use super::*;
#[cfg(target_os = "macos")]
use crate::utils::test_helpers;
#[test]
fn should_init_fswatcher() {
let watcher = FsWatcher::init(Duration::from_secs(5)).unwrap();
assert!(watcher.paths.is_empty());
}
#[test]
fn should_watch_path() {
let mut watcher = FsWatcher::init(Duration::from_secs(5)).unwrap();
let tempdir = TempDir::new().unwrap();
assert!(
watcher
.watch(tempdir.path(), Path::new("/tmp/test"))
.is_ok()
);
// check if in paths
assert_eq!(
watcher.paths.get(tempdir.path()).unwrap(),
Path::new("/tmp/test")
);
// close tempdir
assert!(tempdir.close().is_ok());
}
#[test]
fn should_not_watch_path_if_subdir_of_watched_path() {
let mut watcher = FsWatcher::init(Duration::from_secs(5)).unwrap();
let tempdir = TempDir::new().unwrap();
assert!(
watcher
.watch(tempdir.path(), Path::new("/tmp/test"))
.is_ok()
);
// watch subdir
let mut subdir = tempdir.path().to_path_buf();
subdir.push("abc/def");
// should return already watched
assert!(
watcher
.watch(subdir.as_path(), Path::new("/tmp/test/abc/def"))
.is_err()
);
// close tempdir
assert!(tempdir.close().is_ok());
}
#[test]
fn should_unwatch_path() {
let mut watcher = FsWatcher::init(Duration::from_secs(5)).unwrap();
let tempdir = TempDir::new().unwrap();
assert!(
watcher
.watch(tempdir.path(), Path::new("/tmp/test"))
.is_ok()
);
// unwatch
assert!(watcher.unwatch(tempdir.path()).is_ok());
assert!(!watcher.paths.contains_key(tempdir.path()));
// close tempdir
assert!(tempdir.close().is_ok());
}
#[test]
fn should_unwatch_path_when_subdir() {
let mut watcher = FsWatcher::init(Duration::from_secs(5)).unwrap();
let tempdir = TempDir::new().unwrap();
assert!(
watcher
.watch(tempdir.path(), Path::new("/tmp/test"))
.is_ok()
);
// unwatch
let mut subdir = tempdir.path().to_path_buf();
subdir.push("abc/def");
assert_eq!(
watcher.unwatch(subdir.as_path()).unwrap().as_path(),
Path::new(tempdir.path())
);
assert!(!watcher.paths.contains_key(tempdir.path()));
// close tempdir
assert!(tempdir.close().is_ok());
}
#[test]
fn should_return_err_when_unwatching_unwatched_path() {
let mut watcher = FsWatcher::init(Duration::from_secs(5)).unwrap();
assert!(watcher.unwatch(Path::new("/tmp")).is_err());
}
#[test]
fn should_tell_whether_path_is_watched() {
let mut watcher = FsWatcher::init(Duration::from_secs(5)).unwrap();
let tempdir = TempDir::new().unwrap();
assert!(
watcher
.watch(tempdir.path(), Path::new("/tmp/test"))
.is_ok()
);
assert_eq!(watcher.watched(tempdir.path()), true);
let mut subdir = tempdir.path().to_path_buf();
subdir.push("abc/def");
assert_eq!(watcher.watched(subdir.as_path()), true);
assert_eq!(watcher.watched(Path::new("/tmp")), false);
// close tempdir
assert!(tempdir.close().is_ok());
}
#[test]
#[cfg(target_os = "macos")]
fn should_poll_file_update() {
let mut watcher = FsWatcher::init(Duration::from_millis(100)).unwrap();
let tempdir = TempDir::new().unwrap();
let tempdir_path = PathBuf::from(format!("/private{}", tempdir.path().display()));
assert!(
watcher
.watch(tempdir_path.as_path(), Path::new("/tmp/test"))
.is_ok()
);
// create file
let file_path = test_helpers::make_file_at(tempdir_path.as_path(), "test.txt").unwrap();
// wait
std::thread::sleep(Duration::from_millis(500));
// wait till update
loop {
let fs_change = watcher.poll().unwrap();
if let Some(FsChange::Update(_)) = fs_change {
break;
}
std::thread::sleep(Duration::from_millis(500));
}
assert!(std::fs::remove_file(file_path.as_path()).is_ok());
// close tempdir
assert!(tempdir.close().is_ok());
}
#[test]
#[cfg(target_os = "macos")]
fn should_poll_file_removed() {
let mut watcher = FsWatcher::init(Duration::from_millis(100)).unwrap();
let tempdir = TempDir::new().unwrap();
let tempdir_path = PathBuf::from(format!("/private{}", tempdir.path().display()));
assert!(
watcher
.watch(tempdir_path.as_path(), Path::new("/tmp/test"))
.is_ok()
);
// create file
let file_path = test_helpers::make_file_at(tempdir_path.as_path(), "test.txt").unwrap();
std::thread::sleep(Duration::from_millis(500));
// wait
assert!(std::fs::remove_file(file_path.as_path()).is_ok());
// poll till remove
loop {
let fs_change = watcher.poll().unwrap();
if let Some(FsChange::Remove(remove)) = fs_change {
assert_eq!(remove.path(), Path::new("/tmp/test/test.txt"));
break;
}
std::thread::sleep(Duration::from_millis(500));
}
// close tempdir
assert!(tempdir.close().is_ok());
}
/*
#[test]
#[cfg(posix)]
fn should_poll_file_moved() {
let mut watcher = FsWatcher::init(Duration::from_millis(100)).unwrap();
let tempdir = TempDir::new().unwrap();
let tempdir_path = PathBuf::from(format!("/private{}", tempdir.path().display()));
assert!(watcher
.watch(tempdir_path.as_path(), Path::new("/tmp/test"))
.is_ok());
// create file
let file_path = test_helpers::make_file_at(tempdir_path.as_path(), "test.txt").unwrap();
// wait
std::thread::sleep(Duration::from_millis(500));
// move file
let mut new_file_path = tempdir.path().to_path_buf();
new_file_path.push("new.txt");
assert!(std::fs::rename(file_path.as_path(), new_file_path.as_path()).is_ok());
std::thread::sleep(Duration::from_millis(500));
// wait till rename
loop {
let fs_change = watcher.poll().unwrap();
if let Some(FsChange::Move(mov)) = fs_change {
assert_eq!(mov.source(), Path::new("/tmp/test/test.txt"));
assert_eq!(mov.destination(), Path::new("/tmp/test/new.txt"));
break;
}
std::thread::sleep(Duration::from_millis(500));
}
// remove file
assert!(std::fs::remove_file(new_file_path.as_path()).is_ok());
// close tempdir
assert!(tempdir.close().is_ok());
}
*/
#[test]
#[cfg(target_os = "macos")]
fn should_poll_nothing() {
let mut watcher = FsWatcher::init(Duration::from_secs(5)).unwrap();
let tempdir = TempDir::new().unwrap();
assert!(
watcher
.watch(tempdir.path(), Path::new("/tmp/test"))
.is_ok()
);
assert!(watcher.poll().ok().unwrap().is_none());
// close tempdir
assert!(tempdir.close().is_ok());
}
#[test]
#[cfg(target_os = "macos")]
fn should_get_watched_paths() {
let mut watcher = FsWatcher::init(Duration::from_secs(5)).unwrap();
assert!(watcher.watch(Path::new("/tmp"), Path::new("/tmp")).is_ok());
assert!(
watcher
.watch(Path::new("/home"), Path::new("/home"))
.is_ok()
);
let mut watched_paths = watcher.watched_paths();
watched_paths.sort();
assert_eq!(watched_paths, vec![Path::new("/home"), Path::new("/tmp")]);
}
}