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 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) => {
let mut fired_list = false;
let mut fired_checked = false;
for event in events {
for path in &event.paths {
match classify_path(&base_for_task, path) {
Some(WatchedFile::List) if !fired_list => {
fired_list = true;
}
Some(WatchedFile::Checked) if !fired_checked => {
fired_checked = true;
}
_ => {}
}
}
}
if fired_list {
let _ = tx_for_task.send(ShoppingListChangeEvent {
file: WatchedFile::List,
});
}
if fired_checked {
let _ = tx_for_task.send(ShoppingListChangeEvent {
file: WatchedFile::Checked,
});
}
}
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.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);
}
}