greentic_flow/
config_flow.rs1use std::path::Path;
2
3use lazy_static::lazy_static;
4use regex::Regex;
5use serde_json::{Map, Value};
6
7use crate::{
8 compile_flow,
9 error::{FlowError, FlowErrorLocation, Result},
10 loader::load_ygtc_from_str_with_schema,
11};
12
13#[derive(Debug, Clone, PartialEq)]
15pub struct ConfigFlowOutput {
16 pub node_id: String,
17 pub node: Value,
18}
19
20pub fn run_config_flow(
29 yaml: &str,
30 schema_path: &Path,
31 answers: &Map<String, Value>,
32) -> Result<ConfigFlowOutput> {
33 let normalized_yaml = normalize_config_flow_yaml(yaml)?;
34 let doc = load_ygtc_from_str_with_schema(&normalized_yaml, schema_path)?;
35 let flow = compile_flow(doc.clone())?;
36 let mut state = answers.clone();
37
38 let mut current = resolve_entry(&doc);
39 let mut visited = 0usize;
40 while visited < flow.nodes.len().saturating_add(4) {
41 visited += 1;
42 let node_id = greentic_types::NodeId::new(current.as_str()).map_err(|e| {
43 FlowError::InvalidIdentifier {
44 kind: "node",
45 value: current.clone(),
46 detail: e.to_string(),
47 location: FlowErrorLocation::at_path(format!("nodes.{current}")),
48 }
49 })?;
50 let node = flow
51 .nodes
52 .get(&node_id)
53 .ok_or_else(|| FlowError::Internal {
54 message: format!("node '{current}' missing during config flow execution"),
55 location: FlowErrorLocation::at_path(format!("nodes.{current}")),
56 })?;
57
58 match node.component.id.as_str() {
59 "questions" => {
60 apply_questions(&node.input.mapping, &mut state)?;
61 }
62 "template" => {
63 let payload = render_template(&node.input.mapping, &state)?;
64 return extract_config_output(payload);
65 }
66 other => {
67 return Err(FlowError::Internal {
68 message: format!("unsupported component '{other}' in config flow"),
69 location: FlowErrorLocation::at_path(format!("nodes.{current}")),
70 });
71 }
72 }
73
74 current = match &node.routing {
75 greentic_types::Routing::Next { node_id } => node_id.as_str().to_string(),
76 greentic_types::Routing::End | greentic_types::Routing::Reply => {
77 return Err(FlowError::Internal {
78 message: "config flow terminated without reaching template node".to_string(),
79 location: FlowErrorLocation::at_path("nodes".to_string()),
80 });
81 }
82 greentic_types::Routing::Branch { .. } | greentic_types::Routing::Custom(_) => {
83 return Err(FlowError::Internal {
84 message: "unsupported routing shape in config flow".to_string(),
85 location: FlowErrorLocation::at_path(format!("nodes.{current}.routing")),
86 });
87 }
88 }
89 }
90
91 Err(FlowError::Internal {
92 message: "config flow exceeded traversal limit".to_string(),
93 location: FlowErrorLocation::at_path("nodes".to_string()),
94 })
95}
96
97pub fn run_config_flow_from_path(
99 path: &Path,
100 schema_path: &Path,
101 answers: &Map<String, Value>,
102) -> Result<ConfigFlowOutput> {
103 let text = std::fs::read_to_string(path).map_err(|e| FlowError::Internal {
104 message: format!("read config flow {}: {e}", path.display()),
105 location: FlowErrorLocation::at_path(path.display().to_string())
106 .with_source_path(Some(path)),
107 })?;
108 run_config_flow(&text, schema_path, answers)
109}
110
111fn resolve_entry(doc: &crate::model::FlowDoc) -> String {
112 if let Some(start) = &doc.start {
113 return start.clone();
114 }
115 if doc.nodes.contains_key("in") {
116 return "in".to_string();
117 }
118 doc.nodes
119 .keys()
120 .next()
121 .cloned()
122 .unwrap_or_else(|| "in".to_string())
123}
124
125fn apply_questions(payload: &Value, state: &mut Map<String, Value>) -> Result<()> {
126 let fields = payload
127 .get("fields")
128 .and_then(Value::as_array)
129 .ok_or_else(|| FlowError::Internal {
130 message: "questions node missing fields array".to_string(),
131 location: FlowErrorLocation::at_path("questions.fields".to_string()),
132 })?;
133
134 for field in fields {
135 let id = field
136 .get("id")
137 .and_then(Value::as_str)
138 .ok_or_else(|| FlowError::Internal {
139 message: "questions field missing id".to_string(),
140 location: FlowErrorLocation::at_path("questions.fields".to_string()),
141 })?;
142 if state.contains_key(id) {
143 continue;
144 }
145 if let Some(default) = field.get("default") {
146 state.insert(id.to_string(), default.clone());
147 } else {
148 return Err(FlowError::Internal {
149 message: format!("missing answer for '{id}'"),
150 location: FlowErrorLocation::at_path(format!("questions.fields.{id}")),
151 });
152 }
153 }
154 Ok(())
155}
156
157fn render_template(payload: &Value, state: &Map<String, Value>) -> Result<Value> {
158 let template_str = payload.as_str().ok_or_else(|| FlowError::Internal {
159 message: "template node payload must be a string".to_string(),
160 location: FlowErrorLocation::at_path("template".to_string()),
161 })?;
162 let mut value: Value = serde_json::from_str(template_str).map_err(|e| FlowError::Internal {
163 message: format!("template JSON parse error: {e}"),
164 location: FlowErrorLocation::at_path("template".to_string()),
165 })?;
166 substitute_state(&mut value, state)?;
167 Ok(value)
168}
169
170lazy_static! {
171 static ref STATE_RE: Regex = Regex::new(r"^\{\{\s*state\.([A-Za-z_]\w*)\s*\}\}$").unwrap();
172}
173
174fn substitute_state(target: &mut Value, state: &Map<String, Value>) -> Result<()> {
175 match target {
176 Value::String(s) => {
177 if let Some(caps) = STATE_RE.captures(s) {
178 let key = caps.get(1).unwrap().as_str();
179 let val = state.get(key).ok_or_else(|| FlowError::Internal {
180 message: format!("state value for '{key}' not found"),
181 location: FlowErrorLocation::at_path(format!("state.{key}")),
182 })?;
183 *target = val.clone();
184 }
185 Ok(())
186 }
187 Value::Array(items) => {
188 for item in items {
189 substitute_state(item, state)?;
190 }
191 Ok(())
192 }
193 Value::Object(map) => {
194 for value in map.values_mut() {
195 substitute_state(value, state)?;
196 }
197 Ok(())
198 }
199 _ => Ok(()),
200 }
201}
202
203fn extract_config_output(value: Value) -> Result<ConfigFlowOutput> {
204 let node_id = value
205 .get("node_id")
206 .and_then(Value::as_str)
207 .ok_or_else(|| FlowError::Internal {
208 message: "config flow output missing node_id".to_string(),
209 location: FlowErrorLocation::at_path("node_id".to_string()),
210 })?
211 .to_string();
212 let node = value
213 .get("node")
214 .cloned()
215 .ok_or_else(|| FlowError::Internal {
216 message: "config flow output missing node".to_string(),
217 location: FlowErrorLocation::at_path("node".to_string()),
218 })?;
219 if node.get("tool").is_some() {
220 return Err(FlowError::Internal {
221 message: "Legacy tool emission is not supported. Update greentic-component to emit component.exec nodes without tool."
222 .to_string(),
223 location: FlowErrorLocation::at_path("node.tool".to_string()),
224 });
225 }
226 if crate::add_step::id::is_placeholder_value(&node_id) {
227 return Err(FlowError::Internal {
228 message: format!(
229 "Config flow emitted placeholder node id '{node_id}'; update greentic-component to emit the component name."
230 ),
231 location: FlowErrorLocation::at_path("node_id".to_string()),
232 });
233 }
234 Ok(ConfigFlowOutput { node_id, node })
235}
236
237fn normalize_config_flow_yaml(yaml: &str) -> Result<String> {
238 let mut value: Value = serde_yaml_bw::from_str(yaml).map_err(|e| FlowError::Yaml {
239 message: e.to_string(),
240 location: FlowErrorLocation::at_path("config_flow".to_string()),
241 })?;
242 if let Some(map) = value.as_object_mut() {
243 match map.get("type") {
244 Some(Value::String(_)) => {}
245 _ => {
246 map.insert(
247 "type".to_string(),
248 Value::String("component-config".to_string()),
249 );
250 }
251 }
252 }
253 serde_yaml_bw::to_string(&value).map_err(|e| FlowError::Internal {
254 message: format!("normalize config flow: {e}"),
255 location: FlowErrorLocation::at_path("config_flow".to_string()),
256 })
257}