Skip to main content

af_workflow/
graph.rs

1//! Presentation-only workflow graph rendering for UI consumers.
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6use crate::{HostError, Spec};
7
8/// Node in the rendered UI graph.
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10pub struct GraphNode {
11    /// Stable identifier of this record.
12    pub id: String,
13    /// Branch the node belongs to, when not the root.
14    #[serde(skip_serializing_if = "Option::is_none")]
15    pub branch: Option<String>,
16    /// Full node type string.
17    pub full_type: String,
18    /// Discriminator naming the variant of this record.
19    pub kind: String,
20    /// Display title.
21    pub title: String,
22    /// Secondary label rendered under the title.
23    pub subtitle: String,
24}
25
26/// Directed edge in the rendered UI graph.
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
28pub struct GraphEdge {
29    /// Source node id.
30    pub source: String,
31    /// Target node id.
32    pub target: String,
33}
34
35/// Presentation model of a spec for UI rendering.
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
37pub struct WorkflowGraph {
38    /// Workflow spec identifier.
39    pub spec_id: String,
40    /// Nodes in this graph.
41    pub nodes: Vec<GraphNode>,
42    /// Directed edges between nodes.
43    pub edges: Vec<GraphEdge>,
44}
45
46/// Coarse primitive kind (`ingress`, `guard`, `execute`, `sink`, `transform`, ...) for a node type.
47pub 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
88/// Render a spec with templates resolved against `instance_config`.
89pub 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
131/// [`render_graph`] serialized to JSON.
132pub 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}