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