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