greentic_component/cmd/
flow.rs

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 config flows (default/custom) from component.manifest.json
26    Scaffold(FlowScaffoldArgs),
27}
28
29#[derive(Args, Debug, Clone)]
30pub struct FlowScaffoldArgs {
31    /// Path to component.manifest.json (or directory containing it)
32    #[arg(long = "manifest", value_name = "PATH", default_value = DEFAULT_MANIFEST)]
33    pub manifest: PathBuf,
34    /// Overwrite existing flows without prompting
35    #[arg(long = "force")]
36    pub force: bool,
37    /// Skip config inference; fail if config_schema is missing
38    #[arg(long = "no-infer-config")]
39    pub no_infer_config: bool,
40    /// Do not write inferred config_schema back to the manifest
41    #[arg(long = "no-write-schema")]
42    pub no_write_schema: bool,
43    /// Overwrite existing config_schema with inferred schema
44    #[arg(long = "force-write-schema")]
45    pub force_write_schema: bool,
46    /// Skip schema validation
47    #[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}