Skip to main content

greentic_flow/
config_flow.rs

1use 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/// Result of executing a config flow: a node identifier and the node object to insert.
13#[derive(Debug, Clone, PartialEq)]
14pub struct ConfigFlowOutput {
15    pub node_id: String,
16    pub node: Value,
17}
18
19/// Execute a minimal, single-pass config-flow harness.
20///
21/// Supported components:
22/// - `questions`: seeds state values from provided answers or defaults.
23/// - `template`: renders the template payload with simple Handlebars helpers and `{{state.key}}`
24///   interpolation.
25///
26/// The flow ends when a `template` node is executed. Routing follows the first non-out route if
27/// present, otherwise stops.
28pub 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, &current)?;
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
99/// Load config flow YAML from disk, applying type normalization before execution.
100pub 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}