nornir 0.4.21

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
Documentation
//! The viz **control channel** — the drive-half of the robot-UI-tester loop.
//!
//! The read-half ([`super::app::UrdrThreadsApp::state_json`] → `$NORNIR_VIZ_STATE`)
//! lets an agent see what's on screen. This is the write-half: a tiny
//! **command file** an external driver (the `viz.click` MCP tool) writes, that
//! the running viz polls **once per frame**, applies, and then deletes
//! (consume-once, so a command fires exactly one time).
//!
//! Why a file and not a socket: it's the same shape as the rest of the viz's
//! external contract (`$NORNIR_VIZ_STATE` dump file, `$NORNIR_VIZ_ACTIONLOG`
//! file, `$NORNIR_VIZ_TRACE` file) — observable, greppable, no extra runtime,
//! no port to bind, and it works the same headless or live. The driver writes;
//! the viz consumes. One command at a time is plenty for a click loop:
//! `viz.click` writes, the viz applies it next frame, and the result shows up in
//! the next `$NORNIR_VIZ_STATE` dump that `viz.state` reads back.
//!
//! Contract (the JSON written to `$NORNIR_VIZ_CMD`, default
//! `/tmp/nornir_viz_cmd.json`):
//!
//! ```json
//! { "tab": "Test", "workspace": "korp" }
//! ```
//!
//! Both fields are optional: set `tab` to switch the active tab (by its
//! `state_json()["tab"]` debug name — `Timeline`, `DepGraph`, …, `Security`),
//! `workspace` to switch the workspace picker. An unknown tab name is reported
//! back via `applied=false` + `error` in the action log; a known one applies and
//! the file is removed.

use serde::{Deserialize, Serialize};

/// One robot-drive command. All fields optional so a driver can switch just the
/// tab, just the workspace, or both in one command.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct VizCommand {
    /// Tab to make active, by `state_json()["tab"]` debug name (e.g. `"Test"`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tab: Option<String>,
    /// Workspace to select in the picker.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub workspace: Option<String>,
    /// App-wide facett palette to activate (by name, e.g. `"cyberpunk-neon"`);
    /// `-`/`_`/spaces interchange. Drives the 🎨 palette picker (C8) headlessly so
    /// the theming has CLI/robot parity, not GUI-only (project LAW).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub palette: Option<String>,
}

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

/// Reader side (the running viz): read **and consume** a pending command, if any.
/// Returns `None` when no command file exists. The file is removed once read so
/// the command fires exactly once. A malformed file is removed too (so a bad
/// write can't wedge the channel) and surfaced as an `Err`.
pub fn take_pending() -> Option<Result<VizCommand, 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;
    }
    Some(serde_json::from_str::<VizCommand>(&raw).map_err(|e| format!("bad viz command: {e}")))
}

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

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

    #[test]
    fn roundtrip_write_take_consumes_once() {
        let tmp = format!("/tmp/nornir_viz_cmd_test_{}.json", std::process::id());
        std::env::set_var("NORNIR_VIZ_CMD", &tmp);
        let _ = std::fs::remove_file(&tmp);

        // Nothing pending yet.
        assert!(take_pending().is_none());

        // Write a real command, read it back with real field values.
        write_command(&VizCommand {
            tab: Some("Test".into()),
            workspace: Some("korp".into()),
            palette: Some("cyberpunk-neon".into()),
        })
        .unwrap();
        let got = take_pending().expect("a command is pending").expect("parses");
        assert_eq!(got.tab.as_deref(), Some("Test"));
        assert_eq!(got.workspace.as_deref(), Some("korp"));
        assert_eq!(got.palette.as_deref(), Some("cyberpunk-neon"));

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

        // 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 viz command"), "got: {err}");
        assert!(take_pending().is_none());

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