yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! The `ui.json` document (DESIGN §4.1, §15 Y8): yog's one converging UI-state
//! artifact — the four attention `seen` watermarks, `pinned`, `collapsed`,
//! `show_internal`, `identity_last_used`.
//!
//! **Single source of truth:** the whole document is one [`serde_json::Value`]
//! object (`root`); every known field is a *query* over that map and every
//! unknown key round-trips for free — no parallel typed struct plus extra-map
//! to drift (the "one struct, flattened extra" discipline without a `serde`
//! derive dependency: `serde_json` only).
//!
//! Convergence (§4.1, I5) is last-writer-wins whole-file: forgiving load
//! (missing/corrupt ⇒ default doc, never an error), debounced clock-injected
//! atomic writes (temp dotfile + `rename`, I3), echo suppression by content
//! hash ([`UiState::is_echo`]), and wholesale [`UiState::adopt`] otherwise. The
//! [`Clock`] seam is the minimal time injection Y6 reuses for its sweep.

use serde_json::{Map, Value};

mod json;
use json::{default_root, descend, parse_or_default, string_array};
use std::collections::BTreeSet;
use std::fs;
use std::hash::{Hash, Hasher};
use std::io;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};

/// Debounce window: at most one write, 250 ms after the last change (§4.1).
const DEBOUNCE: Duration = Duration::from_millis(250);

/// Injected monotonic time (§7.2: "all timing is clock-injected"); Y6's sweep
/// scheduler reuses or moves this trait. Only differences between calls matter.
pub trait Clock {
    fn now(&self) -> Instant;
}

#[derive(Clone, Copy)]
pub struct SystemClock;

impl Clock for SystemClock {
    fn now(&self) -> Instant {
        Instant::now()
    }
}

/// The four seen-gated attention kinds (§6); each names one watermark slot in
/// a `seen[ws][agent]` object (unknown kinds round-trip as plain map keys).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SeenKind {
    Notify,
    Stopped,
    Budget,
    Conflicted,
}

impl SeenKind {
    fn key(self) -> &'static str {
        match self {
            SeenKind::Notify => "notify",
            SeenKind::Stopped => "stopped",
            SeenKind::Budget => "budget",
            SeenKind::Conflicted => "conflicted",
        }
    }
}

/// Stable content hash of file bytes — the echo-suppression identity (§4.1).
pub fn content_hash(bytes: &[u8]) -> u64 {
    let mut h = std::collections::hash_map::DefaultHasher::new();
    bytes.hash(&mut h);
    h.finish()
}

/// Startup focus (§4.1, §6): first attention-bearing workspace in the caller's
/// derived roster order, else the first, else none. Pure over ids.
pub fn derive_startup_focus(roster: &[&str], attention: &[&str]) -> Option<String> {
    for &w in roster {
        if attention.contains(&w) {
            return Some(w.to_string());
        }
    }
    roster.first().map(|w| (*w).to_string())
}

/// The live `ui.json` handle: document, path, clock, debounce deadline, and
/// last-known on-disk hash (echo suppression). The clock is a trait object
/// (`Arc<dyn Clock>`, cold-path virtual dispatch) so the type carries no
/// `Clock` generic across the public boundary; the `Arc` lets the schedule and
/// this debounce share one injected time source (§7.2).
pub struct UiState {
    path: PathBuf,
    root: Map<String, Value>,
    clock: Arc<dyn Clock>,
    dirty_since: Option<Instant>,
    last_hash: Option<u64>,
}

impl UiState {
    /// Open the document at `path` with the real clock (forgiving load).
    pub fn open(path: PathBuf) -> Self {
        Self::with_clock(path, Arc::new(SystemClock))
    }
}

impl UiState {
    /// Forgiving load with an injected clock (missing/unreadable/corrupt ⇒ default).
    pub fn with_clock(path: PathBuf, clock: Arc<dyn Clock>) -> Self {
        let (root, last_hash) = match fs::read(&path) {
            Ok(bytes) => (parse_or_default(&bytes), Some(content_hash(&bytes))),
            Err(_) => (default_root(), None),
        };
        Self {
            path,
            root,
            clock,
            dirty_since: None,
            last_hash,
        }
    }

    /// True iff `bytes` hash to the content we last wrote/read/adopted (§4.1).
    pub fn is_echo(&self, bytes: &[u8]) -> bool {
        self.last_hash == Some(content_hash(bytes))
    }

    /// Wholesale-adopt an external change (LWW whole-file, I5).
    pub fn adopt(&mut self, bytes: &[u8]) {
        self.root = parse_or_default(bytes);
        self.last_hash = Some(content_hash(bytes));
        self.dirty_since = None;
    }

    /// Record `oid` as the `(kind, ws, agent)` seen watermark on focus (§6).
    pub fn record_seen(&mut self, kind: SeenKind, ws: &str, agent: &str, oid: &str) {
        let by_ws = descend(&mut self.root, "seen".to_string());
        let by_agent = descend(by_ws, ws.to_string());
        let marks = descend(by_agent, agent.to_string());
        marks.insert(kind.key().to_string(), Value::String(oid.to_string()));
        self.touch();
    }

    /// True iff the `(kind, ws, agent)` watermark equals `oid` (else unseen).
    pub fn is_seen(&self, kind: SeenKind, ws: &str, agent: &str, oid: &str) -> bool {
        self.root
            .get("seen")
            .and_then(Value::as_object)
            .and_then(|m| m.get(ws))
            .and_then(Value::as_object)
            .and_then(|m| m.get(agent))
            .and_then(Value::as_object)
            .and_then(|m| m.get(kind.key()))
            .and_then(Value::as_str)
            == Some(oid)
    }

    /// The ordered pin list (user order preserved; non-strings ignored).
    pub fn pinned(&self) -> Vec<String> {
        string_array(&self.root, "pinned")
    }

    /// Replace the pin list.
    pub fn set_pinned(&mut self, list: Vec<String>) {
        let arr = list.into_iter().map(Value::String).collect();
        self.root.insert("pinned".to_string(), Value::Array(arr));
        self.touch();
    }

    /// Whether `key` (`proj:…` / `ws:…`) carries an explicit collapse override.
    pub fn is_collapsed(&self, key: &str) -> bool {
        string_array(&self.root, "collapsed").contains(&key.to_string())
    }

    /// Add/remove a collapse override, kept sorted for byte-determinism.
    pub fn set_collapsed(&mut self, key: &str, collapsed: bool) {
        let mut set: BTreeSet<String> = BTreeSet::new();
        set.extend(string_array(&self.root, "collapsed"));
        if collapsed {
            set.insert(key.to_string());
        } else {
            set.remove(key);
        }
        let arr = set.into_iter().map(Value::String).collect();
        self.root.insert("collapsed".to_string(), Value::Array(arr));
        self.touch();
    }

    /// The identity prefilling `--as` in the claim dialog, if recorded.
    pub fn identity_last_used(&self) -> Option<String> {
        self.root
            .get("identity_last_used")
            .and_then(Value::as_str)
            .map(String::from)
    }

    pub fn set_identity(&mut self, identity: &str) {
        self.root.insert(
            "identity_last_used".to_string(),
            Value::String(identity.to_string()),
        );
        self.touch();
    }

    /// Whether nested-delivery ("internal") clones are shown (§4.1, §5.1 #1):
    /// a global view-filter boolean, default `false` (absent ⇒ hidden).
    pub fn show_internal(&self) -> bool {
        self.root
            .get("show_internal")
            .and_then(Value::as_bool)
            .unwrap_or(false)
    }

    pub fn set_show_internal(&mut self, show: bool) {
        self.root
            .insert("show_internal".to_string(), Value::Bool(show));
        self.touch();
    }

    /// Force a write iff a change is pending (on dispatched action / close).
    pub fn flush(&mut self) -> io::Result<bool> {
        if self.dirty_since.is_some() {
            self.write()?;
            Ok(true)
        } else {
            Ok(false)
        }
    }

    /// Write iff a pending change is older than the debounce window (§7.2 tick).
    pub fn flush_if_due(&mut self) -> io::Result<bool> {
        match self.dirty_since {
            Some(since) if self.clock.now().saturating_duration_since(since) >= DEBOUNCE => {
                self.write()?;
                Ok(true)
            }
            _ => Ok(false),
        }
    }

    /// Reset the debounce deadline to now: a change lands 250 ms hence.
    fn touch(&mut self) {
        self.dirty_since = Some(self.clock.now());
    }

    /// Serialize, write atomically, refresh the echo hash, clear the deadline.
    fn write(&mut self) -> io::Result<()> {
        let bytes = self.serialize();
        self.write_atomic(&bytes)?;
        self.last_hash = Some(content_hash(&bytes));
        self.dirty_since = None;
        Ok(())
    }

    /// Byte-deterministic serialization (`Map` sorts keys; arrays are canonical).
    fn serialize(&self) -> Vec<u8> {
        // A JSON object of strings/maps always serializes; empty on the impossible error.
        serde_json::to_vec_pretty(&self.root).unwrap_or_default()
    }

    /// Temp dotfile in the destination dir + `rename` (I3); creates the dir.
    fn write_atomic(&self, bytes: &[u8]) -> io::Result<()> {
        let dir = self.path.parent().ok_or(io::Error::other("no parent"))?;
        fs::create_dir_all(dir)?;
        let name = self
            .path
            .file_name()
            .ok_or(io::Error::other("no file name"))?;
        let tmp_name = format!(".{}.yog-tmp-{}", name.to_string_lossy(), std::process::id());
        let tmp = dir.join(tmp_name);
        fs::write(&tmp, bytes)?;
        fs::rename(&tmp, &self.path)
    }
}

#[cfg(test)]
mod tests;