lernie 0.1.111

lernie: the operator seat — the window and wire client for a yog server
//! **Where the seat was pointed**, remembered between runs (yog's
//! `docs/REMOTE.md` §7; DESIGN §4.13).
//!
//! REMOTE §7 rules that per-seat UI state never crosses the boundary and is the
//! seat's own. This is the first of it that is durable, and where it goes is
//! the decision that mattered: under `paths::state_root` and never under
//! `paths::data_root`, because everything under the second is
//! operator-provisioned and irreplaceable by anything on this box, and a
//! regenerable subtree beside it would make a rebuild look like a revocation.
//! The whole of that argument is `crate::paths`'s module doc.
//!
//! # It may never become a way for the seat to fail to start
//!
//! [`read`] has exactly one answer for a file that is absent, unreadable,
//! truncated, or written by a build that spelled things differently: **no
//! place**, which is a window that opens on the roster — the same window a
//! first run gets. There is no refusal here and no repair path, because a
//! forgotten selection is a keypress and a startup error is an outage.
//!
//! That is also why the aim is not checked against anything. A workspace that has
//! gone is a workspace no channel resolves, and the standing set already declines to
//! ask about one (`crate::state::Standing::aimed`) — so a stale place is inert
//! by a rule that was already there, and validating it here would be a second
//! answer to a question already settled.
//!
//! # What it holds, and how it grows
//!
//! The workspace the window was aimed at, the accordion beside it (DESIGN §4.39):
//! which server is open, what each server's last opening ranks as, and the
//! workspace last aimed under each — whether the bar is pinned open (§4.44), and
//! the settings set off their defaults (§4.46 item 4), the two widths the
//! bar's edge was dragged to among them.
//! **A JSON object rather than lines**, so
//! the next fact REMOTE §7 names — a scroll, a draft — is a key beside these
//! rather than a format. An unknown key is ignored and a missing
//! key is absence, which is the reply vocabulary's own rungs 3 and 4 applied to
//! this box's own file: a build that reads a file a newer build wrote loses
//! what it does not know and keeps what it does.
//!
//! **Why the accordion is here and not across the boundary**: §4.39's ruling,
//! and it is not §4.25's pin exception. A channel is a client-side fact, named
//! by this box and held by no other, so the order this box shows its channels
//! in can be nobody else's and there is no server to assert it into.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use serde_json::{Value, json};

use crate::settings::{self, Settings};
use crate::ui::{Aim, Servers};

/// The file, under the state root. One name, so there is nothing to configure.
const FILE: &str = "place.json";
/// The aim, and the two words it is written as.
const AIM: &str = "aim";
const CHANNEL: &str = "channel";
const ADDRESS: &str = "address";
/// The accordion's three, beside the aim (DESIGN §4.39).
const OPEN: &str = "open";
const OPENED: &str = "opened";
const AIMED: &str = "aimed";
/// Whether the bar is pinned open (DESIGN §4.44).
const PINNED: &str = "pinned";
/// **The settings set off their defaults** (DESIGN §4.46 item 4), one
/// object — **written only where set**, so a seat that touched nothing
/// writes nothing and reads the table's defaults (`crate::settings`).
const SETTINGS: &str = "settings";
/// **The own channel's spelling before bl-dab9's sweep** (bl-ba9d). A channel
/// is keyed here by its display name, so the sweep that reworded
/// [`crate::seat::OWN`] orphaned every file a seat wrote before it. Read as the
/// current name for one release and never written; delete it after.
const RETIRED_OWN: &str = "(this box's own engine)";

/// **Everything the seat remembers between runs.** One value, because it is
/// one file: read whole at boot and written whole once the event loop has
/// returned, so nothing here costs a frame anything.
///
/// **`Eq` is not derived** and cannot be: a dragged width is points, and a
/// float has no total equality. Nothing here needs one.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Place {
    /// The workspace the window was aimed at, or nothing — which an operator who
    /// left the roster comes back to.
    pub aim: Option<Aim>,
    /// How the servers were arranged (`crate::ui::Servers`).
    pub servers: Servers,
    /// **Whether the bar is pinned open** (DESIGN §4.44): where the bar
    /// stands is a layout the operator chose, so it outlives the run.
    pub pinned: bool,
    /// **The knobs** (DESIGN §4.46 item 4), the bar's two dragged widths
    /// among them.
    pub settings: Settings,
}

impl Place {
    /// **The place a model IS**, as a projection and never as a stored copy:
    /// the aim and the accordion are the model's own fields, so there is
    /// nothing to keep in step and nothing that can disagree with the glass.
    pub fn of(model: &crate::ui::Model) -> Self {
        Self {
            aim: model.aim.clone(),
            servers: model.servers.clone(),
            pinned: model.bar.pinned,
            settings: model.settings.clone(),
        }
    }
}

/// Where the place is kept under `root`.
pub fn at(root: &Path) -> PathBuf {
    root.join(FILE)
}

/// **Where the seat was pointed and how it was arranged**, or the empty place
/// — which is every way this can fail and the answer a first run gets.
pub fn read(root: &Path) -> Place {
    let held = std::fs::read_to_string(at(root))
        .ok()
        .and_then(|text| serde_json::from_str::<Value>(&text).ok())
        .unwrap_or(Value::Null);
    Place {
        aim: aimed(&held),
        servers: Servers {
            open: held.get(OPEN).and_then(Value::as_str).map(current),
            opened: table(&held, OPENED, Value::as_u64),
            aimed: table(&held, AIMED, |held| held.as_str().map(str::to_owned)),
        },
        pinned: held.get(PINNED).and_then(Value::as_bool).unwrap_or(false),
        // A key that is absent, null or some other shape entirely is absence
        // per knob, which is rung 4 read on this box's own file — and there
        // absence is the table's own default rather than a missing number.
        settings: settings::from_json(held.get(SETTINGS).unwrap_or(&Value::Null)),
    }
}

/// The aim, where the file carries a whole one. A half-written aim is no aim:
/// an address with no channel names nothing this seat can resolve.
fn aimed(held: &Value) -> Option<Aim> {
    let text = |key| held.get(AIM)?.get(key)?.as_str().map(str::to_owned);
    Some(Aim {
        channel: current(&text(CHANNEL)?),
        address: text(ADDRESS)?,
    })
}

/// **A channel name as this build spells it**: the retired own spelling read
/// as [`crate::seat::OWN`], every other name as itself (bl-ba9d).
fn current(name: &str) -> String {
    if name == RETIRED_OWN {
        crate::seat::OWN
    } else {
        name
    }
    .to_owned()
}

/// One name-keyed table, dropping every row this build cannot read — rung 3,
/// per row rather than per key, so one unreadable entry costs one entry.
fn table<T>(held: &Value, key: &str, read: impl Fn(&Value) -> Option<T>) -> BTreeMap<String, T> {
    held.get(key)
        .and_then(Value::as_object)
        .map(|rows| {
            rows.iter()
                .filter_map(|(name, held)| Some((current(name), read(held)?)))
                .collect()
        })
        .unwrap_or_default()
}

/// **Write the place down.** Aimed at nothing is a place too — an operator who
/// left the roster comes back to it — so this is called with whatever the last
/// frame held rather than only when there is something to say.
///
/// It answers a refusal rather than swallowing one. A read that fails has a
/// correct answer and this does not: the only alternative to saying so is
/// losing the operator's place in silence, and by the time this runs there is
/// no window left to paint it in.
pub fn write(root: &Path, place: &Place) -> Result<(), String> {
    let mut body = json!({
        AIM: place
            .aim
            .clone()
            .map(|aim| json!({ CHANNEL: aim.channel, ADDRESS: aim.address })),
        OPEN: place.servers.open,
        OPENED: place.servers.opened,
        AIMED: place.servers.aimed,
        PINNED: place.pinned,
    });
    let knobs = settings::to_json(&place.settings);
    if let Some(keys) = body.as_object_mut().filter(|_| knobs != json!({})) {
        keys.insert(SETTINGS.to_owned(), knobs);
    }
    std::fs::create_dir_all(root).map_err(|e| format!("{}: {e}", root.display()))?;
    std::fs::write(at(root), body.to_string()).map_err(|e| format!("{}: {e}", at(root).display()))
}

#[cfg(test)]
mod tests;