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 Update(FlowUpdateArgs),
25}
26
27#[derive(Args, Debug, Clone)]
28pub struct FlowUpdateArgs {
29 #[arg(long = "manifest", value_name = "PATH", default_value = DEFAULT_MANIFEST)]
31 pub manifest: PathBuf,
32 #[arg(long = "no-infer-config")]
34 pub no_infer_config: bool,
35 #[arg(long = "no-write-schema")]
37 pub no_write_schema: bool,
38 #[arg(long = "force-write-schema")]
40 pub force_write_schema: bool,
41 #[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}