use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
pub const FLOW_ASSET: &str = include_str!("../../assets/flow.html");
const BOOT_PLACEHOLDER: &str = "/*__BOOT__*/null";
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct VizModel {
#[serde(skip_serializing_if = "Option::is_none")]
pub plan_title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub plan_state: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub about: Option<String>,
pub tasks: Vec<TaskRow>,
pub machine: Machine,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TaskRow {
pub id: String,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
pub depth: u8,
pub state: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub visit_count: Option<u32>,
#[serde(default)]
pub prior: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub history: Vec<StateHistoryEntry>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct StateHistoryEntry {
pub from: String,
pub to: String,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct Machine {
pub name: String,
pub states: Vec<MachineState>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct MachineState {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub instructions: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub visits: Option<u32>,
pub initial: bool,
pub terminal: bool,
pub gating: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub process: Option<MachineProcessKind>,
pub transitions: Vec<Transition>,
#[serde(default)]
pub inputs: Vec<Artifact>,
#[serde(default)]
pub outputs: Vec<Artifact>,
#[serde(default, skip_serializing_if = "TemplateContext::is_empty")]
pub template_context: TemplateContext,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub template_contexts: Vec<TemplateContext>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum MachineProcessKind {
Agent,
Program,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct TemplateContext {
#[serde(skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target_slug: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model_provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_mode: Option<String>,
}
impl TemplateContext {
fn is_empty(&self) -> bool {
self.target.is_none()
&& self.target_slug.is_none()
&& self.model.is_none()
&& self.model_provider.is_none()
&& self.model_name.is_none()
&& self.agent.is_none()
&& self.agent_mode.is_none()
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Transition {
pub to: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub condition: Option<String>,
pub wildcard: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Artifact {
pub name: String,
pub path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub optional: bool,
}
pub fn render_static(plans: &BTreeMap<String, VizModel>) -> String {
let data = serde_json::to_string(plans).expect("plan bundle should always serialize");
render_inline(&data)
}
pub fn render_inline(boot_json: &str) -> String {
FLOW_ASSET.replace(BOOT_PLACEHOLDER, &escape_json_for_html_script(boot_json))
}
pub fn with_page_notice(html: &str, notice: &str) -> String {
let banner = format!(
"<div role=\"note\" style=\"padding:10px 16px;background:var(--surface-2);\
border-bottom:1px solid var(--hairline);color:var(--dim);\
font:12px/1.5 var(--mono)\">{}</div>",
escape_html_text(notice)
);
match html.find("<body>") {
Some(index) => {
let split = index + "<body>".len();
format!("{}\n{}{}", &html[..split], banner, &html[split..])
}
None => format!("{banner}{html}"),
}
}
fn escape_html_text(text: &str) -> String {
text.replace('&', "&").replace('<', "<").replace('>', ">")
}
pub fn render_static_one(key: &str, model: &VizModel) -> String {
let mut bundle = BTreeMap::new();
bundle.insert(key.to_string(), model.clone());
render_static(&bundle)
}
pub fn live_asset() -> &'static str {
FLOW_ASSET
}
pub fn escape_json_for_html_script(data: &str) -> String {
let mut out = String::with_capacity(data.len());
for ch in data.chars() {
match ch {
'<' => out.push_str("\\u003c"),
'>' => out.push_str("\\u003e"),
'&' => out.push_str("\\u0026"),
'\u{2028}' => out.push_str("\\u2028"),
'\u{2029}' => out.push_str("\\u2029"),
_ => out.push(ch),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn demo_model() -> VizModel {
VizModel {
plan_title: Some("Demo".into()),
plan_state: Some("active".into()),
about: None,
tasks: vec![TaskRow {
id: "1".into(),
title: "Alpha".into(),
parent: None,
depth: 0,
state: "in-progress".into(),
visit_count: None,
prior: vec![],
history: vec![],
}],
machine: Machine { name: "rhei".into(), states: vec![] },
}
}
#[test]
fn render_static_inlines_bundle_and_drops_placeholder() {
let html = render_static_one("demo", &demo_model());
assert!(!html.contains(BOOT_PLACEHOLDER));
assert!(html.contains("\"plan_title\":\"Demo\""));
assert!(html.contains("\"depth\":0"));
}
#[test]
fn render_static_escapes_script_breakouts() {
let mut model = demo_model();
model.plan_title = Some("</script><script>alert(1)</script>".into());
let html = render_static_one("demo", &model);
assert!(!html.contains("</script><script>alert(1)</script>"));
assert!(
html.contains("\\u003c/script\\u003e\\u003cscript\\u003ealert(1)\\u003c/script\\u003e")
);
}
#[test]
fn live_asset_leaves_boot_null_so_js_polls() {
assert!(live_asset().contains(BOOT_PLACEHOLDER));
}
#[test]
fn flow_asset_running_now_uses_runtime_slots() {
assert!(FLOW_ASSET.contains("function isRunningNow(n)"));
assert!(FLOW_ASSET.contains("filter(isRunningNow)"));
assert!(FLOW_ASSET.contains("const runningPart = hasRuntimeOverlay()"));
}
#[test]
fn flow_asset_clears_stale_running_overlay_on_poll_loss() {
assert!(FLOW_ASSET.contains("let POLL_FAILS=0, LIVE_STALE=false"));
assert!(FLOW_ASSET.contains("function clearRunningOverlay()"));
assert!(FLOW_ASSET.contains("if(POLL_FAILS>=2) clearRunningOverlay()"));
assert!(FLOW_ASSET.contains("delete rt.in_slot"));
assert!(FLOW_ASSET.contains("s.active=false"));
}
}