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