yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! Filesystem watcher for a watched root.
//!
//! Exposes the subset of paths admitted by the root's [`RootKind`] allowlist
//! (DESIGN §7.1) as a drainable stream of coalesced change notifications. The
//! module is pure Rust — no egui/eframe dependency — so a future
//! `lernie-ui-web` crate can reuse it unchanged. The watcher is strictly
//! read-only: it never mutates the repo.
//!
//! Backing impl: `notify::RecommendedWatcher` (inotify on Linux, kqueue on
//! BSD/macOS, polling fallback elsewhere). Coalescing collapses multiple
//! events for the same path within one tick window — rapid sequential
//! writes and atomic-rename sequences both emerge as a single change per
//! destination path.

mod roots;

pub use roots::RootKind;

use std::collections::{HashMap, hash_map::Entry};
use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, Receiver, TryRecvError};

use notify::{
    Event, RecommendedWatcher, RecursiveMode, Watcher as NotifyWatcher,
    event::{EventKind, ModifyKind, RenameMode},
};

use roots::is_watched;

#[derive(Debug, thiserror::Error)]
#[error("filesystem watcher: {0}")]
pub struct WatchError(#[from] notify::Error);

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChangeKind {
    Touched,
    Removed,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Change {
    pub path: PathBuf,
    pub kind: ChangeKind,
}

pub struct Watcher {
    repo_root: PathBuf,
    kind: RootKind,
    _inner: RecommendedWatcher,
    rx: Receiver<notify::Result<Event>>,
}

impl Watcher {
    /// Watch a lernie workspace root — the original behavior, unchanged
    /// ([`RootKind::Workspace`]).
    pub fn new(repo_root: &Path) -> Result<Self, WatchError> {
        Self::with_kind(repo_root, RootKind::Workspace)
    }

    /// Watch `repo_root`, admitting only the paths in `kind`'s allowlist
    /// (DESIGN §7.1).
    pub fn with_kind(repo_root: &Path, kind: RootKind) -> Result<Self, WatchError> {
        // Canonicalize the root so the prefix we strip from emitted event
        // paths (`is_watched`) and the `Change.path` values match the
        // backend's own path spelling. The macOS FSEvents backend reports
        // fully-resolved paths (`/private/…`); a raw `/tmp/…` root would
        // never prefix-match them. This also validates existence, so a
        // missing root surfaces as `WatchError` (via `io::Error`).
        let repo_root = repo_root.canonicalize().map_err(notify::Error::from)?;
        let (tx, rx) = mpsc::channel();
        let mut inner: RecommendedWatcher = notify::recommended_watcher(move |res| {
            let _ = tx.send(res);
        })?;
        inner.watch(&repo_root, RecursiveMode::Recursive)?;
        Ok(Self {
            repo_root,
            kind,
            _inner: inner,
            rx,
        })
    }

    /// Drain pending notify events and return one coalesced `Change` per
    /// affected watched path. Paths outside this root's [`RootKind`] allowlist
    /// (DESIGN §7.1) are dropped; rename-source events are dropped in favor of
    /// the destination.
    pub fn tick(&self) -> Vec<Change> {
        let mut raw: Vec<(PathBuf, EventKind)> = Vec::new();
        loop {
            match self.rx.try_recv() {
                Ok(Ok(event)) => ingest(event, &mut raw),
                Ok(Err(_)) => {}
                Err(TryRecvError::Empty | TryRecvError::Disconnected) => break,
            }
        }
        coalesce(&self.repo_root, self.kind, raw)
    }
}

fn ingest(event: Event, raw: &mut Vec<(PathBuf, EventKind)>) {
    let paths = event.paths;
    let kind = event.kind;
    // A `Both` rename carries exactly the (from, to) pair — a slice pattern
    // binds both without indexing; any other shape falls through unchanged.
    if let EventKind::Modify(ModifyKind::Name(RenameMode::Both)) = kind
        && let [from, to] = paths.as_slice()
    {
        raw.push((
            from.clone(),
            EventKind::Modify(ModifyKind::Name(RenameMode::From)),
        ));
        raw.push((
            to.clone(),
            EventKind::Modify(ModifyKind::Name(RenameMode::To)),
        ));
        return;
    }
    for path in paths {
        raw.push((path, kind));
    }
}

fn coalesce(repo_root: &Path, root_kind: RootKind, raw: Vec<(PathBuf, EventKind)>) -> Vec<Change> {
    let mut renamed: HashMap<PathBuf, bool> = HashMap::new();
    let mut order: Vec<PathBuf> = Vec::new();
    for (path, kind) in raw {
        if !is_watched(root_kind, repo_root, &path) {
            continue;
        }
        let is_rename = matches!(kind, EventKind::Modify(ModifyKind::Name(_)));
        match renamed.entry(path.clone()) {
            Entry::Occupied(mut e) => *e.get_mut() |= is_rename,
            Entry::Vacant(e) => {
                order.push(path);
                e.insert(is_rename);
            }
        }
    }
    order
        .into_iter()
        .filter_map(|p| {
            let change_kind = classify(renamed.get(&p).copied().unwrap_or(false), &p)?;
            Some(Change {
                path: p,
                kind: change_kind,
            })
        })
        .collect()
}

/// Classify a watched path into a surfaced [`ChangeKind`], or `None` to drop
/// it, from two established facts: whether the path exists now, and whether any
/// rename (`Modify(Name(_))`) event landed on it this tick (`renamed`,
/// OR-folded across the path's whole event burst in `coalesce`).
///
/// Existence is ground truth for the current state, so a path present now is
/// `Touched` whatever its history. A path that is gone is disambiguated by
/// *how* it left: an atomic-write rename source carried a `Name` event and its
/// destination survives, so it is dropped (`None`); a genuine deletion carried
/// no rename, so it surfaces as `Removed`. This depends only on the invariants
/// that a rename source always carries a `Name` event and a delete never does —
/// not on whether macOS FSEvents emits a trailing `Remove` for a deletion. It
/// does not: the coalesced `CREATED|REMOVED` burst's last event for the
/// vanished path is a non-`Remove` `Modify(Data)`, so keying on the *presence*
/// or *absence* of a `Remove` event misreads both a real deletion and a rename
/// source. The OR-fold is essential — that trailing `Modify(Data)` must not
/// clear the `Name` seen earlier in the same tick.
fn classify(renamed: bool, path: &Path) -> Option<ChangeKind> {
    if path.exists() {
        Some(ChangeKind::Touched)
    } else if renamed {
        None
    } else {
        Some(ChangeKind::Removed)
    }
}

#[cfg(test)]
mod tests;