greentic_flow/
flow_ir.rs

1use std::collections::BTreeMap;
2
3use indexmap::IndexMap;
4use serde::{Deserialize, Serialize};
5use serde_json::{Map, Value};
6
7use crate::{
8    error::{FlowError, FlowErrorLocation, Result},
9    loader::load_ygtc_from_str,
10    model::{FlowDoc, NodeDoc},
11};
12
13/// Typed intermediate representation for flows, suitable for planning edits before
14/// rendering back into YGTC YAML.
15#[derive(Debug, Clone)]
16pub struct FlowIr {
17    pub id: String,
18    pub kind: String,
19    pub entrypoints: IndexMap<String, String>,
20    pub nodes: IndexMap<String, NodeIr>,
21}
22
23#[derive(Debug, Clone)]
24pub struct NodeIr {
25    pub id: String,
26    pub kind: NodeKind,
27    pub routing: Vec<Route>,
28}
29
30#[derive(Debug, Clone)]
31pub enum NodeKind {
32    Component(ComponentRef),
33    Questions {
34        fields: Value,
35    },
36    Template {
37        template: String,
38    },
39    Other {
40        component_id: String,
41        payload: Value,
42    },
43}
44
45#[derive(Debug, Clone)]
46pub struct ComponentRef {
47    pub component_id: String,
48    pub pack_alias: Option<String>,
49    pub operation: Option<String>,
50    pub payload: Value,
51}
52
53#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
54pub struct Route {
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub to: Option<String>,
57    #[serde(default, skip_serializing_if = "is_false")]
58    pub out: bool,
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub status: Option<String>,
61    #[serde(default, skip_serializing_if = "is_false")]
62    pub reply: bool,
63}
64
65fn is_false(value: &bool) -> bool {
66    !*value
67}
68
69impl FlowIr {
70    pub fn from_doc(doc: FlowDoc) -> Result<Self> {
71        let entrypoints = resolve_entrypoints(&doc);
72        let mut nodes = IndexMap::new();
73        for (id, node_doc) in doc.nodes {
74            let routing = parse_routing(&node_doc, &id)?;
75            let kind = match node_doc.component.as_str() {
76                "questions" => NodeKind::Questions {
77                    fields: node_doc.payload.clone(),
78                },
79                "template" => {
80                    let template =
81                        node_doc
82                            .payload
83                            .as_str()
84                            .ok_or_else(|| FlowError::Internal {
85                                message: "template node payload must be a string".to_string(),
86                                location: FlowErrorLocation::at_path(format!(
87                                    "nodes.{id}.template"
88                                )),
89                            })?;
90                    NodeKind::Template {
91                        template: template.to_string(),
92                    }
93                }
94                other => NodeKind::Component(ComponentRef {
95                    component_id: other.to_string(),
96                    pack_alias: node_doc.pack_alias.clone(),
97                    operation: node_doc.operation.clone(),
98                    payload: node_doc.payload.clone(),
99                }),
100            };
101            nodes.insert(
102                id.clone(),
103                NodeIr {
104                    id: id.clone(),
105                    kind,
106                    routing,
107                },
108            );
109        }
110
111        Ok(FlowIr {
112            id: doc.id,
113            kind: doc.flow_type,
114            entrypoints,
115            nodes,
116        })
117    }
118
119    pub fn to_doc(&self) -> Result<FlowDoc> {
120        let mut nodes: BTreeMap<String, NodeDoc> = BTreeMap::new();
121        for (id, node_ir) in &self.nodes {
122            let (component, payload, pack_alias, operation, raw) = match &node_ir.kind {
123                NodeKind::Component(comp) => {
124                    let mut raw = BTreeMap::new();
125                    raw.insert(comp.component_id.clone(), comp.payload.clone());
126                    if let Some(alias) = &comp.pack_alias {
127                        raw.insert("pack_alias".to_string(), Value::String(alias.clone()));
128                    }
129                    if let Some(op) = &comp.operation {
130                        raw.insert("operation".to_string(), Value::String(op.clone()));
131                    }
132                    (
133                        comp.component_id.clone(),
134                        comp.payload.clone(),
135                        comp.pack_alias.clone(),
136                        comp.operation.clone(),
137                        raw,
138                    )
139                }
140                NodeKind::Questions { fields } => {
141                    let mut raw = BTreeMap::new();
142                    raw.insert("questions".to_string(), fields.clone());
143                    ("questions".to_string(), fields.clone(), None, None, raw)
144                }
145                NodeKind::Template { template } => {
146                    let mut raw = BTreeMap::new();
147                    raw.insert("template".to_string(), Value::String(template.clone()));
148                    (
149                        "template".to_string(),
150                        Value::String(template.clone()),
151                        None,
152                        None,
153                        raw,
154                    )
155                }
156                NodeKind::Other {
157                    component_id,
158                    payload,
159                } => {
160                    let mut raw = BTreeMap::new();
161                    raw.insert(component_id.clone(), payload.clone());
162                    (component_id.clone(), payload.clone(), None, None, raw)
163                }
164            };
165
166            let routing_value =
167                serde_json::to_value(&node_ir.routing).map_err(|e| FlowError::Internal {
168                    message: format!("serialize routing for node '{id}': {e}"),
169                    location: FlowErrorLocation::at_path(format!("nodes.{id}.routing")),
170                })?;
171
172            nodes.insert(
173                id.clone(),
174                NodeDoc {
175                    component,
176                    pack_alias,
177                    operation,
178                    payload,
179                    routing: routing_value,
180                    output: None,
181                    telemetry: None,
182                    raw,
183                },
184            );
185        }
186
187        Ok(FlowDoc {
188            id: self.id.clone(),
189            title: None,
190            description: None,
191            flow_type: self.kind.clone(),
192            start: self.entrypoints.get("default").cloned(),
193            parameters: Value::Object(Map::new()),
194            tags: Vec::new(),
195            entrypoints: BTreeMap::new(),
196            nodes,
197        })
198    }
199}
200
201fn resolve_entrypoints(doc: &FlowDoc) -> IndexMap<String, String> {
202    let mut entries = IndexMap::new();
203    if let Some(start) = &doc.start {
204        entries.insert("default".to_string(), start.clone());
205    } else if doc.nodes.contains_key("in") {
206        entries.insert("default".to_string(), "in".to_string());
207    } else if let Some(first) = doc.nodes.keys().next() {
208        entries.insert("default".to_string(), first.clone());
209    }
210    for (k, v) in &doc.entrypoints {
211        if let Some(target) = v.as_str() {
212            entries.insert(k.clone(), target.to_string());
213        }
214    }
215    entries
216}
217
218fn parse_routing(node: &NodeDoc, node_id: &str) -> Result<Vec<Route>> {
219    #[derive(serde::Deserialize)]
220    struct RouteDoc {
221        #[serde(default)]
222        to: Option<String>,
223        #[serde(default)]
224        out: Option<bool>,
225        #[serde(default)]
226        status: Option<String>,
227        #[serde(default)]
228        reply: Option<bool>,
229    }
230
231    let routes: Vec<RouteDoc> = if node.routing.is_null() {
232        Vec::new()
233    } else {
234        serde_json::from_value(node.routing.clone()).map_err(|e| FlowError::Internal {
235            message: format!("routing decode for node '{node_id}': {e}"),
236            location: FlowErrorLocation::at_path(format!("nodes.{node_id}.routing")),
237        })?
238    };
239
240    Ok(routes
241        .into_iter()
242        .map(|r| Route {
243            to: r.to,
244            out: r.out.unwrap_or(false),
245            status: r.status,
246            reply: r.reply.unwrap_or(false),
247        })
248        .collect())
249}
250
251/// Helper for tests: load YAML text straight into Flow IR.
252pub fn parse_flow_to_ir(yaml: &str) -> Result<FlowIr> {
253    let doc = load_ygtc_from_str(yaml)?;
254    FlowIr::from_doc(doc)
255}