aphid 0.1.4

A static site generator for blogs and wikis, with wiki-links across both.
Documentation
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;

use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher, event::ModifyKind};
use tokio::sync::mpsc;

use super::AppState;
use crate::Error;
use crate::config::Config;

const DEBOUNCE: Duration = Duration::from_millis(200);

pub(crate) struct ContentWatcher {
    /// Held to keep the watcher alive; dropping it stops file events.
    _watcher: RecommendedWatcher,
    rx: mpsc::Receiver<()>,
}

impl ContentWatcher {
    pub fn new(config: &Config) -> Result<Self, Error> {
        let (tx, rx) = mpsc::channel::<()>(1);

        let mut watcher = RecommendedWatcher::new(
            move |result: Result<notify::Event, notify::Error>| match result {
                Ok(event) if is_content_change(&event.kind) => {
                    let _ = tx.try_send(());
                }
                Ok(_) => {}
                Err(e) => tracing::error!("file watcher error: {e}"),
            },
            notify::Config::default(),
        )?;

        let dirs = [
            Some(&config.source_dir),
            Some(&config.static_dir),
            config.theme_dir.as_ref(),
        ];
        for dir in dirs.into_iter().flatten() {
            if dir.is_dir() {
                tracing::debug!(path = %dir.display(), "watching directory");
                watcher.watch(dir, RecursiveMode::Recursive)?;
            }
        }

        tracing::info!("file watcher started");
        Ok(Self {
            _watcher: watcher,
            rx,
        })
    }

    pub async fn run(&mut self, config_path: &Path, state: &Arc<AppState>) -> Result<(), Error> {
        // Pinned across iterations: once registered as a waiter, the future
        // stays in `Notify`'s waker list and reliably observes a later
        // `notify_waiters()` even if it fired while we were rebuilding.
        let shutdown = state.shutdown.notified();
        tokio::pin!(shutdown);

        loop {
            tokio::select! {
                biased;
                _ = &mut shutdown => {
                    tracing::debug!("watcher received shutdown signal");
                    break;
                }
                result = self.rx.recv() => {
                    if result.is_none() {
                        break;
                    }
                    self.debounce().await;

                    tracing::info!("file change detected, rebuilding…");
                    if let Err(e) = state.rebuild(config_path).await {
                        tracing::error!("rebuild failed: {e}");
                    }
                }
            }
        }

        Ok(())
    }

    async fn debounce(&mut self) {
        let mut deadline = tokio::time::Instant::now() + DEBOUNCE;
        loop {
            tokio::select! {
                _ = tokio::time::sleep_until(deadline) => break,
                result = self.rx.recv() => {
                    if result.is_none() { break; }
                    deadline = tokio::time::Instant::now() + DEBOUNCE;
                }
            }
        }
    }
}

fn is_content_change(kind: &EventKind) -> bool {
    matches!(
        kind,
        EventKind::Create(_)
            | EventKind::Remove(_)
            | EventKind::Modify(ModifyKind::Data(_) | ModifyKind::Name(_) | ModifyKind::Any)
    )
}