use std::path::{Path, PathBuf};
use std::sync::mpsc;
use anyhow::{Context, Result};
use notify::event::{ModifyKind, RenameMode};
use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
pub struct ManifestWatcher {
_watcher: RecommendedWatcher,
rx: mpsc::Receiver<()>,
#[allow(dead_code)]
target: PathBuf,
}
impl ManifestWatcher {
pub fn start(project_root: &Path) -> Result<Self> {
let target = manifest_path(project_root);
let (tx, rx) = mpsc::channel::<()>();
let mut watcher = notify::recommended_watcher(move |res: notify::Result<Event>| {
if let Ok(event) = res {
if event_is_relevant(&event.kind) {
let _ = tx.send(());
}
}
})
.context("failed to construct notify watcher")?;
let parent = target
.parent()
.ok_or_else(|| anyhow::anyhow!("manifest has no parent: {}", target.display()))?;
if !parent.exists() {
std::fs::create_dir_all(parent).with_context(|| {
format!("failed to create watch parent dir: {}", parent.display())
})?;
}
watcher
.watch(parent, RecursiveMode::NonRecursive)
.with_context(|| format!("failed to watch {}", parent.display()))?;
Ok(Self {
_watcher: watcher,
rx,
target,
})
}
#[allow(dead_code)]
pub fn target(&self) -> &Path {
&self.target
}
pub fn poll_change(&self) -> bool {
let mut seen = false;
while let Ok(()) = self.rx.try_recv() {
seen = true;
}
seen
}
#[cfg(test)]
pub fn wait_change(&self, dur: std::time::Duration) -> bool {
match self.rx.recv_timeout(dur) {
Ok(()) => {
while self.rx.try_recv().is_ok() {}
true
}
Err(_) => false,
}
}
}
fn manifest_path(project_root: &Path) -> PathBuf {
project_root.join(".macot").join("experts_manifest.json")
}
pub fn event_is_relevant(kind: &EventKind) -> bool {
matches!(
kind,
EventKind::Create(_)
| EventKind::Modify(ModifyKind::Name(RenameMode::To))
| EventKind::Modify(ModifyKind::Name(RenameMode::Both))
| EventKind::Modify(ModifyKind::Name(RenameMode::Any))
| EventKind::Modify(ModifyKind::Data(_))
| EventKind::Modify(ModifyKind::Any)
)
}
#[cfg(test)]
mod tests {
use super::*;
use notify::event::{CreateKind, DataChange};
use std::time::Duration;
use tempfile::TempDir;
#[test]
fn event_is_relevant_for_rename_to() {
assert!(event_is_relevant(&EventKind::Modify(ModifyKind::Name(
RenameMode::To
))));
}
#[test]
fn event_is_relevant_for_data_modify() {
assert!(event_is_relevant(&EventKind::Modify(ModifyKind::Data(
DataChange::Content
))));
}
#[test]
fn event_is_relevant_for_create() {
assert!(event_is_relevant(&EventKind::Create(CreateKind::File)));
}
#[test]
fn event_is_irrelevant_for_access() {
assert!(!event_is_relevant(&EventKind::Access(
notify::event::AccessKind::Read
)));
}
#[test]
fn watcher_emits_on_atomic_rename() {
let tmp = TempDir::new().unwrap();
let project_root = tmp.path();
let watcher = ManifestWatcher::start(project_root).expect("watcher start");
let manifest = manifest_path(project_root);
let staging = manifest.with_extension("json.tmp.test");
std::fs::write(&staging, b"[]").expect("write tmp");
std::fs::rename(&staging, &manifest).expect("rename");
let observed = watcher.wait_change(Duration::from_secs(1));
assert!(observed, "watcher should observe the rename within 1s");
}
#[test]
fn watcher_target_path_matches_macot_layout() {
let tmp = TempDir::new().unwrap();
let watcher = ManifestWatcher::start(tmp.path()).unwrap();
assert_eq!(
watcher.target(),
tmp.path().join(".macot").join("experts_manifest.json")
);
}
}