greentic_component/cmd/
flow.rs

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