use camino::Utf8Path;
use serde::Serialize;
use std::path::Path;
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum WatchedFile {
List,
Checked,
}
#[derive(Debug, Clone, Serialize)]
pub struct ShoppingListChangeEvent {
pub file: WatchedFile,
}
pub fn classify_path(base_path: &Utf8Path, path: &Path) -> Option<WatchedFile> {
let parent = path.parent()?;
let parent = camino::Utf8Path::from_path(parent)?;
if parent != base_path {
return None;
}
let name = path.file_name()?.to_str()?;
match name {
".shopping-list" => Some(WatchedFile::List),
".shopping-checked" => Some(WatchedFile::Checked),
_ => None,
}
}
use notify::event::{AccessKind, AccessMode};
use notify::EventKind;
use notify_debouncer_full::DebouncedEvent;
pub(super) fn is_read(kind: &EventKind) -> bool {
match kind {
EventKind::Access(AccessKind::Close(AccessMode::Write)) => false,
EventKind::Access(_) => true,
_ => false,
}
}
fn changed_files(base_path: &Utf8Path, events: &[DebouncedEvent]) -> Vec<WatchedFile> {
if events.iter().any(|event| event.need_rescan()) {
return vec![WatchedFile::List, WatchedFile::Checked];
}
let mut list = false;
let mut checked = false;
for event in events.iter().filter(|event| !is_read(&event.kind)) {
for path in &event.paths {
match classify_path(base_path, path) {
Some(WatchedFile::List) => list = true,
Some(WatchedFile::Checked) => checked = true,
None => {}
}
}
}
let mut files = Vec::new();
if list {
files.push(WatchedFile::List);
}
if checked {
files.push(WatchedFile::Checked);
}
files
}
use anyhow::{Context, Result};
use notify::RecursiveMode;
use notify_debouncer_full::{new_debouncer, DebounceEventResult};
use std::time::Duration;
use tokio::sync::broadcast;
pub type ChangeSender = broadcast::Sender<ShoppingListChangeEvent>;
const CHANNEL_CAPACITY: usize = 16;
const BRIDGE_CAPACITY: usize = 32;
const DEBOUNCE: Duration = Duration::from_millis(200);
pub fn spawn(base_path: camino::Utf8PathBuf) -> Result<ChangeSender> {
let (tx, _rx) = broadcast::channel::<ShoppingListChangeEvent>(CHANNEL_CAPACITY);
let tx_for_task = tx.clone();
let (evt_tx, mut evt_rx) = tokio::sync::mpsc::channel::<DebounceEventResult>(BRIDGE_CAPACITY);
let mut debouncer = new_debouncer(DEBOUNCE, None, move |res: DebounceEventResult| {
if let Err(e) = evt_tx.try_send(res) {
tracing::debug!("shopping list watcher: dropping batch ({e})");
}
})
.context("initializing filesystem debouncer")?;
debouncer
.watch(base_path.as_std_path(), RecursiveMode::NonRecursive)
.with_context(|| format!("watching {base_path}"))?;
let base_for_task = base_path.clone();
tokio::spawn(async move {
let _debouncer = debouncer;
while let Some(result) = evt_rx.recv().await {
match result {
Ok(events) => {
for file in changed_files(&base_for_task, &events) {
let _ = tx_for_task.send(ShoppingListChangeEvent { file });
}
}
Err(errors) => {
for err in errors {
tracing::warn!("shopping list watcher error: {err}");
}
}
}
}
});
Ok(tx)
}
#[cfg(test)]
mod tests {
use super::*;
use camino::Utf8PathBuf;
use std::path::PathBuf;
fn base() -> Utf8PathBuf {
Utf8PathBuf::from("/tmp/recipes")
}
#[test]
fn classifies_shopping_list() {
let p = PathBuf::from("/tmp/recipes/.shopping-list");
assert_eq!(classify_path(&base(), &p), Some(WatchedFile::List));
}
#[test]
fn classifies_shopping_checked() {
let p = PathBuf::from("/tmp/recipes/.shopping-checked");
assert_eq!(classify_path(&base(), &p), Some(WatchedFile::Checked));
}
#[test]
fn ignores_temp_file_from_atomic_rename() {
let p = PathBuf::from("/tmp/recipes/..shopping-checked.4321.tmp");
assert_eq!(classify_path(&base(), &p), None);
}
#[test]
fn ignores_recipe_files() {
let p = PathBuf::from("/tmp/recipes/Breakfast/Pancakes.cook");
assert_eq!(classify_path(&base(), &p), None);
}
#[test]
fn ignores_nested_shopping_list() {
let p = PathBuf::from("/tmp/recipes/subdir/.shopping-list");
assert_eq!(classify_path(&base(), &p), None);
}
#[test]
fn ignores_path_outside_base() {
let p = PathBuf::from("/etc/.shopping-list");
assert_eq!(classify_path(&base(), &p), None);
}
use notify::event::{CreateKind, DataChange, Flag, ModifyKind, RemoveKind, RenameMode};
use notify::Event;
use std::time::Instant;
fn event(kind: EventKind, name: &str) -> Event {
Event::new(kind).add_path(PathBuf::from(format!("/tmp/recipes/{name}")))
}
fn changed(events: Vec<Event>) -> Vec<WatchedFile> {
let batch: Vec<DebouncedEvent> = events
.into_iter()
.map(|event| DebouncedEvent::new(event, Instant::now()))
.collect();
changed_files(&base(), &batch)
}
#[test]
fn reading_a_file_is_not_a_change() {
for kind in [
EventKind::Access(AccessKind::Open(AccessMode::Any)),
EventKind::Access(AccessKind::Read),
EventKind::Access(AccessKind::Close(AccessMode::Read)),
] {
assert_eq!(
changed(vec![
event(kind, ".shopping-list"),
event(kind, ".shopping-checked"),
]),
vec![],
"{kind:?}"
);
}
}
#[test]
fn closing_a_file_after_writing_it_is_a_change() {
let kind = EventKind::Access(AccessKind::Close(AccessMode::Write));
assert_eq!(
changed(vec![event(kind, ".shopping-checked")]),
vec![WatchedFile::Checked]
);
}
#[test]
fn writing_creating_and_removing_are_changes() {
for kind in [
EventKind::Modify(ModifyKind::Data(DataChange::Any)),
EventKind::Modify(ModifyKind::Any),
EventKind::Create(CreateKind::File),
EventKind::Remove(RemoveKind::File),
] {
assert_eq!(
changed(vec![event(kind, ".shopping-checked")]),
vec![WatchedFile::Checked],
"{kind:?}"
);
}
}
#[test]
fn a_rename_counts_for_the_file_it_replaces() {
let rename = Event::new(EventKind::Modify(ModifyKind::Name(RenameMode::Both)))
.add_path(PathBuf::from("/tmp/recipes/..shopping-list.4321.tmp"))
.add_path(PathBuf::from("/tmp/recipes/.shopping-list"));
assert_eq!(changed(vec![rename]), vec![WatchedFile::List]);
}
#[test]
fn a_batch_names_each_changed_file_once_list_first() {
let write = EventKind::Modify(ModifyKind::Data(DataChange::Any));
let open = EventKind::Access(AccessKind::Open(AccessMode::Any));
assert_eq!(
changed(vec![
event(write, ".shopping-checked"),
event(open, ".shopping-list"),
event(write, ".shopping-list"),
event(write, ".shopping-list"),
event(write, "Pancakes.cook"),
]),
vec![WatchedFile::List, WatchedFile::Checked]
);
}
#[test]
fn a_batch_of_unrelated_changes_names_nothing() {
let write = EventKind::Modify(ModifyKind::Data(DataChange::Any));
assert_eq!(changed(vec![event(write, "Pancakes.cook")]), vec![]);
}
#[test]
fn a_rescan_counts_for_both_files() {
let rescan = Event::new(EventKind::Any).set_flag(Flag::Rescan);
assert_eq!(
changed(vec![rescan]),
vec![WatchedFile::List, WatchedFile::Checked]
);
}
}