use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct VizCommand {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub palette: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub set_field: Option<VizField>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub click_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub screenshot: Option<ScreenshotRequest>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct ScreenshotRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub out_path: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct VizField {
pub name: String,
pub value: String,
}
pub fn cmd_path() -> String {
std::env::var("NORNIR_VIZ_CMD").unwrap_or_else(|_| "/tmp/nornir_viz_cmd.json".to_string())
}
pub fn take_pending() -> Option<Result<VizCommand, String>> {
let path = cmd_path();
let raw = std::fs::read_to_string(&path).ok()?;
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}")))
}
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);
assert!(take_pending().is_none());
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"
);
assert!(take_pending().is_none());
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);
}
}