greentic_flow/add_step/
normalize.rs

1use 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}