1#![cfg(feature = "cli")]
2
3use std::collections::{BTreeMap, HashSet};
4use std::fs;
5use std::io::{self, IsTerminal, Write};
6use std::path::{Path, PathBuf};
7
8use anyhow::{Context, Result, anyhow, bail};
9use clap::{Args, Subcommand};
10use component_manifest::validate_config_schema;
11use serde::Serialize;
12use serde_json::Value as JsonValue;
13use serde_yaml::{Mapping, Value as YamlValue};
14
15const DEFAULT_MANIFEST: &str = "component.manifest.json";
16const DEFAULT_NODE_ID: &str = "COMPONENT_STEP";
17const DEFAULT_KIND: &str = "component-config";
18
19#[derive(Subcommand, Debug, Clone)]
20pub enum FlowCommand {
21 Scaffold(FlowScaffoldArgs),
23}
24
25#[derive(Args, Debug, Clone)]
26pub struct FlowScaffoldArgs {
27 #[arg(long = "manifest", value_name = "PATH", default_value = DEFAULT_MANIFEST)]
29 pub manifest: PathBuf,
30 #[arg(long = "force")]
32 pub force: bool,
33}
34
35pub fn run(command: FlowCommand) -> Result<()> {
36 match command {
37 FlowCommand::Scaffold(args) => scaffold(args),
38 }
39}
40
41fn scaffold(args: FlowScaffoldArgs) -> Result<()> {
42 let manifest_path = resolve_manifest_path(&args.manifest);
43 let manifest_dir = manifest_path
44 .parent()
45 .ok_or_else(|| anyhow!("manifest path has no parent: {}", manifest_path.display()))?;
46 let manifest_raw = fs::read_to_string(&manifest_path)
47 .with_context(|| format!("failed to read {}", manifest_path.display()))?;
48 let manifest_json: JsonValue = serde_json::from_str(&manifest_raw)
49 .with_context(|| format!("failed to parse {}", manifest_path.display()))?;
50
51 let component_id = manifest_json
52 .get("id")
53 .and_then(|value| value.as_str())
54 .ok_or_else(|| anyhow!("component.manifest.json must contain a string `id` field"))?;
55 let mode = manifest_json
56 .get("mode")
57 .or_else(|| manifest_json.get("kind"))
58 .and_then(|value| value.as_str())
59 .unwrap_or("tool");
60 let config_schema = manifest_json
61 .get("config_schema")
62 .ok_or_else(|| anyhow!("component.manifest.json is missing `config_schema`"))?;
63 validate_config_schema(config_schema)
64 .map_err(|err| anyhow!("config_schema failed validation: {err}"))?;
65
66 let fields = collect_fields(config_schema)?;
67
68 let flows_dir = manifest_dir.join("flows");
69 fs::create_dir_all(&flows_dir).with_context(|| {
70 format!(
71 "failed to create flows directory at {}",
72 flows_dir.display()
73 )
74 })?;
75
76 let default_flow = render_default_flow(component_id, mode, &fields)?;
77 let default_path = flows_dir.join("default.ygtc");
78 let default_written = write_flow_file(&default_path, &default_flow, args.force)?;
79
80 let custom_flow = render_custom_flow(component_id, mode, &fields)?;
81 let custom_path = flows_dir.join("custom.ygtc");
82 let custom_written = write_flow_file(&custom_path, &custom_flow, args.force)?;
83
84 if !default_written && !custom_written {
85 println!("No flows written (existing files kept).");
86 } else {
87 if default_written {
88 println!("Wrote {}", default_path.display());
89 }
90 if custom_written {
91 println!("Wrote {}", custom_path.display());
92 }
93 }
94
95 Ok(())
96}
97
98fn resolve_manifest_path(path: &Path) -> PathBuf {
99 if path.is_dir() {
100 path.join(DEFAULT_MANIFEST)
101 } else {
102 path.to_path_buf()
103 }
104}
105
106fn write_flow_file(path: &Path, contents: &str, force: bool) -> Result<bool> {
107 if path.exists() && !confirm_overwrite(path, force)? {
108 return Ok(false);
109 }
110 fs::write(path, contents).with_context(|| format!("failed to write {}", path.display()))?;
111 Ok(true)
112}
113
114fn confirm_overwrite(path: &Path, force: bool) -> Result<bool> {
115 if force {
116 return Ok(true);
117 }
118 if !path.exists() {
119 return Ok(true);
120 }
121 if io::stdin().is_terminal() {
122 print!("{} already exists. Overwrite? [y/N]: ", path.display());
123 io::stdout().flush().ok();
124 let mut input = String::new();
125 io::stdin()
126 .read_line(&mut input)
127 .context("failed to read response")?;
128 let normalized = input.trim().to_ascii_lowercase();
129 Ok(normalized == "y" || normalized == "yes")
130 } else {
131 bail!(
132 "{} already exists; rerun with --force to overwrite",
133 path.display()
134 );
135 }
136}
137
138fn collect_fields(config_schema: &JsonValue) -> Result<Vec<ConfigField>> {
139 let properties = config_schema
140 .get("properties")
141 .and_then(|value| value.as_object())
142 .ok_or_else(|| anyhow!("config_schema.properties must be an object"))?;
143 let required = config_schema
144 .get("required")
145 .and_then(|value| value.as_array())
146 .map(|values| {
147 values
148 .iter()
149 .filter_map(|v| v.as_str().map(str::to_string))
150 .collect::<HashSet<String>>()
151 })
152 .unwrap_or_default();
153
154 let mut fields = properties
155 .iter()
156 .map(|(name, schema)| ConfigField::from_schema(name, schema, required.contains(name)))
157 .collect::<Vec<_>>();
158 fields.sort_by(|a, b| a.name.cmp(&b.name));
159 Ok(fields)
160}
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163enum FieldType {
164 String,
165 Number,
166 Integer,
167 Boolean,
168 Unknown,
169}
170
171impl FieldType {
172 fn from_schema(schema: &JsonValue) -> Self {
173 let type_value = schema.get("type");
174 match type_value {
175 Some(JsonValue::String(value)) => Self::from_type_str(value),
176 Some(JsonValue::Array(types)) => types
177 .iter()
178 .filter_map(|v| v.as_str())
179 .find_map(|value| {
180 let field_type = Self::from_type_str(value);
181 (field_type != FieldType::Unknown && value != "null").then_some(field_type)
182 })
183 .unwrap_or(FieldType::Unknown),
184 _ => FieldType::Unknown,
185 }
186 }
187
188 fn from_type_str(value: &str) -> Self {
189 match value {
190 "string" => FieldType::String,
191 "number" => FieldType::Number,
192 "integer" => FieldType::Integer,
193 "boolean" => FieldType::Boolean,
194 _ => FieldType::Unknown,
195 }
196 }
197}
198
199#[derive(Debug, Clone)]
200struct ConfigField {
201 name: String,
202 description: Option<String>,
203 field_type: FieldType,
204 enum_options: Vec<String>,
205 default_value: Option<JsonValue>,
206 required: bool,
207 hidden: bool,
208}
209
210impl ConfigField {
211 fn from_schema(name: &str, schema: &JsonValue, required: bool) -> Self {
212 let field_type = FieldType::from_schema(schema);
213 let description = schema
214 .get("description")
215 .and_then(|value| value.as_str())
216 .map(str::to_string);
217 let default_value = schema.get("default").cloned();
218 let enum_options = schema
219 .get("enum")
220 .and_then(|value| value.as_array())
221 .map(|values| {
222 values
223 .iter()
224 .map(|entry| {
225 entry
226 .as_str()
227 .map(str::to_string)
228 .unwrap_or_else(|| entry.to_string())
229 })
230 .collect::<Vec<_>>()
231 })
232 .unwrap_or_default();
233 let hidden = schema
234 .get("x_flow_hidden")
235 .and_then(|value| value.as_bool())
236 .unwrap_or(false);
237 Self {
238 name: name.to_string(),
239 description,
240 field_type,
241 enum_options,
242 default_value,
243 required,
244 hidden,
245 }
246 }
247
248 fn prompt(&self) -> String {
249 if let Some(desc) = &self.description {
250 return desc.clone();
251 }
252 humanize(&self.name)
253 }
254
255 fn question_type(&self) -> &'static str {
256 if !self.enum_options.is_empty() {
257 "enum"
258 } else {
259 match self.field_type {
260 FieldType::String => "string",
261 FieldType::Number | FieldType::Integer => "number",
262 FieldType::Boolean => "boolean",
263 FieldType::Unknown => "string",
264 }
265 }
266 }
267
268 fn is_string_like(&self) -> bool {
269 !self.enum_options.is_empty()
270 || matches!(self.field_type, FieldType::String | FieldType::Unknown)
271 }
272}
273
274fn humanize(raw: &str) -> String {
275 let mut result = raw
276 .replace(['_', '-'], " ")
277 .split_whitespace()
278 .map(|word| {
279 let mut chars = word.chars();
280 match chars.next() {
281 Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()),
282 None => String::new(),
283 }
284 })
285 .collect::<Vec<_>>()
286 .join(" ");
287 if !result.ends_with(':') && !result.is_empty() {
288 result.push(':');
289 }
290 result
291}
292
293fn render_default_flow(component_id: &str, mode: &str, fields: &[ConfigField]) -> Result<String> {
294 let required_with_defaults = fields
295 .iter()
296 .filter(|field| field.required && field.default_value.is_some())
297 .collect::<Vec<_>>();
298
299 let field_values = required_with_defaults
300 .iter()
301 .map(|field| {
302 let literal =
303 serde_json::to_string(field.default_value.as_ref().expect("filtered to Some"))
304 .expect("json serialize default");
305 EmitField {
306 name: field.name.clone(),
307 value: EmitFieldValue::Literal(literal),
308 }
309 })
310 .collect::<Vec<_>>();
311
312 let emit_template = render_emit_template(component_id, mode, field_values);
313 let mut emit_node = Mapping::new();
314 emit_node.insert(
315 YamlValue::String("template".into()),
316 YamlValue::String(emit_template),
317 );
318
319 let mut nodes = BTreeMap::new();
320 nodes.insert("emit_config".to_string(), YamlValue::Mapping(emit_node));
321
322 let doc = FlowDocument {
323 id: format!("{component_id}.default"),
324 kind: DEFAULT_KIND.to_string(),
325 description: format!("Auto-generated default config for {component_id}"),
326 nodes,
327 };
328
329 flow_to_string(&doc)
330}
331
332fn render_custom_flow(component_id: &str, mode: &str, fields: &[ConfigField]) -> Result<String> {
333 let visible_fields = fields
334 .iter()
335 .filter(|field| !field.hidden)
336 .collect::<Vec<_>>();
337
338 let mut question_fields = Vec::new();
339 for field in &visible_fields {
340 let mut mapping = Mapping::new();
341 mapping.insert(
342 YamlValue::String("id".into()),
343 YamlValue::String(field.name.clone()),
344 );
345 mapping.insert(
346 YamlValue::String("prompt".into()),
347 YamlValue::String(field.prompt()),
348 );
349 mapping.insert(
350 YamlValue::String("type".into()),
351 YamlValue::String(field.question_type().to_string()),
352 );
353 if !field.enum_options.is_empty() {
354 let options = field
355 .enum_options
356 .iter()
357 .map(|value| YamlValue::String(value.clone()))
358 .collect::<Vec<_>>();
359 mapping.insert(
360 YamlValue::String("options".into()),
361 YamlValue::Sequence(options),
362 );
363 }
364 if let Some(default_value) = &field.default_value {
365 mapping.insert(
366 YamlValue::String("default".into()),
367 serde_yaml::to_value(default_value.clone()).unwrap_or(YamlValue::Null),
368 );
369 }
370 question_fields.push(YamlValue::Mapping(mapping));
371 }
372
373 let mut questions_inner = Mapping::new();
374 questions_inner.insert(
375 YamlValue::String("fields".into()),
376 YamlValue::Sequence(question_fields),
377 );
378
379 let mut ask_node = Mapping::new();
380 ask_node.insert(
381 YamlValue::String("questions".into()),
382 YamlValue::Mapping(questions_inner),
383 );
384 ask_node.insert(
385 YamlValue::String("routing".into()),
386 YamlValue::Sequence(vec![{
387 let mut route = Mapping::new();
388 route.insert(
389 YamlValue::String("to".into()),
390 YamlValue::String("emit_config".into()),
391 );
392 YamlValue::Mapping(route)
393 }]),
394 );
395
396 let emit_field_values = visible_fields
397 .iter()
398 .map(|field| EmitField {
399 name: field.name.clone(),
400 value: if field.is_string_like() {
401 EmitFieldValue::StateQuoted(field.name.clone())
402 } else {
403 EmitFieldValue::StateRaw(field.name.clone())
404 },
405 })
406 .collect::<Vec<_>>();
407 let emit_template = render_emit_template(component_id, mode, emit_field_values);
408 let mut emit_node = Mapping::new();
409 emit_node.insert(
410 YamlValue::String("template".into()),
411 YamlValue::String(emit_template),
412 );
413
414 let mut nodes = BTreeMap::new();
415 nodes.insert("ask_config".to_string(), YamlValue::Mapping(ask_node));
416 nodes.insert("emit_config".to_string(), YamlValue::Mapping(emit_node));
417
418 let doc = FlowDocument {
419 id: format!("{component_id}.custom"),
420 kind: DEFAULT_KIND.to_string(),
421 description: format!("Auto-generated custom config for {component_id}"),
422 nodes,
423 };
424
425 flow_to_string(&doc)
426}
427
428fn render_emit_template(component_id: &str, mode: &str, fields: Vec<EmitField>) -> String {
429 let mut lines = Vec::new();
430 lines.push("{".to_string());
431 lines.push(format!(" \"node_id\": \"{DEFAULT_NODE_ID}\","));
432 lines.push(" \"node\": {".to_string());
433 lines.push(format!(" \"{mode}\": {{"));
434 lines.push(format!(
435 " \"component\": \"{component_id}\"{}",
436 if fields.is_empty() { "" } else { "," }
437 ));
438
439 for (idx, field) in fields.iter().enumerate() {
440 let suffix = if idx + 1 == fields.len() { "" } else { "," };
441 lines.push(format!(
442 " \"{}\": {}{}",
443 field.name,
444 field.value.render(),
445 suffix
446 ));
447 }
448
449 lines.push(" },".to_string());
450 lines.push(" \"routing\": [".to_string());
451 lines.push(" { \"to\": \"NEXT_NODE_PLACEHOLDER\" }".to_string());
452 lines.push(" ]".to_string());
453 lines.push(" }".to_string());
454 lines.push("}".to_string());
455 lines.join("\n")
456}
457
458struct EmitField {
459 name: String,
460 value: EmitFieldValue,
461}
462
463enum EmitFieldValue {
464 Literal(String),
465 StateQuoted(String),
466 StateRaw(String),
467}
468
469impl EmitFieldValue {
470 fn render(&self) -> String {
471 match self {
472 EmitFieldValue::Literal(value) => value.clone(),
473 EmitFieldValue::StateQuoted(name) => format!("\"{{{{state.{name}}}}}\""),
474 EmitFieldValue::StateRaw(name) => format!("{{{{state.{name}}}}}"),
475 }
476 }
477}
478
479#[derive(Serialize)]
480struct FlowDocument {
481 id: String,
482 kind: String,
483 description: String,
484 nodes: BTreeMap<String, YamlValue>,
485}
486
487fn flow_to_string(doc: &FlowDocument) -> Result<String> {
488 let mut yaml = serde_yaml::to_string(doc).context("failed to render YAML")?;
489 if yaml.starts_with("---\n") {
490 yaml = yaml.replacen("---\n", "", 1);
491 }
492 if !yaml.ends_with('\n') {
493 yaml.push('\n');
494 }
495 Ok(yaml)
496}