yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! Watch registry + repaint bridge (DESIGN §7.2, §15 Y6).
//!
//! Three pieces wire the built-but-unwired [`fs_watcher`](crate::fs_watcher)
//! to a live-re-rendering UI:
//!
//! - [`WatchSet`] owns one [`Watcher`] per `(root, RootKind)` and
//!   [`reconcile`](WatchSet::reconcile)s a *desired* root list against the live
//!   one — dropping watchers no longer wanted, creating missing ones, and
//!   leaving surviving watchers (and their armed inotify state) untouched. A
//!   construction failure is normal (a missing root is absent, not an error):
//!   it is skipped, never poisoning the set, and retried on the next reconcile
//!   (the live set is the single source of truth — "absent" is `desired`
//!   minus `live`, computed, never stored).
//! - [`DirtySet`] is the one cross-thread hand-off: a mutex-guarded set of
//!   dirty root paths the bridge fills and the frame drains.
//! - [`Bridge`] is the background thread. DESIGN §7.2 describes it "blocking on
//!   the aggregated notify channels"; the existing [`Watcher`] is **pull-based**
//!   ([`Watcher::tick`] drains coalesced changes). Rather than grow the watcher
//!   a channel-exposing surface, the smallest faithful mechanism is a thread
//!   that *polls* every live watcher's `tick()` on a short interval and parks
//!   between polls ([`BRIDGE_POLL`]) — the pull API's equivalent of blocking on
//!   the channels. On any change it marks the root dirty and requests a repaint
//!   ([`Repaint`], injected on the LockProbe template so the bridge is testable
//!   without a window). Correctness never rides on this thread: the frame's 2 s
//!   poll floor (§7.2) re-derives regardless, so the bridge only buys latency
//!   ("watches are latency, polls are correctness", I4).

use std::collections::{BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread::JoinHandle;
use std::time::Duration;

use crate::fs_watcher::{RootKind, Watcher};
use crate::state::{DirtySet, WatchSetHandle, lock_watchset};

/// Bridge poll cadence: how often the background thread drains the watchers.
/// Short enough that a disk change surfaces sub-frame; the frame's own 2 s
/// poll floor (§7.2) is the correctness backstop, so this is a latency knob
/// only, deliberately not clock-injected (it is a real thread sleep, not a
/// time-gated decision under test).
const BRIDGE_POLL: Duration = Duration::from_millis(50);

/// One [`Watcher`] per `(root, RootKind)` (DESIGN §7.1). The live map is the
/// only state; a desired root that fails to arm is simply not present and is
/// retried on the next [`reconcile`](Self::reconcile).
#[derive(Default)]
pub struct WatchSet {
    live: HashMap<(PathBuf, RootKind), Watcher>,
}

impl WatchSet {
    pub fn new() -> Self {
        Self::default()
    }

    /// Diff `desired` against the live watchers: drop every watcher no longer
    /// desired, then create each desired watcher not already live. A surviving
    /// watcher is left in place, keeping its armed state. Construction failure
    /// (a missing root — normal) is skipped, never poisoning the set; the key
    /// stays absent and is retried on the next reconcile.
    pub fn reconcile(&mut self, desired: &[(PathBuf, RootKind)]) {
        self.live.retain(|key, _| desired.contains(key));
        for key in desired {
            if !self.live.contains_key(key)
                && let Ok(watcher) = Watcher::with_kind(&key.0, key.1)
            {
                self.live.insert(key.clone(), watcher);
            }
        }
    }

    /// Drain every live watcher; return the roots that saw at least one
    /// allowlisted change since the last drain (root granularity — the frame
    /// re-derives a whole root, §7.2).
    pub fn drain_dirty(&self) -> BTreeSet<PathBuf> {
        let mut dirty = BTreeSet::new();
        for ((root, _kind), watcher) in &self.live {
            if !watcher.tick().is_empty() {
                dirty.insert(root.clone());
            }
        }
        dirty
    }

    /// Number of live watchers.
    pub fn len(&self) -> usize {
        self.live.len()
    }

    pub fn is_empty(&self) -> bool {
        self.live.is_empty()
    }

    /// Whether a live watcher guards `(root, kind)`.
    pub fn watches(&self, root: &Path, kind: RootKind) -> bool {
        self.live.contains_key(&(root.to_path_buf(), kind))
    }
}

/// Effect: request an egui repaint. Injected on the LockProbe template
/// (DESIGN §12) so the [`Bridge`] is exercised headlessly with a counting
/// double; [`EguiRepaint`] is the production impl.
pub trait Repaint: Send {
    fn request(&self);
}

/// Production [`Repaint`]: wakes the egui event loop.
pub struct EguiRepaint(pub egui::Context);

impl Repaint for EguiRepaint {
    fn request(&self) {
        self.0.request_repaint();
    }
}

/// The background bridge thread (DESIGN §7.2). Owns its join handle and a stop
/// flag; [`Drop`] signals stop, unparks, and joins for a clean shutdown.
pub struct Bridge {
    stop: Arc<AtomicBool>,
    handle: Option<JoinHandle<()>>,
}

impl Bridge {
    /// Spawn the poll-and-repaint loop over the shared `watchset` and `dirty`
    /// hand-off. See the module doc for why this polls rather than blocks.
    pub fn spawn(
        watchset: WatchSetHandle,
        dirty: DirtySet,
        repaint: impl Repaint + 'static,
    ) -> Self {
        let stop = Arc::new(AtomicBool::new(false));
        let flag = Arc::clone(&stop);
        let handle = std::thread::spawn(move || {
            while !flag.load(Ordering::Relaxed) {
                if pump(&watchset, &dirty) {
                    repaint.request();
                }
                std::thread::park_timeout(BRIDGE_POLL);
            }
        });
        Self {
            stop,
            handle: Some(handle),
        }
    }
}

impl Drop for Bridge {
    fn drop(&mut self) {
        self.stop.store(true, Ordering::Relaxed);
        if let Some(handle) = self.handle.take() {
            handle.thread().unpark();
            let _ = handle.join();
        }
    }
}

/// One bridge iteration: drain the watchset into the dirty hand-off. Returns
/// whether anything became dirty (→ a repaint request). Pure over the shared
/// handles, so both arms are unit-tested without the thread.
fn pump(watchset: &WatchSetHandle, dirty: &DirtySet) -> bool {
    let roots = lock_watchset(watchset).drain_dirty();
    if roots.is_empty() {
        return false;
    }
    dirty.mark_all(roots);
    true
}

#[cfg(test)]
mod tests;