use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{HostError, Spec};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct GraphNode {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
pub full_type: String,
pub kind: String,
pub title: String,
pub subtitle: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GraphEdge {
pub source: String,
pub target: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct WorkflowGraph {
pub spec_id: String,
pub nodes: Vec<GraphNode>,
pub edges: Vec<GraphEdge>,
}
pub fn primitive_kind(node_type: &str) -> &'static str {
match node_type.split('.').next().unwrap_or(node_type) {
"ingress" => "event",
"filter" | "decision" => "filter",
"risk" => "risk",
"notify" => "channel",
"sink" => "sink",
_ => "tool",
}
}
fn render_text(template: &str, instance_config: &Value) -> String {
let mut output = template.to_string();
if let Some(config) = instance_config.as_object() {
for (key, value) in config {
let value = value
.as_str()
.map(str::to_string)
.unwrap_or_else(|| value.to_string());
output = output.replace(&format!("$config.{key}"), &value);
}
}
output
}
fn default_subtitle(config: &Value, instance_config: &Value) -> String {
config
.as_object()
.into_iter()
.flat_map(|object| object.iter().take(3))
.map(|(key, value)| {
let value = value
.as_str()
.map(|text| render_text(text, instance_config))
.unwrap_or_else(|| value.to_string());
format!("{key}={value}")
})
.collect::<Vec<_>>()
.join(", ")
}
pub fn render_graph(spec: &Spec, instance_config: &Value) -> WorkflowGraph {
let multi_branch = spec.branches.len() > 1;
let mut nodes = Vec::new();
let mut edges = Vec::new();
for branch in &spec.branches {
let prefix = if multi_branch {
format!("{}__", branch.branch_id)
} else {
String::new()
};
for node in &branch.nodes {
let display = spec.display.get(&node.id);
let text = |key: &str| {
display
.and_then(|value| value.get(key))
.and_then(Value::as_str)
.map(|value| render_text(value, instance_config))
};
nodes.push(GraphNode {
id: format!("{prefix}{}", node.id),
branch: multi_branch.then(|| branch.branch_id.clone()),
full_type: node.node_type.clone(),
kind: primitive_kind(&node.node_type).into(),
title: text("title").unwrap_or_else(|| node.action().replace('_', " ")),
subtitle: text("subtitle")
.unwrap_or_else(|| default_subtitle(&node.config, instance_config)),
});
}
edges.extend(branch.edges.iter().map(|edge| GraphEdge {
source: format!("{prefix}{}", edge.source),
target: format!("{prefix}{}", edge.target),
}));
}
WorkflowGraph {
spec_id: spec.spec_id.clone(),
nodes,
edges,
}
}
pub fn render_graph_json(
spec_json: &str,
instance_config: &Value,
) -> Result<WorkflowGraph, HostError> {
Ok(render_graph(&Spec::from_json(spec_json)?, instance_config))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn multi_branch_ids_and_display_are_ui_ready() {
let graph = render_graph_json(
r#"{"spec_id":"g","version":"1","display":{"in":{"title":"Run $config.name"}},"branches":[
{"branch_id":"a","nodes":[{"id":"in","type":"ingress.event","config":{}}],"edges":[]},
{"branch_id":"b","nodes":[{"id":"out","type":"notify.email","config":{"to":"$config.email"}}],"edges":[]}
]}"#,
&json!({"name":"demo","email":"a@example.com"}),
)
.unwrap();
assert_eq!(graph.nodes[0].id, "a__in");
assert_eq!(graph.nodes[0].title, "Run demo");
assert_eq!(graph.nodes[1].kind, "channel");
assert!(graph.nodes[1].subtitle.contains("a@example.com"));
}
#[test]
fn primitive_categories_are_stable() {
assert_eq!(primitive_kind("ingress.event"), "event");
assert_eq!(primitive_kind("decision.vote"), "filter");
assert_eq!(primitive_kind("risk.guard"), "risk");
assert_eq!(primitive_kind("sink.log"), "sink");
assert_eq!(primitive_kind("transform.map"), "tool");
}
}