greentic_flow/
config_flow.rs

1use 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/// Result of executing a config flow: a node identifier and the node object to insert.
14#[derive(Debug, Clone, PartialEq)]
15pub struct ConfigFlowOutput {
16    pub node_id: String,
17    pub node: Value,
18}
19
20/// Execute a minimal, single-pass config-flow harness.
21///
22/// Supported components:
23/// - `questions`: seeds state values from provided answers or defaults.
24/// - `template`: renders the template payload, replacing `{{state.key}}` placeholders inside strings.
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) -> 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
97/// Load config flow YAML from disk, applying type normalization before execution.
98pub 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}