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_id, 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 =
406 render_emit_template(component_id, component_name, operation, emit_field_values);
407
408 let mut nodes = BTreeMap::new();
409 nodes.insert("ask_config".to_string(), JsonValue::Object(ask_node));
410 nodes.insert(
411 "emit_config".to_string(),
412 json!({ "template": emit_template }),
413 );
414
415 let doc = FlowDocument {
416 id: format!("{component_id}.custom"),
417 kind: DEFAULT_KIND.to_string(),
418 description: format!("Auto-generated custom config for {component_id}"),
419 nodes,
420 };
421
422 flow_to_value(&doc)
423}
424
425fn render_emit_template(
426 component_id: &str,
427 component_name: &str,
428 operation: &str,
429 fields: Vec<EmitField>,
430) -> String {
431 let mut lines = Vec::new();
432 lines.push("{".to_string());
433 lines.push(format!(" \"node_id\": \"{component_name}\","));
434 lines.push(" \"node\": {".to_string());
435 lines.push(format!(" \"{COMPONENT_EXEC_KIND}\": {{"));
436 lines.push(format!(" \"component\": \"{component_id}\","));
437 lines.push(format!(" \"operation\": \"{operation}\","));
438 lines.push(" \"input\": {".to_string());
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 lines.push(" }".to_string());
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
458pub(crate) fn manifest_component_id(manifest: &JsonValue) -> Result<&str> {
459 manifest
460 .get("id")
461 .and_then(|value| value.as_str())
462 .ok_or_else(|| anyhow!("component.manifest.json must contain a string `id` field"))
463}
464
465fn manifest_component_name(manifest: &JsonValue) -> Result<&str> {
466 manifest
467 .get("name")
468 .and_then(|value| value.as_str())
469 .ok_or_else(|| anyhow!("component.manifest.json must contain a string `name` field"))
470}
471
472fn resolve_node_kind(manifest: &JsonValue) -> Result<&'static str> {
473 let requested = manifest
474 .get("mode")
475 .or_else(|| manifest.get("kind"))
476 .and_then(|value| value.as_str());
477 let resolved = requested.unwrap_or(COMPONENT_EXEC_KIND);
478 if resolved == "tool" {
479 bail!("mode/kind `tool` is no longer supported for config flows");
480 }
481 if resolved != COMPONENT_EXEC_KIND {
482 bail!(
483 "unsupported config flow node kind `{resolved}`; allowed kinds: {COMPONENT_EXEC_KIND}"
484 );
485 }
486 Ok(COMPONENT_EXEC_KIND)
487}
488
489pub(crate) fn resolve_operation(manifest: &JsonValue, component_id: &str) -> Result<String> {
490 let missing_msg = || {
491 anyhow!(
492 "Component {component_id} has no operations; add at least one operation (e.g. handle_message)"
493 )
494 };
495 let operations_value = manifest.get("operations").ok_or_else(missing_msg)?;
496 let operations_array = operations_value
497 .as_array()
498 .ok_or_else(|| anyhow!("`operations` must be an array of objects"))?;
499 let mut operations = Vec::new();
500 for entry in operations_array {
501 let op = entry
502 .as_object()
503 .ok_or_else(|| anyhow!("`operations` entries must be objects"))?;
504 let name = op
505 .get("name")
506 .and_then(|value| value.as_str())
507 .ok_or_else(|| anyhow!("`operations` entries must include a string `name` field"))?;
508 if name.trim().is_empty() {
509 return Err(missing_msg());
510 }
511 let input_schema = op.get("input_schema").ok_or_else(|| {
512 anyhow!("`operations` entries must include input_schema and output_schema")
513 })?;
514 let output_schema = op.get("output_schema").ok_or_else(|| {
515 anyhow!("`operations` entries must include input_schema and output_schema")
516 })?;
517 if !input_schema.is_object() || !output_schema.is_object() {
518 return Err(anyhow!(
519 "`operations` input_schema/output_schema must be objects"
520 ));
521 }
522 operations.push(name.to_string());
523 }
524 if operations.is_empty() {
525 return Err(missing_msg());
526 }
527
528 let default_operation = manifest
529 .get("default_operation")
530 .and_then(|value| value.as_str());
531 let chosen = if let Some(default) = default_operation {
532 if default.trim().is_empty() {
533 return Err(anyhow!("default_operation cannot be empty"));
534 }
535 if operations.iter().any(|op| op == default) {
536 default.to_string()
537 } else {
538 return Err(anyhow!(
539 "default_operation `{default}` must match one of the declared operations"
540 ));
541 }
542 } else if operations.len() == 1 {
543 operations[0].clone()
544 } else {
545 return Err(anyhow!(
546 "Component {component_id} declares multiple operations {:?}; set `default_operation` to pick one",
547 operations
548 ));
549 };
550 Ok(chosen)
551}
552
553struct EmitField {
554 name: String,
555 value: EmitFieldValue,
556}
557
558enum EmitFieldValue {
559 Literal(String),
560 StateQuoted(String),
561 StateRaw(String),
562}
563
564impl EmitFieldValue {
565 fn render(&self) -> String {
566 match self {
567 EmitFieldValue::Literal(value) => value.clone(),
568 EmitFieldValue::StateQuoted(name) => format!("\"{{{{state.{name}}}}}\""),
569 EmitFieldValue::StateRaw(name) => format!("{{{{state.{name}}}}}"),
570 }
571 }
572}
573
574#[derive(Serialize)]
575struct FlowDocument {
576 id: String,
577 kind: String,
578 description: String,
579 nodes: BTreeMap<String, JsonValue>,
580}
581
582fn flow_to_value(doc: &FlowDocument) -> Result<JsonValue> {
583 serde_json::to_value(doc).context("failed to render flow to JSON")
584}
585
586fn write_manifest(manifest_path: &PathBuf, manifest: &JsonValue) -> Result<()> {
587 let formatted = serde_json::to_string_pretty(manifest)?;
588 fs::write(manifest_path, formatted + "\n")
589 .with_context(|| format!("failed to write {}", manifest_path.display()))
590}
591
592fn load_operation_input_schema(manifest_path: &Path, manifest: &JsonValue) -> Result<JsonValue> {
593 let manifest_dir = manifest_path
594 .parent()
595 .ok_or_else(|| anyhow!("manifest path has no parent: {}", manifest_path.display()))?;
596 let schema_path = manifest
597 .get("schemas")
598 .and_then(|entry| entry.get("input"))
599 .and_then(|value| value.as_str())
600 .map(|path| manifest_dir.join(path))
601 .unwrap_or_else(|| manifest_dir.join("schemas/io/input.schema.json"));
602 let text = fs::read_to_string(&schema_path)
603 .with_context(|| format!("failed to read {}", schema_path.display()))?;
604 serde_json::from_str(&text)
605 .with_context(|| format!("failed to parse {}", schema_path.display()))
606}
607
608fn compute_default_fields(fields: &[ConfigField]) -> Result<Vec<EmitField>> {
609 let mut emit_fields = Vec::new();
610 for field in fields {
611 if field.required {
612 if let Some(default_value) = &field.default_value {
613 let literal = serde_json::to_string(default_value)
614 .context("failed to serialize default value")?;
615 emit_fields.push(EmitField {
616 name: field.name.clone(),
617 value: EmitFieldValue::Literal(literal),
618 });
619 } else {
620 bail!(
621 "Required field {} has no default; cannot generate default dev_flow. Provide defaults or use custom mode.",
622 field.name
623 );
624 }
625 }
626 }
627 Ok(emit_fields)
628}