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