nornir 0.4.33

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
//! 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>,
    /// **R6** — set a named form field on the LIVE viz (CLI/robot parity with the
    /// in-process `RobotSession::set_field`). The `name` is a stable viz field key
    /// (e.g. `"funnel.intake"`, `"funnel.intake_is_error"`, `"funnel.plan_target"`,
    /// `"funnel.demo_size"`), `value` the new value as a string (the viz parses it
    /// per field type). Unknown names are reported `applied=false` in the action
    /// log. See [`VizField`].
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub set_field: Option<VizField>,
    /// **R6** — click a named button/action on the LIVE viz by a stable button key
    /// (e.g. `"funnel.submit_intake"`, `"funnel.classify"`, `"funnel.generate_plan"`,
    /// `"funnel.run_demo"`). The drive-half equivalent of `RobotSession::click_by_id`
    /// for the running viz (where AccessKit node ids aren't stable across runs, a
    /// named key is the durable address). Unknown keys → `applied=false`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub click_id: Option<String>,
    /// **LAW4b** — request a self-screenshot: the viz fires
    /// `ViewportCommand::Screenshot`, encodes the result as PNG, writes it to
    /// `out_path` (when `Some`) **and** appends it to the warehouse
    /// `robot_test_results` blob store. `None` path → warehouse-only. The path
    /// is still returned in `state_json["screenshot"]["last_path"]`.
    /// CLI parity: `nornir viz --screenshot <path>` sends this on launch and
    /// exits after the file is written. Keyboard: Ctrl+Shift+S.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub screenshot: Option<ScreenshotRequest>,
}

/// **LAW4b** — payload for a `VizCommand` self-screenshot request.
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ScreenshotRequest {
    /// File path to write the PNG to, or `None` for warehouse-only.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub out_path: Option<String>,
}

/// **R6** — one named-field set for the live control channel: a stable viz field
/// `name` + its new `value` (stringified; the viz parses bool/number/text per
/// the field). The robot/CLI parity counterpart of
/// [`RobotSession::set_field`](../../../nornir_robotui/struct.RobotSession.html#method.set_field).
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct VizField {
    /// Stable field key (`"funnel.intake"`, …) the viz maps to its own state.
    pub name: String,
    /// New value as a string; the viz parses it for the field's type.
    pub value: 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()),
            set_field: Some(VizField {
                name: "funnel.intake".into(),
                value: "add dark mode".into(),
            }),
            click_id: Some("funnel.submit_intake".into()),
            screenshot: Some(ScreenshotRequest { out_path: Some("/tmp/test.png".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"));
        assert_eq!(
            got.set_field,
            Some(VizField { name: "funnel.intake".into(), value: "add dark mode".into() }),
            "R6 set_field round-trips through the control channel"
        );
        assert_eq!(got.click_id.as_deref(), Some("funnel.submit_intake"));
        assert_eq!(
            got.screenshot,
            Some(ScreenshotRequest { out_path: Some("/tmp/test.png".into()) }),
            "LAW4b screenshot request round-trips through the control channel"
        );

        // 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);
    }
}