1#![cfg(feature = "cli")]
2
3use std::collections::{BTreeMap, HashSet};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result, anyhow, bail};
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_KIND: &str = "component-config";
19pub(crate) const COMPONENT_EXEC_KIND: &str = "component.exec";
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 = manifest_component_id(&config.manifest)?;
108 let component_name = manifest_component_name(&config.manifest)?;
109 let _node_kind = resolve_node_kind(&config.manifest)?;
110 let operation = resolve_operation(&config.manifest, component_id)?;
111 let input_schema = load_operation_input_schema(&config.manifest_path, &config.manifest)?;
112
113 validate_config_schema(&config.schema)
114 .map_err(|err| anyhow!("config_schema failed validation: {err}"))?;
115
116 let fields = collect_fields(&input_schema)?;
117
118 let default_flow = render_default_flow(component_id, component_name, &operation, &fields)?;
119 let custom_flow = render_custom_flow(component_id, component_name, &operation, &fields)?;
120
121 let mut manifest = config.manifest.clone();
122 let manifest_obj = manifest
123 .as_object_mut()
124 .ok_or_else(|| anyhow!("manifest must be a JSON object"))?;
125 let dev_flows_entry = manifest_obj
126 .entry("dev_flows")
127 .or_insert_with(|| JsonValue::Object(JsonMap::new()));
128 let dev_flows = dev_flows_entry
129 .as_object_mut()
130 .ok_or_else(|| anyhow!("dev_flows must be an object"))?;
131
132 let mut merged = BTreeMap::new();
133 for (key, value) in dev_flows.iter() {
134 if key != "custom" && key != "default" {
135 merged.insert(key.clone(), value.clone());
136 }
137 }
138 merged.insert(
139 "custom".to_string(),
140 json!({
141 "format": "flow-ir-json",
142 "graph": custom_flow,
143 }),
144 );
145 merged.insert(
146 "default".to_string(),
147 json!({
148 "format": "flow-ir-json",
149 "graph": default_flow,
150 }),
151 );
152
153 *dev_flows = merged.into_iter().collect();
154
155 Ok(FlowUpdateOutcome {
156 manifest,
157 result: FlowUpdateResult {
158 default_updated: true,
159 custom_updated: true,
160 },
161 })
162}
163
164fn collect_fields(config_schema: &JsonValue) -> Result<Vec<ConfigField>> {
165 let properties = config_schema
166 .get("properties")
167 .and_then(|value| value.as_object())
168 .ok_or_else(|| anyhow!("config_schema.properties must be an object"))?;
169 let required = config_schema
170 .get("required")
171 .and_then(|value| value.as_array())
172 .map(|values| {
173 values
174 .iter()
175 .filter_map(|v| v.as_str().map(str::to_string))
176 .collect::<HashSet<String>>()
177 })
178 .unwrap_or_default();
179
180 let mut fields = properties
181 .iter()
182 .map(|(name, schema)| ConfigField::from_schema(name, schema, required.contains(name)))
183 .collect::<Vec<_>>();
184 fields.sort_by(|a, b| a.name.cmp(&b.name));
185 Ok(fields)
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189enum FieldType {
190 String,
191 Number,
192 Integer,
193 Boolean,
194 Unknown,
195}
196
197impl FieldType {
198 fn from_schema(schema: &JsonValue) -> Self {
199 let type_value = schema.get("type");
200 match type_value {
201 Some(JsonValue::String(value)) => Self::from_type_str(value),
202 Some(JsonValue::Array(types)) => types
203 .iter()
204 .filter_map(|v| v.as_str())
205 .find_map(|value| {
206 let field_type = Self::from_type_str(value);
207 (field_type != FieldType::Unknown && value != "null").then_some(field_type)
208 })
209 .unwrap_or(FieldType::Unknown),
210 _ => FieldType::Unknown,
211 }
212 }
213
214 fn from_type_str(value: &str) -> Self {
215 match value {
216 "string" => FieldType::String,
217 "number" => FieldType::Number,
218 "integer" => FieldType::Integer,
219 "boolean" => FieldType::Boolean,
220 _ => FieldType::Unknown,
221 }
222 }
223}
224
225#[derive(Debug, Clone)]
226struct ConfigField {
227 name: String,
228 description: Option<String>,
229 field_type: FieldType,
230 enum_options: Vec<String>,
231 default_value: Option<JsonValue>,
232 required: bool,
233 hidden: bool,
234}
235
236impl ConfigField {
237 fn from_schema(name: &str, schema: &JsonValue, required: bool) -> Self {
238 let field_type = FieldType::from_schema(schema);
239 let description = schema
240 .get("description")
241 .and_then(|value| value.as_str())
242 .map(str::to_string);
243 let default_value = schema.get("default").cloned();
244 let enum_options = schema
245 .get("enum")
246 .and_then(|value| value.as_array())
247 .map(|values| {
248 values
249 .iter()
250 .map(|entry| {
251 entry
252 .as_str()
253 .map(str::to_string)
254 .unwrap_or_else(|| entry.to_string())
255 })
256 .collect::<Vec<_>>()
257 })
258 .unwrap_or_default();
259 let hidden = schema
260 .get("x_flow_hidden")
261 .and_then(|value| value.as_bool())
262 .unwrap_or(false);
263 Self {
264 name: name.to_string(),
265 description,
266 field_type,
267 enum_options,
268 default_value,
269 required,
270 hidden,
271 }
272 }
273
274 fn prompt(&self) -> String {
275 if let Some(desc) = &self.description {
276 return desc.clone();
277 }
278 humanize(&self.name)
279 }
280
281 fn question_type(&self) -> &'static str {
282 if !self.enum_options.is_empty() {
283 "enum"
284 } else {
285 match self.field_type {
286 FieldType::String => "string",
287 FieldType::Number | FieldType::Integer => "number",
288 FieldType::Boolean => "boolean",
289 FieldType::Unknown => "string",
290 }
291 }
292 }
293
294 fn is_string_like(&self) -> bool {
295 !self.enum_options.is_empty()
296 || matches!(self.field_type, FieldType::String | FieldType::Unknown)
297 }
298}
299
300fn humanize(raw: &str) -> String {
301 let mut result = raw
302 .replace(['_', '-'], " ")
303 .split_whitespace()
304 .map(|word| {
305 let mut chars = word.chars();
306 match chars.next() {
307 Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()),
308 None => String::new(),
309 }
310 })
311 .collect::<Vec<_>>()
312 .join(" ");
313 if !result.ends_with(':') && !result.is_empty() {
314 result.push(':');
315 }
316 result
317}
318
319fn render_default_flow(
320 component_id: &str,
321 component_name: &str,
322 operation: &str,
323 fields: &[ConfigField],
324) -> Result<JsonValue> {
325 let field_values = compute_default_fields(fields)?;
326
327 let emit_template = render_emit_template(component_name, operation, field_values);
328 let mut nodes = BTreeMap::new();
329 nodes.insert(
330 "emit_config".to_string(),
331 json!({
332 "template": emit_template,
333 }),
334 );
335
336 let doc = FlowDocument {
337 id: format!("{component_id}.default"),
338 kind: DEFAULT_KIND.to_string(),
339 description: format!("Auto-generated default config for {component_id}"),
340 nodes,
341 };
342
343 flow_to_value(&doc)
344}
345
346fn render_custom_flow(
347 component_id: &str,
348 component_name: &str,
349 operation: &str,
350 fields: &[ConfigField],
351) -> Result<JsonValue> {
352 let visible_fields = fields
353 .iter()
354 .filter(|field| !field.hidden)
355 .collect::<Vec<_>>();
356
357 let mut question_fields = Vec::new();
358 for field in &visible_fields {
359 let mut mapping = JsonMap::new();
360 mapping.insert("id".into(), JsonValue::String(field.name.clone()));
361 mapping.insert("prompt".into(), JsonValue::String(field.prompt()));
362 mapping.insert(
363 "type".into(),
364 JsonValue::String(field.question_type().to_string()),
365 );
366 if !field.enum_options.is_empty() {
367 mapping.insert(
368 "options".into(),
369 JsonValue::Array(
370 field
371 .enum_options
372 .iter()
373 .map(|value| JsonValue::String(value.clone()))
374 .collect(),
375 ),
376 );
377 }
378 if let Some(default_value) = &field.default_value {
379 mapping.insert("default".into(), default_value.clone());
380 }
381 question_fields.push(JsonValue::Object(mapping));
382 }
383
384 let mut questions_inner = JsonMap::new();
385 questions_inner.insert("fields".into(), JsonValue::Array(question_fields));
386
387 let mut ask_node = JsonMap::new();
388 ask_node.insert("questions".into(), JsonValue::Object(questions_inner));
389 ask_node.insert(
390 "routing".into(),
391 JsonValue::Array(vec![json!({ "to": "emit_config" })]),
392 );
393
394 let emit_field_values = visible_fields
395 .iter()
396 .map(|field| EmitField {
397 name: field.name.clone(),
398 value: if field.is_string_like() {
399 EmitFieldValue::StateQuoted(field.name.clone())
400 } else {
401 EmitFieldValue::StateRaw(field.name.clone())
402 },
403 })
404 .collect::<Vec<_>>();
405 let emit_template = render_emit_template(component_name, operation, emit_field_values);
406
407 let mut nodes = BTreeMap::new();
408 nodes.insert("ask_config".to_string(), JsonValue::Object(ask_node));
409 nodes.insert(
410 "emit_config".to_string(),
411 json!({ "template": emit_template }),
412 );
413
414 let doc = FlowDocument {
415 id: format!("{component_id}.custom"),
416 kind: DEFAULT_KIND.to_string(),
417 description: format!("Auto-generated custom config for {component_id}"),
418 nodes,
419 };
420
421 flow_to_value(&doc)
422}
423
424fn render_emit_template(component_name: &str, operation: &str, fields: Vec<EmitField>) -> String {
425 let mut lines = Vec::new();
426 lines.push("{".to_string());
427 lines.push(format!(" \"node_id\": \"{component_name}\","));
428 lines.push(" \"node\": {".to_string());
429 lines.push(format!(" \"{operation}\": {{"));
430 lines.push(" \"input\": {".to_string());
431 for (idx, field) in fields.iter().enumerate() {
432 let suffix = if idx + 1 == fields.len() { "" } else { "," };
433 lines.push(format!(
434 " \"{}\": {}{}",
435 field.name,
436 field.value.render(),
437 suffix
438 ));
439 }
440 lines.push(" }".to_string());
441 lines.push(" },".to_string());
442 lines.push(" \"routing\": [".to_string());
443 lines.push(" { \"to\": \"NEXT_NODE_PLACEHOLDER\" }".to_string());
444 lines.push(" ]".to_string());
445 lines.push(" }".to_string());
446 lines.push("}".to_string());
447 lines.join("\n")
448}
449
450pub(crate) fn manifest_component_id(manifest: &JsonValue) -> Result<&str> {
451 manifest
452 .get("id")
453 .and_then(|value| value.as_str())
454 .ok_or_else(|| anyhow!("component.manifest.json must contain a string `id` field"))
455}
456
457fn manifest_component_name(manifest: &JsonValue) -> Result<&str> {
458 manifest
459 .get("name")
460 .and_then(|value| value.as_str())
461 .ok_or_else(|| anyhow!("component.manifest.json must contain a string `name` field"))
462}
463
464fn resolve_node_kind(manifest: &JsonValue) -> Result<&'static str> {
465 let requested = manifest
466 .get("mode")
467 .or_else(|| manifest.get("kind"))
468 .and_then(|value| value.as_str());
469 let resolved = requested.unwrap_or(COMPONENT_EXEC_KIND);
470 if resolved == "tool" {
471 bail!("mode/kind `tool` is no longer supported for config flows");
472 }
473 if resolved != COMPONENT_EXEC_KIND {
474 bail!(
475 "unsupported config flow node kind `{resolved}`; allowed kinds: {COMPONENT_EXEC_KIND}"
476 );
477 }
478 Ok(COMPONENT_EXEC_KIND)
479}
480
481pub(crate) fn resolve_operation(manifest: &JsonValue, component_id: &str) -> Result<String> {
482 let missing_msg = || {
483 anyhow!(
484 "Component {component_id} has no operations; add at least one operation (e.g. handle_message)"
485 )
486 };
487 let operations_value = manifest.get("operations").ok_or_else(missing_msg)?;
488 let operations_array = operations_value
489 .as_array()
490 .ok_or_else(|| anyhow!("`operations` must be an array of objects"))?;
491 let mut operations = Vec::new();
492 for entry in operations_array {
493 let op = entry
494 .as_object()
495 .ok_or_else(|| anyhow!("`operations` entries must be objects"))?;
496 let name = op
497 .get("name")
498 .and_then(|value| value.as_str())
499 .ok_or_else(|| anyhow!("`operations` entries must include a string `name` field"))?;
500 if name.trim().is_empty() {
501 return Err(missing_msg());
502 }
503 let input_schema = op.get("input_schema").ok_or_else(|| {
504 anyhow!("`operations` entries must include input_schema and output_schema")
505 })?;
506 let output_schema = op.get("output_schema").ok_or_else(|| {
507 anyhow!("`operations` entries must include input_schema and output_schema")
508 })?;
509 if !input_schema.is_object() || !output_schema.is_object() {
510 return Err(anyhow!(
511 "`operations` input_schema/output_schema must be objects"
512 ));
513 }
514 operations.push(name.to_string());
515 }
516 if operations.is_empty() {
517 return Err(missing_msg());
518 }
519
520 let default_operation = manifest
521 .get("default_operation")
522 .and_then(|value| value.as_str());
523 let chosen = if let Some(default) = default_operation {
524 if default.trim().is_empty() {
525 return Err(anyhow!("default_operation cannot be empty"));
526 }
527 if operations.iter().any(|op| op == default) {
528 default.to_string()
529 } else {
530 return Err(anyhow!(
531 "default_operation `{default}` must match one of the declared operations"
532 ));
533 }
534 } else if operations.len() == 1 {
535 operations[0].clone()
536 } else {
537 return Err(anyhow!(
538 "Component {component_id} declares multiple operations {:?}; set `default_operation` to pick one",
539 operations
540 ));
541 };
542 Ok(chosen)
543}
544
545struct EmitField {
546 name: String,
547 value: EmitFieldValue,
548}
549
550enum EmitFieldValue {
551 Literal(String),
552 StateQuoted(String),
553 StateRaw(String),
554}
555
556impl EmitFieldValue {
557 fn render(&self) -> String {
558 match self {
559 EmitFieldValue::Literal(value) => value.clone(),
560 EmitFieldValue::StateQuoted(name) => format!("\"{{{{state.{name}}}}}\""),
561 EmitFieldValue::StateRaw(name) => format!("{{{{state.{name}}}}}"),
562 }
563 }
564}
565
566#[derive(Serialize)]
567struct FlowDocument {
568 id: String,
569 kind: String,
570 description: String,
571 nodes: BTreeMap<String, JsonValue>,
572}
573
574fn flow_to_value(doc: &FlowDocument) -> Result<JsonValue> {
575 serde_json::to_value(doc).context("failed to render flow to JSON")
576}
577
578fn write_manifest(manifest_path: &PathBuf, manifest: &JsonValue) -> Result<()> {
579 let formatted = serde_json::to_string_pretty(manifest)?;
580 fs::write(manifest_path, formatted + "\n")
581 .with_context(|| format!("failed to write {}", manifest_path.display()))
582}
583
584fn load_operation_input_schema(manifest_path: &Path, manifest: &JsonValue) -> Result<JsonValue> {
585 let manifest_dir = manifest_path
586 .parent()
587 .ok_or_else(|| anyhow!("manifest path has no parent: {}", manifest_path.display()))?;
588 let schema_path = manifest
589 .get("schemas")
590 .and_then(|entry| entry.get("input"))
591 .and_then(|value| value.as_str())
592 .map(|path| manifest_dir.join(path))
593 .unwrap_or_else(|| manifest_dir.join("schemas/io/input.schema.json"));
594 let text = fs::read_to_string(&schema_path)
595 .with_context(|| format!("failed to read {}", schema_path.display()))?;
596 serde_json::from_str(&text)
597 .with_context(|| format!("failed to parse {}", schema_path.display()))
598}
599
600fn compute_default_fields(fields: &[ConfigField]) -> Result<Vec<EmitField>> {
601 let mut emit_fields = Vec::new();
602 for field in fields {
603 if field.required {
604 if let Some(default_value) = &field.default_value {
605 let literal = serde_json::to_string(default_value)
606 .context("failed to serialize default value")?;
607 emit_fields.push(EmitField {
608 name: field.name.clone(),
609 value: EmitFieldValue::Literal(literal),
610 });
611 } else {
612 bail!(
613 "Required field {} has no default; cannot generate default dev_flow. Provide defaults or use custom mode.",
614 field.name
615 );
616 }
617 }
618 }
619 Ok(emit_fields)
620}