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 let _bundle_dir = resolve_component_bundle(&coord, args.profile.as_deref())?;
107
108 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 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}