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::{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 config flows (default/custom) from component.manifest.json
22    Scaffold(FlowScaffoldArgs),
23}
24
25#[derive(Args, Debug, Clone)]
26pub struct FlowScaffoldArgs {
27    /// Path to component.manifest.json (or directory containing it)
28    #[arg(long = "manifest", value_name = "PATH", default_value = DEFAULT_MANIFEST)]
29    pub manifest: PathBuf,
30    /// Overwrite existing flows without prompting
31    #[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}