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 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 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}