Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-demo/src/control.rs

//! The facett-demo **control channel** — the drive-half of the robot-UI loop,
//! the exact EPIC-R pattern nornir's viz uses (`$NORNIR_VIZ_CMD` /
//! `$NORNIR_VIZ_STATE`), applied to facett-demo.
//!
//! Two file-backed seams give the running demo CLI/robot parity with its GUI:
//!
//! - **read-half** — [`dump_state`] writes the whole demo's aggregated
//!   [`state_json`](crate::DemoState::state_json) to `$FACETT_DEMO_STATE` (default
//!   `/tmp/facett_demo_state.json`) every frame (throttled), so an external driver
//!   *sees what the demo shows* (every control + tab + folded-in component state).
//! - **write-half** — [`take_pending`] reads (and **consumes**) one
//!   [`DemoCommand`] from `$FACETT_DEMO_CMD` (default `/tmp/facett_demo_cmd.json`)
//!   each frame, applies it, and deletes the file (consume-once, so one write fires
//!   exactly once). The result then shows up in the next `$FACETT_DEMO_STATE` dump.
//!
//! Why a file (same reasoning as nornir's `viz/control.rs`): it's observable,
//! greppable, needs no runtime/port, and works identically headless or live. The
//! driver writes a [`DemoCommand`]; the demo consumes it next frame.
//!
//! Contract — the JSON written to `$FACETT_DEMO_CMD`, all fields optional:
//!
//! ```json
//! { "tab": "grid", "palette": "cyberpunk-neon", "look": "macos-dark",
//!   "command": "fx.glow", "select_node": "Ingest" }
//! ```
//!
//! | field         | effect                                                        |
//! |---------------|---------------------------------------------------------------|
//! | `tab`         | switch the active deck tab by its facet title (`grid`, …)      |
//! | `palette`     | activate a `Theme::ALL` palette by name (the 🎨 deck override)  |
//! | `look`        | switch the `LookTheme` preset by name (windows/macos/device)  |
//! | `command`     | fire a `DemoState::run_command` id (`fx.glow`, `raven.send`, …)|
//! | `select_node` | select a `syschart` node by id (label-addressable)            |
//!
//! `DemoCommand` is parsed from / serialized to a plain [`serde_json::Value`]
//! (optional fields only, no derive) so the demo carries no extra dep, while the
//! shape is byte-identical to nornir's `VizCommand` channel.

use serde_json::{Map, Value};

/// One robot-drive command for the live demo. Every field optional, so a driver
/// can switch just the tab, just the palette, fire just a command, …, or several
/// in one write (applied in a fixed order — see [`crate::DemoState::apply_command`]).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct DemoCommand {
    /// Deck tab to make active, by facet title (`"grid"`, `"icebergview"`, …).
    pub tab: Option<String>,
    /// `Theme::ALL` palette to activate by name (`"cyberpunk-neon"`); `-`/`_`/space
    /// interchange (the deck's `set_palette_named` fuzzy match).
    pub palette: Option<String>,
    /// `LookTheme` preset to activate by name (`"windows-dark"`, `"macos-light"`,
    /// `"device"`); same fuzzy match as the palette.
    pub look: Option<String>,
    /// Platform-adaptive **native-feel** override (`"mac"`, `"windows"`,
    /// `"neutral"`, or `"cycle"`) — jumps to that platform's preset so the native
    /// cues are demonstrable on any host OS (fuzzy: `macos`/`win`/`linux` accepted).
    pub platform: Option<String>,
    /// A `DemoState::run_command` id to fire (`"fx.glow"`, `"raven.send"`,
    /// `"about.toggle"`, `"palette.cycle"`).
    pub command: Option<String>,
    /// A `syschart` node id to select (label-addressable, parity with the demo's
    /// `node:<id>` toolbar control).
    pub select_node: Option<String>,
}

impl DemoCommand {
    /// Parse a [`DemoCommand`] out of a JSON object, reading each optional string
    /// field by key (mirrors `#[serde(default)]` optional fields, no derive).
    pub fn from_value(v: &Value) -> Self {
        let s = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_string);
        Self {
            tab: s("tab"),
            palette: s("palette"),
            look: s("look"),
            platform: s("platform"),
            command: s("command"),
            select_node: s("select_node"),
        }
    }

    /// Serialize to a JSON object, emitting only the set fields (the wire form a
    /// driver writes to `$FACETT_DEMO_CMD`).
    pub fn to_value(&self) -> Value {
        let mut m = Map::new();
        let mut put = |k: &str, val: &Option<String>| {
            if let Some(s) = val {
                m.insert(k.to_string(), Value::String(s.clone()));
            }
        };
        put("tab", &self.tab);
        put("palette", &self.palette);
        put("look", &self.look);
        put("platform", &self.platform);
        put("command", &self.command);
        put("select_node", &self.select_node);
        Value::Object(m)
    }
}

/// Resolve the command-file path (`$FACETT_DEMO_CMD`, default
/// `/tmp/facett_demo_cmd.json`). Shared by the demo (reader) and any driver
/// (writer) so both agree on the channel.
pub fn cmd_path() -> String {
    std::env::var("FACETT_DEMO_CMD").unwrap_or_else(|_| "/tmp/facett_demo_cmd.json".to_string())
}

/// Resolve the state-dump path (`$FACETT_DEMO_STATE`, default
/// `/tmp/facett_demo_state.json`).
pub fn state_path() -> String {
    std::env::var("FACETT_DEMO_STATE").unwrap_or_else(|_| "/tmp/facett_demo_state.json".to_string())
}

/// Reader side (the running demo): read **and consume** a pending command, if any.
/// Returns `None` when no command file exists or it's empty. The file is removed
/// once read so the command fires exactly once; a malformed payload is removed too
/// (so a bad write can't wedge the channel) and surfaced as an `Err`.
pub fn take_pending() -> Option<Result<DemoCommand, String>> {
    let path = cmd_path();
    let raw = std::fs::read_to_string(&path).ok()?;
    // Consume-once: remove before parsing so a bad payload can't loop forever.
    let _ = std::fs::remove_file(&path);
    if raw.trim().is_empty() {
        return None;
    }
    match serde_json::from_str::<Value>(&raw) {
        Ok(v) if v.is_object() => Some(Ok(DemoCommand::from_value(&v))),
        Ok(_) => Some(Err("bad demo command: not a JSON object".to_string())),
        Err(e) => Some(Err(format!("bad demo command: {e}"))),
    }
}

/// Writer side (a test driver / external tool): write a command for the running
/// demo to apply next frame. Overwrites any unconsumed command.
pub fn write_command(cmd: &DemoCommand) -> std::io::Result<()> {
    let s = serde_json::to_string_pretty(&cmd.to_value()).unwrap_or_else(|_| "{}".to_string());
    std::fs::write(cmd_path(), s)
}

/// Write a `state_json` value to `$FACETT_DEMO_STATE`. The demo's per-frame dump
/// (throttling lives at the call site, like nornir's `dump_state`).
pub fn dump_state(state: &Value) -> std::io::Result<()> {
    let s = serde_json::to_string_pretty(state).unwrap_or_else(|_| "{}".to_string());
    std::fs::write(state_path(), s)
}

#[cfg(test)]
mod tests {
    use super::*;

    /// LAW (inject-assert): a real command round-trips through the file channel and
    /// is consumed exactly once; a malformed payload is surfaced (not silently
    /// swallowed) and also consumed. We assert real field values, not "didn't panic".
    #[test]
    fn roundtrip_write_take_consumes_once() {
        // Isolate the channel to this process so concurrent tests don't collide.
        let tmp = format!("/tmp/facett_demo_cmd_test_{}.json", std::process::id());
        // SAFETY: single-threaded test; set the env for the whole test body.
        unsafe { std::env::set_var("FACETT_DEMO_CMD", &tmp) };
        let _ = std::fs::remove_file(&tmp);

        // Nothing pending yet.
        assert!(take_pending().is_none(), "no command file → nothing pending");

        // Write a real command, read it back with real field values.
        let cmd = DemoCommand {
            tab: Some("grid".into()),
            palette: Some("cyberpunk-neon".into()),
            look: Some("macos-dark".into()),
            platform: Some("windows".into()),
            command: Some("fx.glow".into()),
            select_node: Some("Ingest".into()),
        };
        write_command(&cmd).unwrap();
        let got = take_pending().expect("a command is pending").expect("parses");
        assert_eq!(got, cmd, "every field round-trips through the control channel");

        // Consume-once: the second take sees nothing (the file was removed).
        assert!(take_pending().is_none(), "command fired exactly once");

        // A malformed payload is surfaced as an error, and consumed.
        std::fs::write(&tmp, "{not json").unwrap();
        let err = take_pending().expect("present").expect_err("malformed");
        assert!(err.contains("bad demo command"), "got: {err}");
        assert!(take_pending().is_none(), "the bad payload was consumed too");

        let _ = std::fs::remove_file(&tmp);
    }

    /// A partial command (only some fields) parses with the rest `None`, and a
    /// non-object payload is rejected.
    #[test]
    fn partial_and_non_object_payloads() {
        let only_tab = DemoCommand::from_value(&serde_json::json!({ "tab": "map" }));
        assert_eq!(only_tab.tab.as_deref(), Some("map"));
        assert_eq!(only_tab.palette, None);
        assert_eq!(only_tab.command, None);

        // to_value emits only the set field.
        let v = only_tab.to_value();
        assert_eq!(v.as_object().unwrap().len(), 1, "only the set field is serialized");
        assert_eq!(v["tab"], "map");
    }
}