1use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6use crate::{HostError, Spec};
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10pub struct GraphNode {
11 pub id: String,
13 #[serde(skip_serializing_if = "Option::is_none")]
15 pub branch: Option<String>,
16 pub full_type: String,
18 pub kind: String,
20 pub title: String,
22 pub subtitle: String,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
28pub struct GraphEdge {
29 pub source: String,
31 pub target: String,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
37pub struct WorkflowGraph {
38 pub spec_id: String,
40 pub nodes: Vec<GraphNode>,
42 pub edges: Vec<GraphEdge>,
44}
45
46pub fn primitive_kind(node_type: &str) -> &'static str {
48 match node_type.split('.').next().unwrap_or(node_type) {
49 "ingress" => "event",
50 "filter" | "decision" => "filter",
51 "risk" => "risk",
52 "notify" => "channel",
53 "sink" => "sink",
54 _ => "tool",
55 }
56}
57
58fn render_text(template: &str, instance_config: &Value) -> String {
59 let mut output = template.to_string();
60 if let Some(config) = instance_config.as_object() {
61 for (key, value) in config {
62 let value = value
63 .as_str()
64 .map(str::to_string)
65 .unwrap_or_else(|| value.to_string());
66 output = output.replace(&format!("$config.{key}"), &value);
67 }
68 }
69 output
70}
71
72fn default_subtitle(config: &Value, instance_config: &Value) -> String {
73 config
74 .as_object()
75 .into_iter()
76 .flat_map(|object| object.iter().take(3))
77 .map(|(key, value)| {
78 let value = value
79 .as_str()
80 .map(|text| render_text(text, instance_config))
81 .unwrap_or_else(|| value.to_string());
82 format!("{key}={value}")
83 })
84 .collect::<Vec<_>>()
85 .join(", ")
86}
87
88pub fn render_graph(spec: &Spec, instance_config: &Value) -> WorkflowGraph {
90 let multi_branch = spec.branches.len() > 1;
91 let mut nodes = Vec::new();
92 let mut edges = Vec::new();
93
94 for branch in &spec.branches {
95 let prefix = if multi_branch {
96 format!("{}__", branch.branch_id)
97 } else {
98 String::new()
99 };
100 for node in &branch.nodes {
101 let display = spec.display.get(&node.id);
102 let text = |key: &str| {
103 display
104 .and_then(|value| value.get(key))
105 .and_then(Value::as_str)
106 .map(|value| render_text(value, instance_config))
107 };
108 nodes.push(GraphNode {
109 id: format!("{prefix}{}", node.id),
110 branch: multi_branch.then(|| branch.branch_id.clone()),
111 full_type: node.node_type.clone(),
112 kind: primitive_kind(&node.node_type).into(),
113 title: text("title").unwrap_or_else(|| node.action().replace('_', " ")),
114 subtitle: text("subtitle")
115 .unwrap_or_else(|| default_subtitle(&node.config, instance_config)),
116 });
117 }
118 edges.extend(branch.edges.iter().map(|edge| GraphEdge {
119 source: format!("{prefix}{}", edge.source),
120 target: format!("{prefix}{}", edge.target),
121 }));
122 }
123
124 WorkflowGraph {
125 spec_id: spec.spec_id.clone(),
126 nodes,
127 edges,
128 }
129}
130
131pub fn render_graph_json(
133 spec_json: &str,
134 instance_config: &Value,
135) -> Result<WorkflowGraph, HostError> {
136 Ok(render_graph(&Spec::from_json(spec_json)?, instance_config))
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142 use serde_json::json;
143
144 #[test]
145 fn multi_branch_ids_and_display_are_ui_ready() {
146 let graph = render_graph_json(
147 r#"{"spec_id":"g","version":"1","display":{"in":{"title":"Run $config.name"}},"branches":[
148 {"branch_id":"a","nodes":[{"id":"in","type":"ingress.event","config":{}}],"edges":[]},
149 {"branch_id":"b","nodes":[{"id":"out","type":"notify.email","config":{"to":"$config.email"}}],"edges":[]}
150 ]}"#,
151 &json!({"name":"demo","email":"a@example.com"}),
152 )
153 .unwrap();
154 assert_eq!(graph.nodes[0].id, "a__in");
155 assert_eq!(graph.nodes[0].title, "Run demo");
156 assert_eq!(graph.nodes[1].kind, "channel");
157 assert!(graph.nodes[1].subtitle.contains("a@example.com"));
158 }
159
160 #[test]
161 fn primitive_categories_are_stable() {
162 assert_eq!(primitive_kind("ingress.event"), "event");
163 assert_eq!(primitive_kind("decision.vote"), "filter");
164 assert_eq!(primitive_kind("risk.guard"), "risk");
165 assert_eq!(primitive_kind("sink.log"), "sink");
166 assert_eq!(primitive_kind("transform.map"), "tool");
167 }
168}