greentic_dev/
flow_cmd.rs

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