greentic_flow/add_step/
normalize.rs1use serde_json::Value;
2
3use crate::{
4 error::{FlowError, FlowErrorLocation, Result},
5 flow_ir::Route,
6 util::is_valid_component_key,
7};
8
9#[derive(Debug, Clone)]
10pub struct NormalizedNode {
11 pub component_id: String,
12 pub pack_alias: Option<String>,
13 pub operation: Option<String>,
14 pub payload: Value,
15 pub routing: Vec<Route>,
16}
17
18pub fn normalize_node_map(value: Value) -> Result<NormalizedNode> {
19 let mut map = value
20 .as_object()
21 .cloned()
22 .ok_or_else(|| FlowError::Internal {
23 message: "node must be an object".to_string(),
24 location: FlowErrorLocation::at_path("node".to_string()),
25 })?;
26
27 if map.contains_key("tool") {
28 return Err(FlowError::Internal {
29 message: "Legacy tool emission is not supported. Update greentic-component to emit component.exec nodes without tool."
30 .to_string(),
31 location: FlowErrorLocation::at_path("node.tool".to_string()),
32 });
33 }
34
35 let mut component: Option<(String, Value)> = None;
36 let mut pack_alias: Option<String> = None;
37 let mut operation: Option<String> = None;
38 let mut routing: Option<Value> = None;
39
40 for (key, val) in map.clone() {
41 match key.as_str() {
42 "pack_alias" => {
43 pack_alias = Some(
44 val.as_str()
45 .ok_or_else(|| FlowError::Internal {
46 message: "pack_alias must be a string".to_string(),
47 location: FlowErrorLocation::at_path("pack_alias".to_string()),
48 })?
49 .to_string(),
50 );
51 map.remove(&key);
52 }
53 "operation" => {
54 operation = Some(
55 val.as_str()
56 .ok_or_else(|| FlowError::Internal {
57 message: "operation must be a string".to_string(),
58 location: FlowErrorLocation::at_path("operation".to_string()),
59 })?
60 .to_string(),
61 );
62 map.remove(&key);
63 }
64 "routing" => {
65 routing = Some(val.clone());
66 map.remove(&key);
67 }
68 _ => {}
69 }
70 }
71
72 for (key, val) in map {
73 if component.is_some() {
74 return Err(FlowError::Internal {
75 message: "node must have exactly one component key".to_string(),
76 location: FlowErrorLocation::at_path(format!("nodes.{key}")),
77 });
78 }
79 if !is_valid_component_key(&key) {
80 return Err(FlowError::BadComponentKey {
81 component: key,
82 node_id: "add_step".to_string(),
83 location: FlowErrorLocation::at_path("node".to_string()),
84 });
85 }
86 component = Some((key, val));
87 }
88
89 let (component_id, payload) = component.ok_or_else(|| FlowError::Internal {
90 message: "node must contain a component key".to_string(),
91 location: FlowErrorLocation::at_path("node".to_string()),
92 })?;
93
94 if component_id == "component.exec" && operation.as_deref().unwrap_or("").is_empty() {
95 return Err(FlowError::Internal {
96 message: "component.exec requires a non-empty operation".to_string(),
97 location: FlowErrorLocation::at_path("node.operation".to_string()),
98 });
99 }
100
101 let routes = parse_routes(routing.unwrap_or(Value::Array(Vec::new())))?;
102
103 Ok(NormalizedNode {
104 component_id,
105 pack_alias,
106 operation,
107 payload,
108 routing: routes,
109 })
110}
111
112fn parse_routes(raw: Value) -> Result<Vec<Route>> {
113 if raw.is_null() {
114 return Ok(Vec::new());
115 }
116
117 let arr = raw.as_array().ok_or_else(|| FlowError::Internal {
118 message: "routing must be an array".to_string(),
119 location: FlowErrorLocation::at_path("routing".to_string()),
120 })?;
121
122 let mut routes = Vec::new();
123 for entry in arr {
124 let obj = entry.as_object().ok_or_else(|| FlowError::Internal {
125 message: "routing entries must be objects".to_string(),
126 location: FlowErrorLocation::at_path("routing".to_string()),
127 })?;
128 for key in obj.keys() {
129 match key.as_str() {
130 "to" | "out" | "status" | "reply" => {}
131 other => {
132 return Err(FlowError::Internal {
133 message: format!("unsupported routing key '{other}'"),
134 location: FlowErrorLocation::at_path("routing".to_string()),
135 });
136 }
137 }
138 }
139 routes.push(Route {
140 to: obj.get("to").and_then(Value::as_str).map(|s| s.to_string()),
141 out: obj.get("out").and_then(Value::as_bool).unwrap_or(false),
142 status: obj
143 .get("status")
144 .and_then(Value::as_str)
145 .map(|s| s.to_string()),
146 reply: obj.get("reply").and_then(Value::as_bool).unwrap_or(false),
147 });
148 }
149
150 Ok(routes)
151}