greentic_dev/
flow_cmd.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use crate::cli::{ConfigFlowModeArg, FlowAddStepArgs};
5use crate::component_add::run_component_add;
6use crate::pack_init::PackInitIntent;
7use crate::path_safety::normalize_under_root;
8use anyhow::{Context, Result, anyhow, bail};
9use greentic_flow::flow_bundle::load_and_validate_bundle;
10use serde_json::Value as JsonValue;
11use serde_yaml_bw as serde_yaml;
12use std::io::Write;
13use std::str::FromStr;
14use std::{io, io::IsTerminal};
15use tempfile::NamedTempFile;
16
17use greentic_types::FlowId;
18use greentic_types::component::ComponentManifest;
19
20const DEFAULT_CONFIG_FLOW_TYPE: &str = "component-config";
21
22pub fn validate(path: &Path, compact_json: bool) -> Result<()> {
23    let root = std::env::current_dir()
24        .context("failed to resolve workspace root")?
25        .canonicalize()
26        .context("failed to canonicalize workspace root")?;
27    let safe = normalize_under_root(&root, path)?;
28    let source = fs::read_to_string(&safe)
29        .with_context(|| format!("failed to read flow definition at {}", safe.display()))?;
30
31    let bundle = load_and_validate_bundle(&source, Some(&safe)).with_context(|| {
32        format!(
33            "flow validation failed for {} using greentic-flow",
34            safe.display()
35        )
36    })?;
37
38    let serialized = if compact_json {
39        serde_json::to_string(&bundle)?
40    } else {
41        serde_json::to_string_pretty(&bundle)?
42    };
43
44    println!("{serialized}");
45    Ok(())
46}
47
48pub fn render_config_flow_yaml(config_flow_id: &str, graph: &JsonValue) -> Result<String> {
49    let mut graph_obj = graph
50        .as_object()
51        .cloned()
52        .ok_or_else(|| anyhow!("config flow `{config_flow_id}` graph is not an object"))?;
53    let ty = graph_obj
54        .entry("type".to_string())
55        .or_insert(JsonValue::String(DEFAULT_CONFIG_FLOW_TYPE.to_string()));
56    if !ty.is_string() {
57        *ty = JsonValue::String(DEFAULT_CONFIG_FLOW_TYPE.to_string());
58    }
59    let normalized = JsonValue::Object(graph_obj);
60    serde_yaml::to_string(&normalized).context("failed to render config flow graph to YAML")
61}
62
63pub fn run_add_step(args: FlowAddStepArgs) -> Result<()> {
64    let manifest_path = args
65        .manifest
66        .clone()
67        .unwrap_or_else(|| PathBuf::from("component.manifest.json"));
68    if !manifest_path.exists() {
69        bail!(
70            "component.manifest.json not found at {}. Use --manifest to point to the manifest file.",
71            manifest_path.display()
72        );
73    }
74    let manifest_raw = std::fs::read_to_string(&manifest_path)
75        .with_context(|| format!("failed to read {}", manifest_path.display()))?;
76    let manifest: ComponentManifest = serde_json::from_str(&manifest_raw).with_context(|| {
77        format!(
78            "failed to parse component manifest JSON at {}",
79            manifest_path.display()
80        )
81    })?;
82
83    let config_flow_id = match args.mode {
84        Some(ConfigFlowModeArg::Custom) => "custom".to_string(),
85        Some(ConfigFlowModeArg::Default) => "default".to_string(),
86        None => args.flow.clone(),
87    };
88    let config_flow_key = FlowId::from_str(&config_flow_id).map_err(|_| {
89        anyhow!(
90            "invalid flow identifier `{}`; flow ids must be valid FlowId strings",
91            config_flow_id
92        )
93    })?;
94    let Some(config_flow) = manifest.dev_flows.get(&config_flow_key) else {
95        bail!(
96            "Flow '{}' is missing from manifest.dev_flows. Run `greentic-component flow update` to regenerate config flows.",
97            config_flow_id
98        );
99    };
100
101    let coord = args
102        .coordinate
103        .ok_or_else(|| anyhow!("component coordinate is required (pass --coordinate)"))?;
104
105    // Ensure the component is available locally (fetch if needed).
106    let _bundle_dir = resolve_component_bundle(&coord, args.profile.as_deref())?;
107
108    // Render the dev flow graph to YAML so the existing runner can consume it.
109    let config_flow_yaml = render_config_flow_yaml(&config_flow_id, &config_flow.graph)?;
110    let mut temp_flow =
111        NamedTempFile::new().context("failed to create temporary config flow file")?;
112    temp_flow
113        .write_all(config_flow_yaml.as_bytes())
114        .context("failed to write temporary config flow")?;
115    temp_flow.flush()?;
116
117    let pack_flow_path = PathBuf::from("flows").join(format!("{}.ygtc", args.flow_id));
118    if !pack_flow_path.exists() {
119        bail!(
120            "Pack flow '{}' not found at {}",
121            args.flow_id,
122            pack_flow_path.display()
123        );
124    }
125    let pack_flow_raw = std::fs::read_to_string(&pack_flow_path)
126        .with_context(|| format!("failed to read pack flow {}", pack_flow_path.display()))?;
127    let mut pack_flow_json: JsonValue = serde_yaml::from_str(&pack_flow_raw)
128        .with_context(|| format!("invalid YAML in {}", pack_flow_path.display()))?;
129
130    let output = crate::pack_run::run_config_flow(temp_flow.path())
131        .with_context(|| format!("failed to run config flow {}", config_flow_id))?;
132    let (node_id, mut node) = parse_config_flow_output(&output)?;
133    let after = args
134        .after
135        .clone()
136        .or_else(|| prompt_routing_target(&pack_flow_json));
137    if let Some(after) = after.as_deref() {
138        patch_placeholder_routing(&mut node, after);
139    }
140
141    let graph_obj = pack_flow_json
142        .as_object_mut()
143        .ok_or_else(|| anyhow!("pack flow {} is not a mapping", pack_flow_path.display()))?;
144
145    // Update target pack flow JSON
146    let nodes = graph_obj
147        .get_mut("nodes")
148        .and_then(|n| n.as_object_mut())
149        .ok_or_else(|| anyhow!("flow `{}` missing nodes map", args.flow_id))?;
150    nodes.insert(node_id.clone(), node);
151
152    if let Some(after) = args.after {
153        append_routing(graph_obj, &after, &node_id)?;
154    } else if let Some(after) = after.as_deref() {
155        append_routing(graph_obj, after, &node_id)?;
156    }
157
158    let rendered =
159        serde_yaml::to_string(&pack_flow_json).context("failed to render updated pack flow")?;
160    std::fs::write(&pack_flow_path, rendered)
161        .with_context(|| format!("failed to write {}", pack_flow_path.display()))?;
162
163    println!(
164        "Added node `{}` from config flow {} to {}",
165        node_id,
166        config_flow_id,
167        pack_flow_path.display()
168    );
169    Ok(())
170}
171
172fn prompt_routing_target(flow_doc: &JsonValue) -> Option<String> {
173    if !io::stdout().is_terminal() {
174        return None;
175    }
176    let nodes = flow_doc
177        .as_object()
178        .and_then(|m| m.get("nodes"))
179        .and_then(|n| n.as_object())?;
180    let mut keys: Vec<String> = nodes.keys().cloned().collect();
181    keys.sort();
182    if keys.is_empty() {
183        return None;
184    }
185
186    println!("Select where to route from (empty to skip):");
187    for (idx, key) in keys.iter().enumerate() {
188        println!("  {}) {}", idx + 1, key);
189    }
190    print!("Choice: ");
191    let _ = io::stdout().flush();
192    let mut buf = String::new();
193    if io::stdin().read_line(&mut buf).is_err() {
194        return None;
195    }
196    let choice = buf.trim();
197    if choice.is_empty() {
198        return None;
199    }
200    if let Ok(idx) = choice.parse::<usize>()
201        && idx >= 1
202        && idx <= keys.len()
203    {
204        return Some(keys[idx - 1].clone());
205    }
206    None
207}
208
209fn patch_placeholder_routing(node: &mut JsonValue, next: &str) {
210    let Some(map) = node.as_object_mut() else {
211        return;
212    };
213    let Some(routing) = map.get_mut("routing") else {
214        return;
215    };
216    let Some(routes) = routing.as_array_mut() else {
217        return;
218    };
219    for entry in routes.iter_mut() {
220        if let Some(JsonValue::String(to)) =
221            entry.as_object_mut().and_then(|route| route.get_mut("to"))
222            && to == "NEXT_NODE_PLACEHOLDER"
223        {
224            *to = next.to_string();
225        }
226    }
227}
228
229fn resolve_component_bundle(coordinate: &str, profile: Option<&str>) -> Result<PathBuf> {
230    let path = PathBuf::from_str(coordinate).unwrap_or_default();
231    if path.exists() {
232        return Ok(path);
233    }
234    let dir = run_component_add(coordinate, profile, PackInitIntent::Dev)?;
235    Ok(dir)
236}
237
238pub fn parse_config_flow_output(output: &str) -> Result<(String, JsonValue)> {
239    let value: JsonValue =
240        serde_json::from_str(output).context("config flow output is not valid JSON")?;
241    let obj = value
242        .as_object()
243        .ok_or_else(|| anyhow!("config flow output must be a JSON object"))?;
244    let node_id = obj
245        .get("node_id")
246        .and_then(|v| v.as_str())
247        .ok_or_else(|| anyhow!("config flow output missing node_id"))?
248        .to_string();
249    let node = obj
250        .get("node")
251        .ok_or_else(|| anyhow!("config flow output missing node"))?
252        .clone();
253    if !node.is_object() {
254        bail!("config flow output node must be an object");
255    }
256    Ok((node_id, node))
257}
258
259fn append_routing(
260    flow_doc: &mut serde_json::Map<String, JsonValue>,
261    from_node: &str,
262    to_node: &str,
263) -> Result<()> {
264    let nodes = flow_doc
265        .get_mut("nodes")
266        .and_then(|n| n.as_object_mut())
267        .ok_or_else(|| anyhow!("flow missing nodes map"))?;
268    let Some(node) = nodes.get_mut(from_node) else {
269        bail!("node `{from_node}` not found for routing update");
270    };
271    let mapping = node
272        .as_object_mut()
273        .ok_or_else(|| anyhow!("node `{from_node}` is not an object"))?;
274    let routing = mapping
275        .entry("routing")
276        .or_insert(JsonValue::Array(Vec::new()));
277    let seq = routing
278        .as_array_mut()
279        .ok_or_else(|| anyhow!("routing for `{from_node}` is not a list"))?;
280    let mut entry = serde_json::Map::new();
281    entry.insert("to".to_string(), JsonValue::String(to_node.to_string()));
282    seq.push(JsonValue::Object(entry));
283    Ok(())
284}