yog 0.0.66

yog: the standalone server for litany loops — the world, the balls and the conversations, behind one wire
Documentation
//! The §3.6 `ui.json` prune: a deleted workspace's keys leave the document
//! (step 2 of the unmake plan).
//!
//! Not hygiene. The claimant join is string equality (§3.2), so a workspace
//! raised again under
//! a dead name **re-adopts** its history — deliberately. What must not come back
//! with it is the dead sphere's acknowledgement watermarks and its pin, which
//! are yog's own assertions about a workspace that no longer exists. One
//! write-through save (§4.1) drops all three keys at once.

use super::UiState;
use serde_json::Value;

impl UiState {
    /// Drop every key the workspace `key` (its path — [`ws_key`](crate::nav::ws_key))
    /// owns: its `seen` map and its `pinned` entry (§4.1). Absent keys are left absent — the prune adds nothing to
    /// the document, so pruning a workspace that owned none writes nothing at all
    /// (the §4.1 content-hash elision).
    /// **One document since bl-f936** (REMOTE §7 as amended): `seen` and
    /// `pinned` are world facts, and the `ws:<path>` collapse override the
    /// prune used to drop beside them is a seat's own glass fact that this
    /// engine never stored. A seat's stale override is inert where it lives —
    /// a section that no longer renders costs nothing.
    pub fn prune_workspace(&mut self, key: &str) {
        if let Some(Value::Object(seen)) = self.world.root.get_mut("seen") {
            seen.remove(key);
        }
        drop_from(&mut self.world.root, "pinned", key);
        self.world.save();
    }

    /// Drop a deleted conversation's acknowledgement watermarks: `seen[key][id]`
    /// for `root` and every `<root>-*` hyphen-descendant — the same subtree cut
    /// litany's `delete` removes (ARCH §2.3), so the two sets cannot drift. The
    /// hyphen boundary is load-bearing: `a-bb` is not pruned by `a-b`'s delete
    /// (a shared byte prefix is nothing, §2.3's whole-token rule).
    pub fn prune_agent(&mut self, key: &str, root: &str) {
        if let Some(Value::Object(seen)) = self.world.root.get_mut("seen")
            && let Some(Value::Object(by_agent)) = seen.get_mut(key)
        {
            let prefix = format!("{root}-");
            by_agent.retain(|id, _| id != root && !id.starts_with(&prefix));
        }
        self.world.save();
    }
}

/// Remove `value` from the string array at `field` of `root`, leaving an absent
/// or non-array field untouched (the forgiving read, §4.1). A free function
/// taking the map rather than a method taking the handle: the borrow is of one
/// map, which is what the mutation needs.
fn drop_from(root: &mut serde_json::Map<String, Value>, field: &str, value: &str) {
    let Some(Value::Array(arr)) = root.get_mut(field) else {
        return;
    };
    arr.retain(|v| v.as_str() != Some(value));
}

#[cfg(test)]
mod tests {
    use crate::ui_state::{SeenKind, UiState};
    use tempfile::tempdir;

    const WS: &str = "/y/workspaces/alba-koi";
    const OTHER: &str = "/y/workspaces/zeta-pug";

    fn seeded(path: std::path::PathBuf) -> UiState {
        let mut ui = UiState::open(path);
        ui.record_seen(WS, "c-1", &[(SeenKind::Notify, "oid1".to_owned())]);
        ui.record_seen(OTHER, "c-9", &[(SeenKind::Notify, "oid9".to_owned())]);
        ui.set_pinned(vec![WS.to_owned(), OTHER.to_owned()]);
        ui
    }

    #[test]
    fn the_prune_drops_seen_and_pin_and_spares_every_other_workspace() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("ui.json");
        let mut ui = seeded(path.clone());
        ui.prune_workspace(WS);

        assert!(!ui.is_seen(SeenKind::Notify, WS, "c-1", "oid1"));
        assert_eq!(ui.pinned(), [OTHER.to_owned()]);
        // The neighbour's own keys are untouched — deletion is per-workspace.
        assert!(ui.is_seen(SeenKind::Notify, OTHER, "c-9", "oid9"));

        // Write-through (§4.1): the prune is on disk before it returned.
        let reread = UiState::open(path);
        assert_eq!(reread.pinned(), [OTHER.to_owned()]);
        assert!(!reread.is_seen(SeenKind::Notify, WS, "c-1", "oid1"));
    }

    #[test]
    fn the_agent_prune_takes_the_subtree_on_the_hyphen_boundary_and_nothing_else() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("ui.json");
        let mut ui = UiState::open(path.clone());
        for id in ["r-aa", "r-aa-c-bb", "r-aab", "z-zz"] {
            ui.record_seen(WS, id, &[(SeenKind::Notify, format!("oid-{id}"))]);
        }
        ui.record_seen(OTHER, "r-aa", &[(SeenKind::Notify, "elsewhere".to_owned())]);
        ui.prune_agent(WS, "r-aa");

        assert!(!ui.is_seen(SeenKind::Notify, WS, "r-aa", "oid-r-aa"));
        assert!(!ui.is_seen(SeenKind::Notify, WS, "r-aa-c-bb", "oid-r-aa-c-bb"));
        // A shared byte prefix is not descent (the whole-token rule).
        assert!(ui.is_seen(SeenKind::Notify, WS, "r-aab", "oid-r-aab"));
        assert!(ui.is_seen(SeenKind::Notify, WS, "z-zz", "oid-z-zz"));
        // Another workspace's same-named agent is untouched.
        assert!(ui.is_seen(SeenKind::Notify, OTHER, "r-aa", "elsewhere"));
        // Write-through (§4.1): the prune is on disk before it returned.
        let reread = UiState::open(path);
        assert!(!reread.is_seen(SeenKind::Notify, WS, "r-aa", "oid-r-aa"));
    }

    #[test]
    fn pruning_an_agent_nobody_acknowledged_adds_no_keys() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("ui.json");
        let mut ui = UiState::open(path.clone());
        ui.prune_agent(WS, "r-aa");
        let doc = String::from_utf8(std::fs::read(&path).unwrap()).unwrap();
        assert!(
            !doc.contains("seen"),
            "a subtraction, never a schema seeding"
        );
    }

    #[test]
    fn pruning_a_workspace_that_owns_nothing_adds_no_keys() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("ui.json");
        let mut ui = UiState::open(path.clone());
        ui.prune_workspace(WS);
        // No `seen`/`pinned` field existed and none was materialized — the
        // prune is a subtraction, never a schema seeding.
        assert!(ui.pinned().is_empty());
        let doc = String::from_utf8(std::fs::read(&path).unwrap()).unwrap();
        assert!(!doc.contains("pinned") && !doc.contains("seen"));
    }
}