1#![cfg(feature = "cli")]
2
3use std::fs;
4use std::io::{self, IsTerminal, Write};
5use std::path::{Path, PathBuf};
6use std::process::Command;
7
8use anyhow::{Context, Result, anyhow, bail};
9use clap::{Args, Subcommand, ValueEnum};
10use greentic_qa_lib::QaLibError;
11use serde::{Deserialize, Serialize};
12use serde_json::{Map as JsonMap, Value as JsonValue, json};
13
14use crate::cmd::build::BuildArgs;
15use crate::cmd::doctor::{DoctorArgs, DoctorFormat};
16use crate::cmd::i18n;
17use crate::scaffold::config_schema::{ConfigSchemaInput, parse_config_field};
18use crate::scaffold::runtime_capabilities::{
19 RuntimeCapabilitiesInput, parse_filesystem_mode, parse_filesystem_mount, parse_secret_format,
20 parse_telemetry_attributes, parse_telemetry_scope,
21};
22use crate::scaffold::validate::{ComponentName, normalize_version};
23use crate::wizard::{self, AnswersPayload, WizardPlanEnvelope, WizardPlanMetadata, WizardStep};
24
25const WIZARD_RUN_SCHEMA: &str = "component-wizard-run/v1";
26const ANSWER_DOC_WIZARD_ID: &str = "greentic-component.wizard.run";
27const ANSWER_DOC_SCHEMA_ID: &str = "greentic-component.wizard.run";
28const ANSWER_DOC_SCHEMA_VERSION: &str = "1.0.0";
29
30#[derive(Args, Debug, Clone)]
31pub struct WizardCliArgs {
32 #[command(subcommand)]
33 pub command: Option<WizardSubcommand>,
34 #[command(flatten)]
35 pub args: WizardArgs,
36}
37
38#[derive(Subcommand, Debug, Clone)]
39pub enum WizardSubcommand {
40 Run(WizardArgs),
41 Validate(WizardArgs),
42 Apply(WizardArgs),
43 #[command(hide = true)]
44 New(WizardLegacyNewArgs),
45}
46
47#[derive(Args, Debug, Clone)]
48pub struct WizardLegacyNewArgs {
49 #[arg(value_name = "LEGACY_NAME")]
50 pub name: Option<String>,
51 #[arg(long = "out", value_name = "PATH")]
52 pub out: Option<PathBuf>,
53 #[command(flatten)]
54 pub args: WizardArgs,
55}
56
57#[derive(Args, Debug, Clone)]
58pub struct WizardArgs {
59 #[arg(long, value_enum, default_value = "create")]
60 pub mode: RunMode,
61 #[arg(long, value_enum, default_value = "execute")]
62 pub execution: ExecutionMode,
63 #[arg(
64 long = "dry-run",
65 default_value_t = false,
66 conflicts_with = "execution"
67 )]
68 pub dry_run: bool,
69 #[arg(
70 long = "validate",
71 default_value_t = false,
72 conflicts_with_all = ["execution", "dry_run", "apply"]
73 )]
74 pub validate: bool,
75 #[arg(
76 long = "apply",
77 default_value_t = false,
78 conflicts_with_all = ["execution", "dry_run", "validate"]
79 )]
80 pub apply: bool,
81 #[arg(long = "qa-answers", value_name = "answers.json")]
82 pub qa_answers: Option<PathBuf>,
83 #[arg(
84 long = "answers",
85 value_name = "answers.json",
86 conflicts_with = "qa_answers"
87 )]
88 pub answers: Option<PathBuf>,
89 #[arg(long = "qa-answers-out", value_name = "answers.json")]
90 pub qa_answers_out: Option<PathBuf>,
91 #[arg(
92 long = "emit-answers",
93 value_name = "answers.json",
94 conflicts_with = "qa_answers_out"
95 )]
96 pub emit_answers: Option<PathBuf>,
97 #[arg(long = "schema-version", value_name = "VER")]
98 pub schema_version: Option<String>,
99 #[arg(long = "migrate", default_value_t = false)]
100 pub migrate: bool,
101 #[arg(long = "plan-out", value_name = "plan.json")]
102 pub plan_out: Option<PathBuf>,
103 #[arg(long = "project-root", value_name = "PATH", default_value = ".")]
104 pub project_root: PathBuf,
105 #[arg(long = "template", value_name = "TEMPLATE_ID")]
106 pub template: Option<String>,
107 #[arg(long = "full-tests")]
108 pub full_tests: bool,
109 #[arg(long = "json", default_value_t = false)]
110 pub json: bool,
111}
112
113#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(rename_all = "snake_case")]
115pub enum RunMode {
116 Create,
117 #[value(alias = "add_operation")]
118 #[serde(alias = "add-operation")]
119 AddOperation,
120 #[value(alias = "update_operation")]
121 #[serde(alias = "update-operation")]
122 UpdateOperation,
123 #[value(alias = "build_test")]
124 #[serde(alias = "build-test")]
125 BuildTest,
126 Doctor,
127}
128
129#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(rename_all = "snake_case")]
131pub enum ExecutionMode {
132 #[value(alias = "dry_run")]
133 DryRun,
134 Execute,
135}
136
137#[derive(Debug, Clone)]
138struct WizardLegacyNewCompat {
139 name: Option<String>,
140 out: Option<PathBuf>,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
144struct WizardRunAnswers {
145 schema: String,
146 mode: RunMode,
147 #[serde(default)]
148 fields: JsonMap<String, JsonValue>,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
152struct AnswerDocument {
153 wizard_id: String,
154 schema_id: String,
155 schema_version: String,
156 #[serde(default)]
157 locale: Option<String>,
158 #[serde(default)]
159 answers: JsonMap<String, JsonValue>,
160 #[serde(default)]
161 locks: JsonMap<String, JsonValue>,
162}
163
164#[derive(Debug, Clone)]
165struct LoadedRunAnswers {
166 run_answers: WizardRunAnswers,
167 source_document: Option<AnswerDocument>,
168}
169
170#[derive(Debug, Serialize)]
171struct WizardRunOutput {
172 mode: RunMode,
173 execution: ExecutionMode,
174 plan: WizardPlanEnvelope,
175 #[serde(skip_serializing_if = "Vec::is_empty")]
176 warnings: Vec<String>,
177}
178
179pub fn run_cli(cli: WizardCliArgs) -> Result<()> {
180 let mut execution_override = None;
181 let mut legacy_new = None;
182 let args = match cli.command {
183 Some(WizardSubcommand::Run(args)) => args,
184 Some(WizardSubcommand::Validate(args)) => {
185 execution_override = Some(ExecutionMode::DryRun);
186 args
187 }
188 Some(WizardSubcommand::Apply(args)) => {
189 execution_override = Some(ExecutionMode::Execute);
190 args
191 }
192 Some(WizardSubcommand::New(new_args)) => {
193 legacy_new = Some(WizardLegacyNewCompat {
194 name: new_args.name,
195 out: new_args.out,
196 });
197 new_args.args
198 }
199 None => cli.args,
200 };
201 run_with_context(args, execution_override, legacy_new)
202}
203
204pub fn run(args: WizardArgs) -> Result<()> {
205 run_with_context(args, None, None)
206}
207
208fn run_with_context(
209 args: WizardArgs,
210 execution_override: Option<ExecutionMode>,
211 legacy_new: Option<WizardLegacyNewCompat>,
212) -> Result<()> {
213 let mut args = args;
214 if args.validate && args.apply {
215 bail!("{}", tr("cli.wizard.result.validate_apply_conflict"));
216 }
217
218 let mut execution = if args.dry_run {
219 ExecutionMode::DryRun
220 } else {
221 args.execution
222 };
223 if let Some(override_mode) = execution_override {
224 execution = override_mode;
225 }
226
227 let input_answers = args.answers.as_ref().or(args.qa_answers.as_ref());
228 let loaded_answers = match input_answers {
229 Some(path) => Some(load_run_answers(path, &args)?),
230 None => None,
231 };
232 let mut answers = loaded_answers
233 .as_ref()
234 .map(|loaded| loaded.run_answers.clone());
235 if args.validate {
236 execution = ExecutionMode::DryRun;
237 } else if args.apply {
238 execution = ExecutionMode::Execute;
239 }
240
241 apply_legacy_wizard_new_compat(legacy_new, &mut args, &mut answers)?;
242
243 if answers.is_none() && io::stdin().is_terminal() && io::stdout().is_terminal() {
244 return run_interactive_loop(args, execution);
245 }
246
247 if let Some(doc) = &answers
248 && doc.mode != args.mode
249 {
250 if args.mode == RunMode::Create {
251 args.mode = doc.mode;
252 } else {
253 bail!(
254 "{}",
255 trf(
256 "cli.wizard.result.answers_mode_mismatch",
257 &[&format!("{:?}", doc.mode), &format!("{:?}", args.mode)],
258 )
259 );
260 }
261 }
262
263 let output = build_run_output(&args, execution, answers.as_ref())?;
264
265 if let Some(path) = &args.qa_answers_out {
266 let doc = answers
267 .clone()
268 .unwrap_or_else(|| default_answers_for(&args));
269 let payload = serde_json::to_string_pretty(&doc)?;
270 write_json_file(path, &payload, "qa-answers-out")?;
271 }
272
273 if let Some(path) = &args.emit_answers {
274 let run_answers = answers
275 .clone()
276 .unwrap_or_else(|| default_answers_for(&args));
277 let source_document = loaded_answers
278 .as_ref()
279 .and_then(|loaded| loaded.source_document.clone());
280 let doc = answer_document_from_run_answers(&run_answers, &args, source_document);
281 let payload = serde_json::to_string_pretty(&doc)?;
282 write_json_file(path, &payload, "emit-answers")?;
283 }
284
285 match execution {
286 ExecutionMode::DryRun => {
287 let plan_out = resolve_plan_out(&args)?;
288 write_plan_json(&output.plan, &plan_out)?;
289 println!(
290 "{}",
291 trf(
292 "cli.wizard.result.plan_written",
293 &[plan_out.to_string_lossy().as_ref()],
294 )
295 );
296 }
297 ExecutionMode::Execute => {
298 execute_run_plan(&output.plan)?;
299 if args.mode == RunMode::Create {
300 println!(
301 "{}",
302 trf(
303 "cli.wizard.result.component_written",
304 &[output.plan.target_root.to_string_lossy().as_ref()],
305 )
306 );
307 } else {
308 println!("{}", tr("cli.wizard.result.execute_ok"));
309 }
310 }
311 }
312
313 if args.json {
314 let json = serde_json::to_string_pretty(&output)?;
315 println!("{json}");
316 }
317 Ok(())
318}
319
320fn run_interactive_loop(mut args: WizardArgs, execution: ExecutionMode) -> Result<()> {
321 loop {
322 let Some(mode) = prompt_main_menu_mode(args.mode)? else {
323 return Ok(());
324 };
325 args.mode = mode;
326
327 let Some(answers) = collect_interactive_answers(&args)? else {
328 continue;
329 };
330 let output = build_run_output(&args, execution, Some(&answers))?;
331
332 match execution {
333 ExecutionMode::DryRun => {
334 let plan_out = resolve_plan_out(&args)?;
335 write_plan_json(&output.plan, &plan_out)?;
336 println!(
337 "{}",
338 trf(
339 "cli.wizard.result.plan_written",
340 &[plan_out.to_string_lossy().as_ref()],
341 )
342 );
343 }
344 ExecutionMode::Execute => {
345 execute_run_plan(&output.plan)?;
346 if args.mode == RunMode::Create {
347 println!(
348 "{}",
349 trf(
350 "cli.wizard.result.component_written",
351 &[output.plan.target_root.to_string_lossy().as_ref()],
352 )
353 );
354 } else {
355 println!("{}", tr("cli.wizard.result.execute_ok"));
356 }
357 }
358 }
359
360 if args.json {
361 let json = serde_json::to_string_pretty(&output)?;
362 println!("{json}");
363 }
364 }
365}
366
367fn apply_legacy_wizard_new_compat(
368 legacy_new: Option<WizardLegacyNewCompat>,
369 args: &mut WizardArgs,
370 answers: &mut Option<WizardRunAnswers>,
371) -> Result<()> {
372 let Some(legacy_new) = legacy_new else {
373 return Ok(());
374 };
375
376 let component_name = legacy_new.name.unwrap_or_else(|| "component".to_string());
377 ComponentName::parse(&component_name)?;
378 let output_parent = legacy_new.out.unwrap_or_else(|| args.project_root.clone());
379 let output_dir = output_parent.join(&component_name);
380
381 args.mode = RunMode::Create;
382 let mut doc = answers.take().unwrap_or_else(|| default_answers_for(args));
383 doc.mode = RunMode::Create;
384 doc.fields.insert(
385 "component_name".to_string(),
386 JsonValue::String(component_name),
387 );
388 doc.fields.insert(
389 "output_dir".to_string(),
390 JsonValue::String(output_dir.display().to_string()),
391 );
392 *answers = Some(doc);
393 Ok(())
394}
395
396fn build_run_output(
397 args: &WizardArgs,
398 execution: ExecutionMode,
399 answers: Option<&WizardRunAnswers>,
400) -> Result<WizardRunOutput> {
401 let mode = args.mode;
402
403 let (plan, warnings) = match mode {
404 RunMode::Create => build_create_plan(args, execution, answers)?,
405 RunMode::AddOperation => build_add_operation_plan(args, answers)?,
406 RunMode::UpdateOperation => build_update_operation_plan(args, answers)?,
407 RunMode::BuildTest => build_build_test_plan(args, answers),
408 RunMode::Doctor => build_doctor_plan(args, answers),
409 };
410
411 Ok(WizardRunOutput {
412 mode,
413 execution,
414 plan,
415 warnings,
416 })
417}
418
419fn resolve_plan_out(args: &WizardArgs) -> Result<PathBuf> {
420 if let Some(path) = &args.plan_out {
421 return Ok(path.clone());
422 }
423 if io::stdin().is_terminal() && io::stdout().is_terminal() {
424 return prompt_path(
425 tr("cli.wizard.prompt.plan_out"),
426 Some("./answers.json".to_string()),
427 );
428 }
429 bail!(
430 "{}",
431 tr("cli.wizard.result.plan_out_required_non_interactive")
432 );
433}
434
435fn write_plan_json(plan: &WizardPlanEnvelope, path: &PathBuf) -> Result<()> {
436 let payload = serde_json::to_string_pretty(plan)?;
437 if let Some(parent) = path.parent()
438 && !parent.as_os_str().is_empty()
439 {
440 fs::create_dir_all(parent)
441 .with_context(|| format!("failed to create plan-out parent {}", parent.display()))?;
442 }
443 fs::write(path, payload).with_context(|| format!("failed to write plan {}", path.display()))
444}
445
446fn build_create_plan(
447 args: &WizardArgs,
448 execution: ExecutionMode,
449 answers: Option<&WizardRunAnswers>,
450) -> Result<(WizardPlanEnvelope, Vec<String>)> {
451 let fields = answers.map(|doc| &doc.fields);
452
453 let component_name = fields
454 .and_then(|f| f.get("component_name"))
455 .and_then(JsonValue::as_str)
456 .unwrap_or("component");
457 let component_name = ComponentName::parse(component_name)?.into_string();
458
459 let abi_version = fields
460 .and_then(|f| f.get("abi_version"))
461 .and_then(JsonValue::as_str)
462 .unwrap_or("0.6.0");
463 let abi_version = normalize_version(abi_version)?;
464
465 let output_dir = fields
466 .and_then(|f| f.get("output_dir"))
467 .and_then(JsonValue::as_str)
468 .map(PathBuf::from)
469 .unwrap_or_else(|| args.project_root.join(&component_name));
470
471 let overwrite_output = fields
472 .and_then(|f| f.get("overwrite_output"))
473 .and_then(JsonValue::as_bool)
474 .unwrap_or(false);
475
476 if overwrite_output {
477 if execution == ExecutionMode::Execute && output_dir.exists() {
478 fs::remove_dir_all(&output_dir).with_context(|| {
479 format!(
480 "failed to clear output directory before overwrite {}",
481 output_dir.display()
482 )
483 })?;
484 }
485 } else {
486 validate_output_path_available(&output_dir)?;
487 }
488
489 let template_id = args
490 .template
491 .clone()
492 .or_else(|| {
493 fields
494 .and_then(|f| f.get("template_id"))
495 .and_then(JsonValue::as_str)
496 .map(ToOwned::to_owned)
497 })
498 .unwrap_or_else(default_template_id);
499
500 let user_operations = parse_user_operations(fields)?;
501 let default_operation = parse_default_operation(fields, &user_operations);
502 let runtime_capabilities = parse_runtime_capabilities(fields)?;
503
504 let prefill = fields
505 .and_then(|f| f.get("prefill_answers"))
506 .filter(|value| value.is_object())
507 .map(|value| -> Result<AnswersPayload> {
508 let json = serde_json::to_string_pretty(value)?;
509 let cbor = greentic_types::cbor::canonical::to_canonical_cbor_allow_floats(value)
510 .map_err(|err| {
511 anyhow!(
512 "{}",
513 trf(
514 "cli.wizard.error.prefill_answers_encode",
515 &[&err.to_string()]
516 )
517 )
518 })?;
519 Ok(AnswersPayload { json, cbor })
520 })
521 .transpose()?;
522
523 let request = wizard::WizardRequest {
524 name: component_name,
525 abi_version,
526 mode: wizard::WizardMode::Default,
527 target: output_dir,
528 answers: prefill,
529 required_capabilities: Vec::new(),
530 provided_capabilities: Vec::new(),
531 user_operations,
532 default_operation,
533 runtime_capabilities,
534 config_schema: parse_config_schema(fields)?,
535 };
536
537 let result = wizard::apply_scaffold(request, true)?;
538 let mut warnings = result.warnings;
539 warnings.push(trf("cli.wizard.step.template_used", &[&template_id]));
540 Ok((result.plan, warnings))
541}
542
543fn build_add_operation_plan(
544 args: &WizardArgs,
545 answers: Option<&WizardRunAnswers>,
546) -> Result<(WizardPlanEnvelope, Vec<String>)> {
547 let fields = answers.map(|doc| &doc.fields);
548 let project_root = resolve_project_root(args, fields);
549 let manifest_path = project_root.join("component.manifest.json");
550 let lib_path = project_root.join("src/lib.rs");
551 let operation_name = fields
552 .and_then(|f| f.get("operation_name"))
553 .and_then(JsonValue::as_str)
554 .ok_or_else(|| anyhow!("{}", tr("cli.wizard.error.add_operation_name_required")))?;
555 let operation_name = normalize_operation_name(operation_name)?;
556
557 let mut manifest: JsonValue = serde_json::from_str(
558 &fs::read_to_string(&manifest_path)
559 .with_context(|| format!("failed to read {}", manifest_path.display()))?,
560 )
561 .with_context(|| format!("manifest {} must be valid JSON", manifest_path.display()))?;
562 let user_operations = add_operation_to_manifest(&mut manifest, &operation_name)?;
563 if fields
564 .and_then(|f| f.get("set_default_operation"))
565 .and_then(JsonValue::as_bool)
566 .unwrap_or(false)
567 {
568 manifest["default_operation"] = JsonValue::String(operation_name.clone());
569 }
570
571 let lib_source = fs::read_to_string(&lib_path)
572 .with_context(|| format!("failed to read {}", lib_path.display()))?;
573 let updated_lib = rewrite_lib_user_ops(&lib_source, &user_operations)?;
574
575 Ok((
576 write_files_plan(
577 "greentic.component.add_operation",
578 "mode-add-operation",
579 &project_root,
580 vec![
581 (
582 "component.manifest.json".to_string(),
583 serde_json::to_string_pretty(&manifest)?,
584 ),
585 ("src/lib.rs".to_string(), updated_lib),
586 ],
587 ),
588 Vec::new(),
589 ))
590}
591
592fn build_update_operation_plan(
593 args: &WizardArgs,
594 answers: Option<&WizardRunAnswers>,
595) -> Result<(WizardPlanEnvelope, Vec<String>)> {
596 let fields = answers.map(|doc| &doc.fields);
597 let project_root = resolve_project_root(args, fields);
598 let manifest_path = project_root.join("component.manifest.json");
599 let lib_path = project_root.join("src/lib.rs");
600 let operation_name = fields
601 .and_then(|f| f.get("operation_name"))
602 .and_then(JsonValue::as_str)
603 .ok_or_else(|| anyhow!("{}", tr("cli.wizard.error.update_operation_name_required")))?;
604 let operation_name = normalize_operation_name(operation_name)?;
605 let new_name = fields
606 .and_then(|f| f.get("new_operation_name"))
607 .and_then(JsonValue::as_str)
608 .filter(|value| !value.trim().is_empty())
609 .map(normalize_operation_name)
610 .transpose()?;
611
612 let mut manifest: JsonValue = serde_json::from_str(
613 &fs::read_to_string(&manifest_path)
614 .with_context(|| format!("failed to read {}", manifest_path.display()))?,
615 )
616 .with_context(|| format!("manifest {} must be valid JSON", manifest_path.display()))?;
617 let final_name =
618 update_operation_in_manifest(&mut manifest, &operation_name, new_name.as_deref())?;
619 if fields
620 .and_then(|f| f.get("set_default_operation"))
621 .and_then(JsonValue::as_bool)
622 .unwrap_or(false)
623 {
624 manifest["default_operation"] = JsonValue::String(final_name.clone());
625 }
626 let user_operations = collect_user_operation_names(&manifest)?;
627
628 let lib_source = fs::read_to_string(&lib_path)
629 .with_context(|| format!("failed to read {}", lib_path.display()))?;
630 let updated_lib = rewrite_lib_user_ops(&lib_source, &user_operations)?;
631
632 Ok((
633 write_files_plan(
634 "greentic.component.update_operation",
635 "mode-update-operation",
636 &project_root,
637 vec![
638 (
639 "component.manifest.json".to_string(),
640 serde_json::to_string_pretty(&manifest)?,
641 ),
642 ("src/lib.rs".to_string(), updated_lib),
643 ],
644 ),
645 Vec::new(),
646 ))
647}
648
649fn resolve_project_root(args: &WizardArgs, fields: Option<&JsonMap<String, JsonValue>>) -> PathBuf {
650 fields
651 .and_then(|f| f.get("project_root"))
652 .and_then(JsonValue::as_str)
653 .map(PathBuf::from)
654 .unwrap_or_else(|| args.project_root.clone())
655}
656
657fn normalize_operation_name(value: &str) -> Result<String> {
658 let trimmed = value.trim();
659 if trimmed.is_empty() {
660 bail!("{}", tr("cli.wizard.error.operation_name_empty"));
661 }
662 let is_valid = trimmed.chars().enumerate().all(|(idx, ch)| match idx {
663 0 => ch.is_ascii_lowercase(),
664 _ => ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '_' | '.' | ':' | '-'),
665 });
666 if !is_valid {
667 bail!(
668 "{}",
669 trf("cli.wizard.error.operation_name_invalid", &[trimmed])
670 );
671 }
672 Ok(trimmed.to_string())
673}
674
675fn parse_user_operations(fields: Option<&JsonMap<String, JsonValue>>) -> Result<Vec<String>> {
676 if let Some(csv) = fields
677 .and_then(|f| f.get("operation_names"))
678 .and_then(JsonValue::as_str)
679 .filter(|value| !value.trim().is_empty())
680 {
681 let parsed = parse_operation_names_csv(csv)?;
682 if !parsed.is_empty() {
683 return Ok(parsed);
684 }
685 }
686
687 let operations = fields
688 .and_then(|f| f.get("operations"))
689 .and_then(JsonValue::as_array)
690 .map(|values| {
691 values
692 .iter()
693 .filter_map(|value| match value {
694 JsonValue::String(name) => Some(name.clone()),
695 JsonValue::Object(map) => map
696 .get("name")
697 .and_then(JsonValue::as_str)
698 .map(ToOwned::to_owned),
699 _ => None,
700 })
701 .collect::<Vec<_>>()
702 })
703 .unwrap_or_default();
704 if !operations.is_empty() {
705 return operations
706 .into_iter()
707 .map(|name| normalize_operation_name(&name))
708 .collect();
709 }
710
711 if let Some(name) = fields
712 .and_then(|f| f.get("primary_operation_name"))
713 .and_then(JsonValue::as_str)
714 .filter(|value| !value.trim().is_empty())
715 {
716 return Ok(vec![normalize_operation_name(name)?]);
717 }
718
719 Ok(vec!["handle_message".to_string()])
720}
721
722fn parse_operation_names_csv(value: &str) -> Result<Vec<String>> {
723 value
724 .split(',')
725 .map(str::trim)
726 .filter(|entry| !entry.is_empty())
727 .map(normalize_operation_name)
728 .collect()
729}
730
731fn parse_default_operation(
732 fields: Option<&JsonMap<String, JsonValue>>,
733 user_operations: &[String],
734) -> Option<String> {
735 fields
736 .and_then(|f| f.get("default_operation"))
737 .and_then(JsonValue::as_str)
738 .and_then(|value| user_operations.iter().find(|name| name.as_str() == value))
739 .cloned()
740 .or_else(|| user_operations.first().cloned())
741}
742
743fn parse_runtime_capabilities(
744 fields: Option<&JsonMap<String, JsonValue>>,
745) -> Result<RuntimeCapabilitiesInput> {
746 let filesystem_mode = fields
747 .and_then(|f| f.get("filesystem_mode"))
748 .and_then(JsonValue::as_str)
749 .unwrap_or("none");
750 let telemetry_scope = fields
751 .and_then(|f| f.get("telemetry_scope"))
752 .and_then(JsonValue::as_str)
753 .unwrap_or("node");
754 let filesystem_mounts = parse_string_array(fields, "filesystem_mounts")
755 .into_iter()
756 .map(|value| parse_filesystem_mount(&value).map_err(anyhow::Error::from))
757 .collect::<Result<Vec<_>>>()?;
758 let telemetry_attributes =
759 parse_telemetry_attributes(&parse_string_array(fields, "telemetry_attributes"))
760 .map_err(anyhow::Error::from)?;
761 let telemetry_span_prefix = fields
762 .and_then(|f| f.get("telemetry_span_prefix"))
763 .and_then(JsonValue::as_str)
764 .map(str::trim)
765 .filter(|value| !value.is_empty())
766 .map(ToOwned::to_owned);
767
768 Ok(RuntimeCapabilitiesInput {
769 filesystem_mode: parse_filesystem_mode(filesystem_mode).map_err(anyhow::Error::from)?,
770 filesystem_mounts,
771 messaging_inbound: fields
772 .and_then(|f| f.get("messaging_inbound"))
773 .and_then(JsonValue::as_bool)
774 .unwrap_or(false),
775 messaging_outbound: fields
776 .and_then(|f| f.get("messaging_outbound"))
777 .and_then(JsonValue::as_bool)
778 .unwrap_or(false),
779 events_inbound: fields
780 .and_then(|f| f.get("events_inbound"))
781 .and_then(JsonValue::as_bool)
782 .unwrap_or(false),
783 events_outbound: fields
784 .and_then(|f| f.get("events_outbound"))
785 .and_then(JsonValue::as_bool)
786 .unwrap_or(false),
787 http_client: fields
788 .and_then(|f| f.get("http_client"))
789 .and_then(JsonValue::as_bool)
790 .unwrap_or(false),
791 http_server: fields
792 .and_then(|f| f.get("http_server"))
793 .and_then(JsonValue::as_bool)
794 .unwrap_or(false),
795 state_read: fields
796 .and_then(|f| f.get("state_read"))
797 .and_then(JsonValue::as_bool)
798 .unwrap_or(false),
799 state_write: fields
800 .and_then(|f| f.get("state_write"))
801 .and_then(JsonValue::as_bool)
802 .unwrap_or(false),
803 state_delete: fields
804 .and_then(|f| f.get("state_delete"))
805 .and_then(JsonValue::as_bool)
806 .unwrap_or(false),
807 telemetry_scope: parse_telemetry_scope(telemetry_scope).map_err(anyhow::Error::from)?,
808 telemetry_span_prefix,
809 telemetry_attributes,
810 secret_keys: parse_string_array(fields, "secret_keys"),
811 secret_env: fields
812 .and_then(|f| f.get("secret_env"))
813 .and_then(JsonValue::as_str)
814 .unwrap_or("dev")
815 .trim()
816 .to_string(),
817 secret_tenant: fields
818 .and_then(|f| f.get("secret_tenant"))
819 .and_then(JsonValue::as_str)
820 .unwrap_or("default")
821 .trim()
822 .to_string(),
823 secret_format: parse_secret_format(
824 fields
825 .and_then(|f| f.get("secret_format"))
826 .and_then(JsonValue::as_str)
827 .unwrap_or("text"),
828 )
829 .map_err(anyhow::Error::from)?,
830 })
831}
832
833fn parse_config_schema(fields: Option<&JsonMap<String, JsonValue>>) -> Result<ConfigSchemaInput> {
834 Ok(ConfigSchemaInput {
835 fields: parse_string_array(fields, "config_fields")
836 .into_iter()
837 .map(|value| parse_config_field(&value).map_err(anyhow::Error::from))
838 .collect::<Result<Vec<_>>>()?,
839 })
840}
841
842fn default_operation_schema(component_name: &str, operation_name: &str) -> JsonValue {
843 json!({
844 "name": operation_name,
845 "input_schema": {
846 "$schema": "https://json-schema.org/draft/2020-12/schema",
847 "title": format!("{component_name} {operation_name} input"),
848 "type": "object",
849 "required": ["input"],
850 "properties": {
851 "input": {
852 "type": "string",
853 "default": format!("Hello from {component_name}!")
854 }
855 },
856 "additionalProperties": false
857 },
858 "output_schema": {
859 "$schema": "https://json-schema.org/draft/2020-12/schema",
860 "title": format!("{component_name} {operation_name} output"),
861 "type": "object",
862 "required": ["message"],
863 "properties": {
864 "message": { "type": "string" }
865 },
866 "additionalProperties": false
867 }
868 })
869}
870
871fn add_operation_to_manifest(
872 manifest: &mut JsonValue,
873 operation_name: &str,
874) -> Result<Vec<String>> {
875 let component_name = manifest
876 .get("name")
877 .and_then(JsonValue::as_str)
878 .unwrap_or("component")
879 .to_string();
880 let operations = manifest
881 .get_mut("operations")
882 .and_then(JsonValue::as_array_mut)
883 .ok_or_else(|| anyhow!("{}", tr("cli.wizard.error.manifest_operations_array")))?;
884 if operations.iter().any(|entry| {
885 entry
886 .get("name")
887 .and_then(JsonValue::as_str)
888 .is_some_and(|name| name == operation_name)
889 }) {
890 bail!(
891 "{}",
892 trf("cli.wizard.error.operation_exists", &[operation_name])
893 );
894 }
895 operations.push(default_operation_schema(&component_name, operation_name));
896 collect_user_operation_names(manifest)
897}
898
899fn update_operation_in_manifest(
900 manifest: &mut JsonValue,
901 operation_name: &str,
902 new_name: Option<&str>,
903) -> Result<String> {
904 let operations = manifest
905 .get_mut("operations")
906 .and_then(JsonValue::as_array_mut)
907 .ok_or_else(|| anyhow!("{}", tr("cli.wizard.error.manifest_operations_array")))?;
908 let target_index = operations.iter().position(|entry| {
909 entry
910 .get("name")
911 .and_then(JsonValue::as_str)
912 .is_some_and(|name| name == operation_name)
913 });
914 let Some(target_index) = target_index else {
915 bail!(
916 "{}",
917 trf("cli.wizard.error.operation_not_found", &[operation_name])
918 );
919 };
920 let final_name = new_name.unwrap_or(operation_name).to_string();
921 if final_name != operation_name
922 && operations.iter().any(|other| {
923 other
924 .get("name")
925 .and_then(JsonValue::as_str)
926 .is_some_and(|name| name == final_name)
927 })
928 {
929 bail!(
930 "{}",
931 trf("cli.wizard.error.operation_exists", &[&final_name])
932 );
933 }
934 let entry = operations.get_mut(target_index).ok_or_else(|| {
935 anyhow!(
936 "{}",
937 trf("cli.wizard.error.operation_not_found", &[operation_name])
938 )
939 })?;
940 entry["name"] = JsonValue::String(final_name.clone());
941 if manifest
942 .get("default_operation")
943 .and_then(JsonValue::as_str)
944 .is_some_and(|value| value == operation_name)
945 {
946 manifest["default_operation"] = JsonValue::String(final_name.clone());
947 }
948 Ok(final_name)
949}
950
951fn collect_user_operation_names(manifest: &JsonValue) -> Result<Vec<String>> {
952 let operations = manifest
953 .get("operations")
954 .and_then(JsonValue::as_array)
955 .ok_or_else(|| anyhow!("{}", tr("cli.wizard.error.manifest_operations_array")))?;
956 Ok(operations
957 .iter()
958 .filter_map(|entry| entry.get("name").and_then(JsonValue::as_str))
959 .filter(|name| !matches!(*name, "qa-spec" | "apply-answers" | "i18n-keys"))
960 .map(ToOwned::to_owned)
961 .collect())
962}
963
964fn write_files_plan(
965 id: &str,
966 digest: &str,
967 project_root: &Path,
968 files: Vec<(String, String)>,
969) -> WizardPlanEnvelope {
970 let file_map = files
971 .into_iter()
972 .collect::<std::collections::BTreeMap<_, _>>();
973 WizardPlanEnvelope {
974 plan_version: wizard::PLAN_VERSION,
975 metadata: WizardPlanMetadata {
976 generator: "greentic-component/wizard-runner".to_string(),
977 template_version: "component-wizard-run/v1".to_string(),
978 template_digest_blake3: digest.to_string(),
979 requested_abi_version: "0.6.0".to_string(),
980 },
981 target_root: project_root.to_path_buf(),
982 plan: wizard::WizardPlan {
983 meta: wizard::WizardPlanMeta {
984 id: id.to_string(),
985 target: wizard::WizardTarget::Component,
986 mode: wizard::WizardPlanMode::Scaffold,
987 },
988 steps: vec![WizardStep::WriteFiles { files: file_map }],
989 },
990 }
991}
992
993fn rewrite_lib_user_ops(source: &str, user_operations: &[String]) -> Result<String> {
994 let generated = user_operations
995 .iter()
996 .map(|name| {
997 format!(
998 r#" node::Op {{
999 name: "{name}".to_string(),
1000 summary: Some("Handle a single message input".to_string()),
1001 input: node::IoSchema {{
1002 schema: node::SchemaSource::InlineCbor(input_schema_cbor.clone()),
1003 content_type: "application/cbor".to_string(),
1004 schema_version: None,
1005 }},
1006 output: node::IoSchema {{
1007 schema: node::SchemaSource::InlineCbor(output_schema_cbor.clone()),
1008 content_type: "application/cbor".to_string(),
1009 schema_version: None,
1010 }},
1011 examples: Vec::new(),
1012 }}"#
1013 )
1014 })
1015 .collect::<Vec<_>>()
1016 .join(",\n");
1017
1018 if let Some(start) = source.find(" ops: vec![")
1019 && let Some(end_rel) = source[start..].find(" schemas: Vec::new(),")
1020 {
1021 let end = start + end_rel;
1022 let qa_anchor = source[start..end]
1023 .find(" node::Op {\n name: \"qa-spec\".to_string(),")
1024 .map(|idx| start + idx)
1025 .ok_or_else(|| anyhow!("{}", tr("cli.wizard.error.lib_missing_qa_block")))?;
1026 let mut updated = String::new();
1027 updated.push_str(&source[..start]);
1028 updated.push_str(" ops: vec![\n");
1029 updated.push_str(&generated);
1030 updated.push_str(",\n");
1031 updated.push_str(&source[qa_anchor..end]);
1032 updated.push_str(&source[end..]);
1033 return Ok(updated);
1034 }
1035
1036 if let Some(start) = source.find(" let mut ops = vec![")
1037 && let Some(end_anchor_rel) = source[start..].find(" ops.extend(vec![")
1038 {
1039 let end = start + end_anchor_rel;
1040 let mut updated = String::new();
1041 updated.push_str(&source[..start]);
1042 updated.push_str(" let mut ops = vec![\n");
1043 updated.push_str(
1044 &user_operations
1045 .iter()
1046 .map(|name| {
1047 format!(
1048 r#" node::Op {{
1049 name: "{name}".to_string(),
1050 summary: Some("Handle a single message input".to_string()),
1051 input: node::IoSchema {{
1052 schema: node::SchemaSource::InlineCbor(input_schema_cbor.clone()),
1053 content_type: "application/cbor".to_string(),
1054 schema_version: None,
1055 }},
1056 output: node::IoSchema {{
1057 schema: node::SchemaSource::InlineCbor(output_schema_cbor.clone()),
1058 content_type: "application/cbor".to_string(),
1059 schema_version: None,
1060 }},
1061 examples: Vec::new(),
1062 }}"#
1063 )
1064 })
1065 .collect::<Vec<_>>()
1066 .join(",\n"),
1067 );
1068 updated.push_str("\n ];\n");
1069 updated.push_str(&source[end..]);
1070 return Ok(updated);
1071 }
1072
1073 bail!("{}", tr("cli.wizard.error.lib_unexpected_layout"))
1074}
1075
1076fn build_build_test_plan(
1077 args: &WizardArgs,
1078 answers: Option<&WizardRunAnswers>,
1079) -> (WizardPlanEnvelope, Vec<String>) {
1080 let fields = answers.map(|doc| &doc.fields);
1081 let project_root = fields
1082 .and_then(|f| f.get("project_root"))
1083 .and_then(JsonValue::as_str)
1084 .map(PathBuf::from)
1085 .unwrap_or_else(|| args.project_root.clone());
1086
1087 let mut steps = vec![WizardStep::BuildComponent {
1088 project_root: project_root.display().to_string(),
1089 }];
1090
1091 let full_tests = fields
1092 .and_then(|f| f.get("full_tests"))
1093 .and_then(JsonValue::as_bool)
1094 .unwrap_or(args.full_tests);
1095
1096 if full_tests {
1097 steps.push(WizardStep::TestComponent {
1098 project_root: project_root.display().to_string(),
1099 full: true,
1100 });
1101 }
1102
1103 (
1104 WizardPlanEnvelope {
1105 plan_version: wizard::PLAN_VERSION,
1106 metadata: WizardPlanMetadata {
1107 generator: "greentic-component/wizard-runner".to_string(),
1108 template_version: "component-wizard-run/v1".to_string(),
1109 template_digest_blake3: "mode-build-test".to_string(),
1110 requested_abi_version: "0.6.0".to_string(),
1111 },
1112 target_root: project_root,
1113 plan: wizard::WizardPlan {
1114 meta: wizard::WizardPlanMeta {
1115 id: "greentic.component.build_test".to_string(),
1116 target: wizard::WizardTarget::Component,
1117 mode: wizard::WizardPlanMode::Scaffold,
1118 },
1119 steps,
1120 },
1121 },
1122 Vec::new(),
1123 )
1124}
1125
1126fn build_doctor_plan(
1127 args: &WizardArgs,
1128 answers: Option<&WizardRunAnswers>,
1129) -> (WizardPlanEnvelope, Vec<String>) {
1130 let fields = answers.map(|doc| &doc.fields);
1131 let project_root = fields
1132 .and_then(|f| f.get("project_root"))
1133 .and_then(JsonValue::as_str)
1134 .map(PathBuf::from)
1135 .unwrap_or_else(|| args.project_root.clone());
1136
1137 (
1138 WizardPlanEnvelope {
1139 plan_version: wizard::PLAN_VERSION,
1140 metadata: WizardPlanMetadata {
1141 generator: "greentic-component/wizard-runner".to_string(),
1142 template_version: "component-wizard-run/v1".to_string(),
1143 template_digest_blake3: "mode-doctor".to_string(),
1144 requested_abi_version: "0.6.0".to_string(),
1145 },
1146 target_root: project_root.clone(),
1147 plan: wizard::WizardPlan {
1148 meta: wizard::WizardPlanMeta {
1149 id: "greentic.component.doctor".to_string(),
1150 target: wizard::WizardTarget::Component,
1151 mode: wizard::WizardPlanMode::Scaffold,
1152 },
1153 steps: vec![WizardStep::Doctor {
1154 project_root: project_root.display().to_string(),
1155 }],
1156 },
1157 },
1158 Vec::new(),
1159 )
1160}
1161
1162fn execute_run_plan(plan: &WizardPlanEnvelope) -> Result<()> {
1163 for step in &plan.plan.steps {
1164 match step {
1165 WizardStep::EnsureDir { .. } | WizardStep::WriteFiles { .. } => {
1166 let single = WizardPlanEnvelope {
1167 plan_version: plan.plan_version,
1168 metadata: plan.metadata.clone(),
1169 target_root: plan.target_root.clone(),
1170 plan: wizard::WizardPlan {
1171 meta: plan.plan.meta.clone(),
1172 steps: vec![step.clone()],
1173 },
1174 };
1175 wizard::execute_plan(&single)?;
1176 }
1177 WizardStep::BuildComponent { project_root } => {
1178 let manifest = PathBuf::from(project_root).join("component.manifest.json");
1179 crate::cmd::build::run(BuildArgs {
1180 manifest,
1181 cargo_bin: None,
1182 no_flow: false,
1183 no_infer_config: false,
1184 no_write_schema: false,
1185 force_write_schema: false,
1186 no_validate: false,
1187 json: false,
1188 permissive: false,
1189 })?;
1190 }
1191 WizardStep::Doctor { project_root } => {
1192 let manifest = PathBuf::from(project_root).join("component.manifest.json");
1193 crate::cmd::doctor::run(DoctorArgs {
1194 target: project_root.clone(),
1195 manifest: Some(manifest),
1196 format: DoctorFormat::Human,
1197 })
1198 .map_err(|err| anyhow!(err.to_string()))?;
1199 }
1200 WizardStep::TestComponent { project_root, full } => {
1201 if *full {
1202 let status = Command::new("cargo")
1203 .arg("test")
1204 .current_dir(project_root)
1205 .status()
1206 .with_context(|| format!("failed to run cargo test in {project_root}"))?;
1207 if !status.success() {
1208 bail!(
1209 "{}",
1210 trf("cli.wizard.error.cargo_test_failed_in", &[project_root])
1211 );
1212 }
1213 }
1214 }
1215 WizardStep::RunCli { command } => {
1216 bail!(
1217 "{}",
1218 trf("cli.wizard.error.unsupported_run_cli", &[command])
1219 );
1220 }
1221 WizardStep::Delegate { id } => {
1222 bail!(
1223 "{}",
1224 trf("cli.wizard.error.unsupported_delegate", &[id.as_str()])
1225 );
1226 }
1227 }
1228 }
1229 Ok(())
1230}
1231
1232fn parse_string_array(fields: Option<&JsonMap<String, JsonValue>>, key: &str) -> Vec<String> {
1233 match fields.and_then(|f| f.get(key)) {
1234 Some(JsonValue::Array(values)) => values
1235 .iter()
1236 .filter_map(JsonValue::as_str)
1237 .map(ToOwned::to_owned)
1238 .collect(),
1239 Some(JsonValue::String(value)) => value
1240 .split(',')
1241 .map(str::trim)
1242 .filter(|entry| !entry.is_empty())
1243 .map(ToOwned::to_owned)
1244 .collect(),
1245 _ => Vec::new(),
1246 }
1247}
1248
1249fn load_run_answers(path: &PathBuf, args: &WizardArgs) -> Result<LoadedRunAnswers> {
1250 let raw = fs::read_to_string(path)
1251 .with_context(|| format!("failed to read qa answers {}", path.display()))?;
1252 let value: JsonValue = serde_json::from_str(&raw)
1253 .with_context(|| format!("qa answers {} must be valid JSON", path.display()))?;
1254
1255 if let Some(doc) = parse_answer_document(&value)? {
1256 let migrated = maybe_migrate_document(doc, args)?;
1257 let run_answers = run_answers_from_answer_document(&migrated, args)?;
1258 return Ok(LoadedRunAnswers {
1259 run_answers,
1260 source_document: Some(migrated),
1261 });
1262 }
1263
1264 let answers: WizardRunAnswers = serde_json::from_value(value)
1265 .with_context(|| format!("qa answers {} must be valid JSON", path.display()))?;
1266 if answers.schema != WIZARD_RUN_SCHEMA {
1267 bail!(
1268 "{}",
1269 trf(
1270 "cli.wizard.result.invalid_schema",
1271 &[&answers.schema, WIZARD_RUN_SCHEMA],
1272 )
1273 );
1274 }
1275 Ok(LoadedRunAnswers {
1276 run_answers: answers,
1277 source_document: None,
1278 })
1279}
1280
1281fn parse_answer_document(value: &JsonValue) -> Result<Option<AnswerDocument>> {
1282 let JsonValue::Object(map) = value else {
1283 return Ok(None);
1284 };
1285 if map.contains_key("wizard_id")
1286 || map.contains_key("schema_id")
1287 || map.contains_key("schema_version")
1288 || map.contains_key("answers")
1289 {
1290 let doc: AnswerDocument = serde_json::from_value(value.clone())
1291 .with_context(|| tr("cli.wizard.result.answer_doc_invalid_shape"))?;
1292 return Ok(Some(doc));
1293 }
1294 Ok(None)
1295}
1296
1297fn maybe_migrate_document(doc: AnswerDocument, args: &WizardArgs) -> Result<AnswerDocument> {
1298 if doc.schema_id != ANSWER_DOC_SCHEMA_ID {
1299 bail!(
1300 "{}",
1301 trf(
1302 "cli.wizard.result.answer_schema_id_mismatch",
1303 &[&doc.schema_id, ANSWER_DOC_SCHEMA_ID],
1304 )
1305 );
1306 }
1307 let target_version = requested_schema_version(args);
1308 if doc.schema_version == target_version {
1309 return Ok(doc);
1310 }
1311 if !args.migrate {
1312 bail!(
1313 "{}",
1314 trf(
1315 "cli.wizard.result.answer_schema_version_mismatch",
1316 &[&doc.schema_version, &target_version],
1317 )
1318 );
1319 }
1320 let mut migrated = doc;
1321 migrated.schema_version = target_version;
1322 Ok(migrated)
1323}
1324
1325fn run_answers_from_answer_document(
1326 doc: &AnswerDocument,
1327 args: &WizardArgs,
1328) -> Result<WizardRunAnswers> {
1329 let mode = doc
1330 .answers
1331 .get("mode")
1332 .and_then(JsonValue::as_str)
1333 .map(parse_run_mode)
1334 .transpose()?
1335 .unwrap_or(args.mode);
1336 let fields = match doc.answers.get("fields") {
1337 Some(JsonValue::Object(fields)) => fields.clone(),
1338 _ => doc.answers.clone(),
1339 };
1340 Ok(WizardRunAnswers {
1341 schema: WIZARD_RUN_SCHEMA.to_string(),
1342 mode,
1343 fields,
1344 })
1345}
1346
1347fn parse_run_mode(value: &str) -> Result<RunMode> {
1348 match value {
1349 "create" => Ok(RunMode::Create),
1350 "add-operation" | "add_operation" => Ok(RunMode::AddOperation),
1351 "update-operation" | "update_operation" => Ok(RunMode::UpdateOperation),
1352 "build-test" | "build_test" => Ok(RunMode::BuildTest),
1353 "doctor" => Ok(RunMode::Doctor),
1354 _ => bail!(
1355 "{}",
1356 trf("cli.wizard.result.answer_mode_unsupported", &[value])
1357 ),
1358 }
1359}
1360
1361fn answer_document_from_run_answers(
1362 run_answers: &WizardRunAnswers,
1363 args: &WizardArgs,
1364 source_document: Option<AnswerDocument>,
1365) -> AnswerDocument {
1366 let locale = i18n::selected_locale().to_string();
1367 let mut answers = JsonMap::new();
1368 answers.insert(
1369 "mode".to_string(),
1370 JsonValue::String(mode_name(run_answers.mode).replace('_', "-")),
1371 );
1372 answers.insert(
1373 "fields".to_string(),
1374 JsonValue::Object(run_answers.fields.clone()),
1375 );
1376
1377 let locks = source_document
1378 .as_ref()
1379 .map(|doc| doc.locks.clone())
1380 .unwrap_or_default();
1381
1382 AnswerDocument {
1383 wizard_id: source_document
1384 .as_ref()
1385 .map(|doc| doc.wizard_id.clone())
1386 .unwrap_or_else(|| ANSWER_DOC_WIZARD_ID.to_string()),
1387 schema_id: source_document
1388 .as_ref()
1389 .map(|doc| doc.schema_id.clone())
1390 .unwrap_or_else(|| ANSWER_DOC_SCHEMA_ID.to_string()),
1391 schema_version: requested_schema_version(args),
1392 locale: Some(locale),
1393 answers,
1394 locks,
1395 }
1396}
1397
1398fn requested_schema_version(args: &WizardArgs) -> String {
1399 args.schema_version
1400 .clone()
1401 .unwrap_or_else(|| ANSWER_DOC_SCHEMA_VERSION.to_string())
1402}
1403
1404fn write_json_file(path: &PathBuf, payload: &str, label: &str) -> Result<()> {
1405 if let Some(parent) = path.parent()
1406 && !parent.as_os_str().is_empty()
1407 {
1408 fs::create_dir_all(parent)
1409 .with_context(|| format!("failed to create {label} parent {}", parent.display()))?;
1410 }
1411 fs::write(path, payload).with_context(|| format!("failed to write {label} {}", path.display()))
1412}
1413
1414fn default_answers_for(args: &WizardArgs) -> WizardRunAnswers {
1415 WizardRunAnswers {
1416 schema: WIZARD_RUN_SCHEMA.to_string(),
1417 mode: args.mode,
1418 fields: JsonMap::new(),
1419 }
1420}
1421
1422fn collect_interactive_answers(args: &WizardArgs) -> Result<Option<WizardRunAnswers>> {
1423 println!("0 = back, M = main menu");
1424 if args.mode == RunMode::Create {
1425 return collect_interactive_create_answers(args);
1426 }
1427
1428 let Some(fields) = collect_interactive_question_map(args, interactive_questions(args))? else {
1429 return Ok(None);
1430 };
1431 Ok(Some(WizardRunAnswers {
1432 schema: WIZARD_RUN_SCHEMA.to_string(),
1433 mode: args.mode,
1434 fields,
1435 }))
1436}
1437
1438fn collect_interactive_create_answers(args: &WizardArgs) -> Result<Option<WizardRunAnswers>> {
1439 let mut answered = JsonMap::new();
1440 let Some(minimal_answers) = collect_interactive_question_map_with_answers(
1441 args,
1442 create_questions(args, false),
1443 answered,
1444 )?
1445 else {
1446 return Ok(None);
1447 };
1448 answered = minimal_answers;
1449
1450 if answered
1451 .get("advanced_setup")
1452 .and_then(JsonValue::as_bool)
1453 .unwrap_or(false)
1454 {
1455 let Some(advanced_answers) = collect_interactive_question_map_with_skip(
1456 args,
1457 create_questions(args, true),
1458 answered,
1459 should_skip_create_advanced_question,
1460 )?
1461 else {
1462 return Ok(None);
1463 };
1464 answered = advanced_answers;
1465 }
1466
1467 let operations = answered
1468 .get("operation_names")
1469 .and_then(JsonValue::as_str)
1470 .filter(|value| !value.trim().is_empty())
1471 .map(parse_operation_names_csv)
1472 .transpose()?
1473 .filter(|ops| !ops.is_empty())
1474 .or_else(|| {
1475 answered
1476 .get("primary_operation_name")
1477 .and_then(JsonValue::as_str)
1478 .filter(|value| !value.trim().is_empty())
1479 .map(|value| vec![value.to_string()])
1480 });
1481 if let Some(operations) = operations {
1482 let default_operation = operations
1483 .first()
1484 .cloned()
1485 .unwrap_or_else(|| "handle_message".to_string());
1486 answered.insert(
1487 "operations".to_string(),
1488 JsonValue::Array(
1489 operations
1490 .into_iter()
1491 .map(JsonValue::String)
1492 .collect::<Vec<_>>(),
1493 ),
1494 );
1495 answered.insert(
1496 "default_operation".to_string(),
1497 JsonValue::String(default_operation),
1498 );
1499 }
1500
1501 Ok(Some(WizardRunAnswers {
1502 schema: WIZARD_RUN_SCHEMA.to_string(),
1503 mode: args.mode,
1504 fields: answered,
1505 }))
1506}
1507
1508fn interactive_questions(args: &WizardArgs) -> Vec<JsonValue> {
1509 match args.mode {
1510 RunMode::Create => create_questions(args, true),
1511 RunMode::AddOperation => vec![
1512 json!({
1513 "id": "project_root",
1514 "type": "string",
1515 "title": tr("cli.wizard.prompt.project_root"),
1516 "title_i18n": {"key":"cli.wizard.prompt.project_root"},
1517 "required": true,
1518 "default": args.project_root.display().to_string()
1519 }),
1520 json!({
1521 "id": "operation_name",
1522 "type": "string",
1523 "title": tr("cli.wizard.prompt.operation_name"),
1524 "title_i18n": {"key":"cli.wizard.prompt.operation_name"},
1525 "required": true
1526 }),
1527 json!({
1528 "id": "set_default_operation",
1529 "type": "boolean",
1530 "title": tr("cli.wizard.prompt.set_default_operation"),
1531 "title_i18n": {"key":"cli.wizard.prompt.set_default_operation"},
1532 "required": false,
1533 "default": false
1534 }),
1535 ],
1536 RunMode::UpdateOperation => vec![
1537 json!({
1538 "id": "project_root",
1539 "type": "string",
1540 "title": tr("cli.wizard.prompt.project_root"),
1541 "title_i18n": {"key":"cli.wizard.prompt.project_root"},
1542 "required": true,
1543 "default": args.project_root.display().to_string()
1544 }),
1545 json!({
1546 "id": "operation_name",
1547 "type": "string",
1548 "title": tr("cli.wizard.prompt.existing_operation_name"),
1549 "title_i18n": {"key":"cli.wizard.prompt.existing_operation_name"},
1550 "required": true
1551 }),
1552 json!({
1553 "id": "new_operation_name",
1554 "type": "string",
1555 "title": tr("cli.wizard.prompt.new_operation_name"),
1556 "title_i18n": {"key":"cli.wizard.prompt.new_operation_name"},
1557 "required": false
1558 }),
1559 json!({
1560 "id": "set_default_operation",
1561 "type": "boolean",
1562 "title": tr("cli.wizard.prompt.set_default_operation"),
1563 "title_i18n": {"key":"cli.wizard.prompt.set_default_operation"},
1564 "required": false,
1565 "default": false
1566 }),
1567 ],
1568 RunMode::BuildTest => vec![
1569 json!({
1570 "id": "project_root",
1571 "type": "string",
1572 "title": tr("cli.wizard.prompt.project_root"),
1573 "title_i18n": {"key":"cli.wizard.prompt.project_root"},
1574 "required": true,
1575 "default": args.project_root.display().to_string()
1576 }),
1577 json!({
1578 "id": "full_tests",
1579 "type": "boolean",
1580 "title": tr("cli.wizard.prompt.full_tests"),
1581 "title_i18n": {"key":"cli.wizard.prompt.full_tests"},
1582 "required": false,
1583 "default": args.full_tests
1584 }),
1585 ],
1586 RunMode::Doctor => vec![json!({
1587 "id": "project_root",
1588 "type": "string",
1589 "title": tr("cli.wizard.prompt.project_root"),
1590 "title_i18n": {"key":"cli.wizard.prompt.project_root"},
1591 "required": true,
1592 "default": args.project_root.display().to_string()
1593 })],
1594 }
1595}
1596
1597fn create_questions(args: &WizardArgs, include_advanced: bool) -> Vec<JsonValue> {
1598 let templates = available_template_ids();
1599 let mut questions = vec![
1600 json!({
1601 "id": "component_name",
1602 "type": "string",
1603 "title": tr("cli.wizard.prompt.component_name"),
1604 "title_i18n": {"key":"cli.wizard.prompt.component_name"},
1605 "required": true,
1606 "default": "component"
1607 }),
1608 json!({
1609 "id": "output_dir",
1610 "type": "string",
1611 "title": tr("cli.wizard.prompt.output_dir"),
1612 "title_i18n": {"key":"cli.wizard.prompt.output_dir"},
1613 "required": true,
1614 "default": args.project_root.join("component").display().to_string()
1615 }),
1616 json!({
1617 "id": "advanced_setup",
1618 "type": "boolean",
1619 "title": tr("cli.wizard.prompt.advanced_setup"),
1620 "title_i18n": {"key":"cli.wizard.prompt.advanced_setup"},
1621 "required": true,
1622 "default": false
1623 }),
1624 ];
1625 if !include_advanced {
1626 return questions;
1627 }
1628
1629 questions.extend([
1630 json!({
1631 "id": "abi_version",
1632 "type": "string",
1633 "title": tr("cli.wizard.prompt.abi_version"),
1634 "title_i18n": {"key":"cli.wizard.prompt.abi_version"},
1635 "required": true,
1636 "default": "0.6.0"
1637 }),
1638 json!({
1639 "id": "operation_names",
1640 "type": "string",
1641 "title": tr("cli.wizard.prompt.operation_names"),
1642 "title_i18n": {"key":"cli.wizard.prompt.operation_names"},
1643 "required": true,
1644 "default": "handle_message"
1645 }),
1646 json!({
1647 "id": "filesystem_mode",
1648 "type": "enum",
1649 "title": tr("cli.wizard.prompt.filesystem_mode"),
1650 "title_i18n": {"key":"cli.wizard.prompt.filesystem_mode"},
1651 "required": true,
1652 "default": "none",
1653 "choices": ["none", "read_only", "sandbox"]
1654 }),
1655 json!({
1656 "id": "filesystem_mounts",
1657 "type": "string",
1658 "title": tr("cli.wizard.prompt.filesystem_mounts"),
1659 "title_i18n": {"key":"cli.wizard.prompt.filesystem_mounts"},
1660 "required": false,
1661 "default": ""
1662 }),
1663 json!({
1664 "id": "http_client",
1665 "type": "boolean",
1666 "title": tr("cli.wizard.prompt.http_client"),
1667 "title_i18n": {"key":"cli.wizard.prompt.http_client"},
1668 "required": false,
1669 "default": false
1670 }),
1671 json!({
1672 "id": "messaging_inbound",
1673 "type": "boolean",
1674 "title": tr("cli.wizard.prompt.messaging_inbound"),
1675 "title_i18n": {"key":"cli.wizard.prompt.messaging_inbound"},
1676 "required": false,
1677 "default": false
1678 }),
1679 json!({
1680 "id": "messaging_outbound",
1681 "type": "boolean",
1682 "title": tr("cli.wizard.prompt.messaging_outbound"),
1683 "title_i18n": {"key":"cli.wizard.prompt.messaging_outbound"},
1684 "required": false,
1685 "default": false
1686 }),
1687 json!({
1688 "id": "events_inbound",
1689 "type": "boolean",
1690 "title": tr("cli.wizard.prompt.events_inbound"),
1691 "title_i18n": {"key":"cli.wizard.prompt.events_inbound"},
1692 "required": false,
1693 "default": false
1694 }),
1695 json!({
1696 "id": "events_outbound",
1697 "type": "boolean",
1698 "title": tr("cli.wizard.prompt.events_outbound"),
1699 "title_i18n": {"key":"cli.wizard.prompt.events_outbound"},
1700 "required": false,
1701 "default": false
1702 }),
1703 json!({
1704 "id": "http_server",
1705 "type": "boolean",
1706 "title": tr("cli.wizard.prompt.http_server"),
1707 "title_i18n": {"key":"cli.wizard.prompt.http_server"},
1708 "required": false,
1709 "default": false
1710 }),
1711 json!({
1712 "id": "state_read",
1713 "type": "boolean",
1714 "title": tr("cli.wizard.prompt.state_read"),
1715 "title_i18n": {"key":"cli.wizard.prompt.state_read"},
1716 "required": false,
1717 "default": false
1718 }),
1719 json!({
1720 "id": "state_write",
1721 "type": "boolean",
1722 "title": tr("cli.wizard.prompt.state_write"),
1723 "title_i18n": {"key":"cli.wizard.prompt.state_write"},
1724 "required": false,
1725 "default": false
1726 }),
1727 json!({
1728 "id": "state_delete",
1729 "type": "boolean",
1730 "title": tr("cli.wizard.prompt.state_delete"),
1731 "title_i18n": {"key":"cli.wizard.prompt.state_delete"},
1732 "required": false,
1733 "default": false
1734 }),
1735 json!({
1736 "id": "telemetry_scope",
1737 "type": "enum",
1738 "title": tr("cli.wizard.prompt.telemetry_scope"),
1739 "title_i18n": {"key":"cli.wizard.prompt.telemetry_scope"},
1740 "required": true,
1741 "default": "node",
1742 "choices": ["tenant", "pack", "node"]
1743 }),
1744 json!({
1745 "id": "telemetry_span_prefix",
1746 "type": "string",
1747 "title": tr("cli.wizard.prompt.telemetry_span_prefix"),
1748 "title_i18n": {"key":"cli.wizard.prompt.telemetry_span_prefix"},
1749 "required": false,
1750 "default": ""
1751 }),
1752 json!({
1753 "id": "telemetry_attributes",
1754 "type": "string",
1755 "title": tr("cli.wizard.prompt.telemetry_attributes"),
1756 "title_i18n": {"key":"cli.wizard.prompt.telemetry_attributes"},
1757 "required": false,
1758 "default": ""
1759 }),
1760 json!({
1761 "id": "secrets_enabled",
1762 "type": "boolean",
1763 "title": tr("cli.wizard.prompt.secrets_enabled"),
1764 "title_i18n": {"key":"cli.wizard.prompt.secrets_enabled"},
1765 "required": false,
1766 "default": false
1767 }),
1768 json!({
1769 "id": "secret_keys",
1770 "type": "string",
1771 "title": tr("cli.wizard.prompt.secret_keys"),
1772 "title_i18n": {"key":"cli.wizard.prompt.secret_keys"},
1773 "required": false,
1774 "default": ""
1775 }),
1776 json!({
1777 "id": "secret_env",
1778 "type": "string",
1779 "title": tr("cli.wizard.prompt.secret_env"),
1780 "title_i18n": {"key":"cli.wizard.prompt.secret_env"},
1781 "required": false,
1782 "default": "dev"
1783 }),
1784 json!({
1785 "id": "secret_tenant",
1786 "type": "string",
1787 "title": tr("cli.wizard.prompt.secret_tenant"),
1788 "title_i18n": {"key":"cli.wizard.prompt.secret_tenant"},
1789 "required": false,
1790 "default": "default"
1791 }),
1792 json!({
1793 "id": "secret_format",
1794 "type": "enum",
1795 "title": tr("cli.wizard.prompt.secret_format"),
1796 "title_i18n": {"key":"cli.wizard.prompt.secret_format"},
1797 "required": false,
1798 "default": "text",
1799 "choices": ["bytes", "text", "json"]
1800 }),
1801 json!({
1802 "id": "config_fields",
1803 "type": "string",
1804 "title": tr("cli.wizard.prompt.config_fields"),
1805 "title_i18n": {"key":"cli.wizard.prompt.config_fields"},
1806 "required": false,
1807 "default": ""
1808 }),
1809 ]);
1810 if args.template.is_none() && templates.len() > 1 {
1811 let template_choices = templates
1812 .into_iter()
1813 .map(JsonValue::String)
1814 .collect::<Vec<_>>();
1815 questions.push(json!({
1816 "id": "template_id",
1817 "type": "enum",
1818 "title": tr("cli.wizard.prompt.template_id"),
1819 "title_i18n": {"key":"cli.wizard.prompt.template_id"},
1820 "required": true,
1821 "default": "component-v0_6",
1822 "choices": template_choices
1823 }));
1824 }
1825 questions
1826}
1827
1828fn available_template_ids() -> Vec<String> {
1829 vec!["component-v0_6".to_string()]
1830}
1831
1832fn default_template_id() -> String {
1833 available_template_ids()
1834 .into_iter()
1835 .next()
1836 .unwrap_or_else(|| "component-v0_6".to_string())
1837}
1838
1839fn mode_name(mode: RunMode) -> &'static str {
1840 match mode {
1841 RunMode::Create => "create",
1842 RunMode::AddOperation => "add_operation",
1843 RunMode::UpdateOperation => "update_operation",
1844 RunMode::BuildTest => "build_test",
1845 RunMode::Doctor => "doctor",
1846 }
1847}
1848
1849enum InteractiveAnswer {
1850 Value(JsonValue),
1851 Back,
1852 MainMenu,
1853}
1854
1855fn prompt_for_wizard_answer(
1856 question_id: &str,
1857 question: &JsonValue,
1858 fallback_default: Option<JsonValue>,
1859) -> Result<InteractiveAnswer, QaLibError> {
1860 let title = question
1861 .get("title")
1862 .and_then(JsonValue::as_str)
1863 .unwrap_or(question_id);
1864 let required = question
1865 .get("required")
1866 .and_then(JsonValue::as_bool)
1867 .unwrap_or(false);
1868 let kind = question
1869 .get("type")
1870 .and_then(JsonValue::as_str)
1871 .unwrap_or("string");
1872 let default_owned = question.get("default").cloned().or(fallback_default);
1873 let default = default_owned.as_ref();
1874
1875 match kind {
1876 "string" if question_id == "component_name" => {
1877 prompt_component_name_value(title, required, default)
1878 }
1879 "string" => prompt_string_value(title, required, default),
1880 "boolean" => prompt_bool_value(title, required, default),
1881 "enum" => prompt_enum_value(question_id, title, required, question, default),
1882 _ => prompt_string_value(title, required, default),
1883 }
1884}
1885
1886fn prompt_component_name_value(
1887 title: &str,
1888 required: bool,
1889 default: Option<&JsonValue>,
1890) -> Result<InteractiveAnswer, QaLibError> {
1891 loop {
1892 let value = prompt_string_value(title, required, default)?;
1893 let InteractiveAnswer::Value(value) = value else {
1894 return Ok(value);
1895 };
1896 let Some(name) = value.as_str() else {
1897 return Ok(InteractiveAnswer::Value(value));
1898 };
1899 match ComponentName::parse(name) {
1900 Ok(_) => return Ok(InteractiveAnswer::Value(value)),
1901 Err(err) => println!("{}", err),
1902 }
1903 }
1904}
1905
1906fn prompt_path(label: String, default: Option<String>) -> Result<PathBuf> {
1907 loop {
1908 if let Some(value) = &default {
1909 print!("{label} [{value}]: ");
1910 } else {
1911 print!("{label}: ");
1912 }
1913 io::stdout().flush()?;
1914 let mut input = String::new();
1915 let read = io::stdin().read_line(&mut input)?;
1916 if read == 0 {
1917 bail!("{}", tr("cli.wizard.error.stdin_closed"));
1918 }
1919 let trimmed = input.trim();
1920 if trimmed.is_empty()
1921 && let Some(value) = &default
1922 {
1923 return Ok(PathBuf::from(value));
1924 }
1925 if !trimmed.is_empty() {
1926 return Ok(PathBuf::from(trimmed));
1927 }
1928 println!("{}", tr("cli.wizard.result.qa_value_required"));
1929 }
1930}
1931
1932fn path_exists_and_non_empty(path: &PathBuf) -> Result<bool> {
1933 if !path.exists() {
1934 return Ok(false);
1935 }
1936 if !path.is_dir() {
1937 return Ok(true);
1938 }
1939 let mut entries = fs::read_dir(path)
1940 .with_context(|| format!("failed to read output directory {}", path.display()))?;
1941 Ok(entries.next().is_some())
1942}
1943
1944fn validate_output_path_available(path: &PathBuf) -> Result<()> {
1945 if !path.exists() {
1946 return Ok(());
1947 }
1948 if !path.is_dir() {
1949 bail!(
1950 "{}",
1951 trf(
1952 "cli.wizard.error.target_path_not_directory",
1953 &[path.display().to_string().as_str()]
1954 )
1955 );
1956 }
1957 if path_exists_and_non_empty(path)? {
1958 bail!(
1959 "{}",
1960 trf(
1961 "cli.wizard.error.target_dir_not_empty",
1962 &[path.display().to_string().as_str()]
1963 )
1964 );
1965 }
1966 Ok(())
1967}
1968
1969fn prompt_yes_no(prompt: String, default_yes: bool) -> Result<InteractiveAnswer> {
1970 let suffix = if default_yes { "[Y/n]" } else { "[y/N]" };
1971 loop {
1972 print!("{prompt} {suffix}: ");
1973 io::stdout().flush()?;
1974 let mut line = String::new();
1975 let read = io::stdin().read_line(&mut line)?;
1976 if read == 0 {
1977 bail!("{}", tr("cli.wizard.error.stdin_closed"));
1978 }
1979 let token = line.trim().to_ascii_lowercase();
1980 if token == "0" {
1981 return Ok(InteractiveAnswer::Back);
1982 }
1983 if token == "m" {
1984 return Ok(InteractiveAnswer::MainMenu);
1985 }
1986 if token.is_empty() {
1987 return Ok(InteractiveAnswer::Value(JsonValue::Bool(default_yes)));
1988 }
1989 match token.as_str() {
1990 "y" | "yes" => return Ok(InteractiveAnswer::Value(JsonValue::Bool(true))),
1991 "n" | "no" => return Ok(InteractiveAnswer::Value(JsonValue::Bool(false))),
1992 _ => println!("{}", tr("cli.wizard.result.qa_answer_yes_no")),
1993 }
1994 }
1995}
1996
1997fn prompt_main_menu_mode(default: RunMode) -> Result<Option<RunMode>> {
1998 println!("{}", tr("cli.wizard.result.interactive_header"));
1999 println!("1) {}", tr("cli.wizard.menu.create_new_component"));
2000 println!("2) {}", tr("cli.wizard.menu.add_operation"));
2001 println!("3) {}", tr("cli.wizard.menu.update_operation"));
2002 println!("4) {}", tr("cli.wizard.menu.build_and_test_component"));
2003 println!("5) {}", tr("cli.wizard.menu.doctor_component"));
2004 println!("0) exit");
2005 let default_label = match default {
2006 RunMode::Create => "1",
2007 RunMode::AddOperation => "2",
2008 RunMode::UpdateOperation => "3",
2009 RunMode::BuildTest => "4",
2010 RunMode::Doctor => "5",
2011 };
2012 loop {
2013 print!(
2014 "{} ",
2015 trf("cli.wizard.prompt.select_option", &[default_label])
2016 );
2017 io::stdout().flush()?;
2018 let mut line = String::new();
2019 let read = io::stdin().read_line(&mut line)?;
2020 if read == 0 {
2021 bail!("{}", tr("cli.wizard.error.stdin_closed"));
2022 }
2023 let token = line.trim().to_ascii_lowercase();
2024 if token == "0" {
2025 return Ok(None);
2026 }
2027 if token == "m" {
2028 continue;
2029 }
2030 let selected = if token.is_empty() {
2031 default_label.to_string()
2032 } else {
2033 token
2034 };
2035 if let Some(mode) = parse_main_menu_selection(&selected) {
2036 return Ok(Some(mode));
2037 }
2038 println!("{}", tr("cli.wizard.result.qa_value_required"));
2039 }
2040}
2041
2042fn parse_main_menu_selection(value: &str) -> Option<RunMode> {
2043 match value.trim().to_ascii_lowercase().as_str() {
2044 "1" | "create" => Some(RunMode::Create),
2045 "2" | "add-operation" | "add_operation" => Some(RunMode::AddOperation),
2046 "3" | "update-operation" | "update_operation" => Some(RunMode::UpdateOperation),
2047 "4" | "build" | "build-test" | "build_test" => Some(RunMode::BuildTest),
2048 "5" | "doctor" => Some(RunMode::Doctor),
2049 _ => None,
2050 }
2051}
2052
2053fn fallback_default_for_question(
2054 args: &WizardArgs,
2055 question_id: &str,
2056 answered: &JsonMap<String, JsonValue>,
2057) -> Option<JsonValue> {
2058 match (args.mode, question_id) {
2059 (RunMode::Create, "component_name") => Some(JsonValue::String("component".to_string())),
2060 (RunMode::Create, "output_dir") => {
2061 let name = answered
2062 .get("component_name")
2063 .and_then(JsonValue::as_str)
2064 .unwrap_or("component");
2065 Some(JsonValue::String(
2066 args.project_root.join(name).display().to_string(),
2067 ))
2068 }
2069 (RunMode::Create, "advanced_setup") => Some(JsonValue::Bool(false)),
2070 (RunMode::Create, "secrets_enabled") => Some(JsonValue::Bool(false)),
2071 (RunMode::Create, "abi_version") => Some(JsonValue::String("0.6.0".to_string())),
2072 (RunMode::Create, "operation_names") | (RunMode::Create, "primary_operation_name") => {
2073 Some(JsonValue::String("handle_message".to_string()))
2074 }
2075 (RunMode::Create, "template_id") => Some(JsonValue::String(default_template_id())),
2076 (RunMode::AddOperation, "project_root")
2077 | (RunMode::UpdateOperation, "project_root")
2078 | (RunMode::BuildTest, "project_root")
2079 | (RunMode::Doctor, "project_root") => {
2080 Some(JsonValue::String(args.project_root.display().to_string()))
2081 }
2082 (RunMode::AddOperation, "set_default_operation")
2083 | (RunMode::UpdateOperation, "set_default_operation") => Some(JsonValue::Bool(false)),
2084 (RunMode::BuildTest, "full_tests") => Some(JsonValue::Bool(args.full_tests)),
2085 _ => None,
2086 }
2087}
2088
2089fn is_secret_question(question_id: &str) -> bool {
2090 matches!(
2091 question_id,
2092 "secret_keys" | "secret_env" | "secret_tenant" | "secret_format"
2093 )
2094}
2095
2096fn should_skip_create_advanced_question(
2097 question_id: &str,
2098 answered: &JsonMap<String, JsonValue>,
2099) -> bool {
2100 if answered.contains_key(question_id) {
2101 return true;
2102 }
2103 if question_id == "filesystem_mounts"
2104 && answered
2105 .get("filesystem_mode")
2106 .and_then(JsonValue::as_str)
2107 .is_some_and(|mode| mode == "none")
2108 {
2109 return true;
2110 }
2111 is_secret_question(question_id)
2112 && !answered
2113 .get("secrets_enabled")
2114 .and_then(JsonValue::as_bool)
2115 .unwrap_or(false)
2116}
2117
2118fn prompt_string_value(
2119 title: &str,
2120 required: bool,
2121 default: Option<&JsonValue>,
2122) -> Result<InteractiveAnswer, QaLibError> {
2123 let default_text = default.and_then(JsonValue::as_str);
2124 loop {
2125 if let Some(value) = default_text {
2126 print!("{title} [{value}]: ");
2127 } else {
2128 print!("{title}: ");
2129 }
2130 io::stdout()
2131 .flush()
2132 .map_err(|err| QaLibError::Component(err.to_string()))?;
2133 let mut input = String::new();
2134 let read = io::stdin()
2135 .read_line(&mut input)
2136 .map_err(|err| QaLibError::Component(err.to_string()))?;
2137 if read == 0 {
2138 return Err(QaLibError::Component(tr("cli.wizard.error.stdin_closed")));
2139 }
2140 let trimmed = input.trim();
2141 if trimmed.eq_ignore_ascii_case("m") {
2142 return Ok(InteractiveAnswer::MainMenu);
2143 }
2144 if trimmed == "0" {
2145 return Ok(InteractiveAnswer::Back);
2146 }
2147 if trimmed.is_empty() {
2148 if let Some(value) = default_text {
2149 return Ok(InteractiveAnswer::Value(JsonValue::String(
2150 value.to_string(),
2151 )));
2152 }
2153 if required {
2154 println!("{}", tr("cli.wizard.result.qa_value_required"));
2155 continue;
2156 }
2157 return Ok(InteractiveAnswer::Value(JsonValue::Null));
2158 }
2159 return Ok(InteractiveAnswer::Value(JsonValue::String(
2160 trimmed.to_string(),
2161 )));
2162 }
2163}
2164
2165fn prompt_bool_value(
2166 title: &str,
2167 required: bool,
2168 default: Option<&JsonValue>,
2169) -> Result<InteractiveAnswer, QaLibError> {
2170 let default_bool = default.and_then(JsonValue::as_bool);
2171 loop {
2172 let suffix = match default_bool {
2173 Some(true) => "[Y/n]",
2174 Some(false) => "[y/N]",
2175 None => "[y/n]",
2176 };
2177 print!("{title} {suffix}: ");
2178 io::stdout()
2179 .flush()
2180 .map_err(|err| QaLibError::Component(err.to_string()))?;
2181 let mut input = String::new();
2182 let read = io::stdin()
2183 .read_line(&mut input)
2184 .map_err(|err| QaLibError::Component(err.to_string()))?;
2185 if read == 0 {
2186 return Err(QaLibError::Component(tr("cli.wizard.error.stdin_closed")));
2187 }
2188 let trimmed = input.trim().to_ascii_lowercase();
2189 if trimmed == "m" {
2190 return Ok(InteractiveAnswer::MainMenu);
2191 }
2192 if trimmed == "0" {
2193 return Ok(InteractiveAnswer::Back);
2194 }
2195 if trimmed.is_empty() {
2196 if let Some(value) = default_bool {
2197 return Ok(InteractiveAnswer::Value(JsonValue::Bool(value)));
2198 }
2199 if required {
2200 println!("{}", tr("cli.wizard.result.qa_value_required"));
2201 continue;
2202 }
2203 return Ok(InteractiveAnswer::Value(JsonValue::Null));
2204 }
2205 match trimmed.as_str() {
2206 "y" | "yes" | "true" | "1" => {
2207 return Ok(InteractiveAnswer::Value(JsonValue::Bool(true)));
2208 }
2209 "n" | "no" | "false" => return Ok(InteractiveAnswer::Value(JsonValue::Bool(false))),
2210 _ => println!("{}", tr("cli.wizard.result.qa_answer_yes_no")),
2211 }
2212 }
2213}
2214
2215fn prompt_enum_value(
2216 question_id: &str,
2217 title: &str,
2218 required: bool,
2219 question: &JsonValue,
2220 default: Option<&JsonValue>,
2221) -> Result<InteractiveAnswer, QaLibError> {
2222 let choices = question
2223 .get("choices")
2224 .and_then(JsonValue::as_array)
2225 .ok_or_else(|| QaLibError::MissingField("choices".to_string()))?
2226 .iter()
2227 .filter_map(JsonValue::as_str)
2228 .map(ToString::to_string)
2229 .collect::<Vec<_>>();
2230 let default_text = default.and_then(JsonValue::as_str);
2231 if choices.is_empty() {
2232 return Err(QaLibError::MissingField("choices".to_string()));
2233 }
2234 loop {
2235 println!("{title}:");
2236 for (idx, choice) in choices.iter().enumerate() {
2237 println!(" {}. {}", idx + 1, enum_choice_label(question_id, choice));
2238 }
2239 if let Some(value) = default_text {
2240 print!(
2241 "{} [{value}] ",
2242 tr("cli.wizard.result.qa_select_number_or_value")
2243 );
2244 } else {
2245 print!("{} ", tr("cli.wizard.result.qa_select_number_or_value"));
2246 }
2247 io::stdout()
2248 .flush()
2249 .map_err(|err| QaLibError::Component(err.to_string()))?;
2250 let mut input = String::new();
2251 let read = io::stdin()
2252 .read_line(&mut input)
2253 .map_err(|err| QaLibError::Component(err.to_string()))?;
2254 if read == 0 {
2255 return Err(QaLibError::Component(tr("cli.wizard.error.stdin_closed")));
2256 }
2257 let trimmed = input.trim();
2258 if trimmed.eq_ignore_ascii_case("m") {
2259 return Ok(InteractiveAnswer::MainMenu);
2260 }
2261 if trimmed == "0" {
2262 return Ok(InteractiveAnswer::Back);
2263 }
2264 if trimmed.is_empty() {
2265 if let Some(value) = default_text {
2266 return Ok(InteractiveAnswer::Value(JsonValue::String(
2267 value.to_string(),
2268 )));
2269 }
2270 if required {
2271 println!("{}", tr("cli.wizard.result.qa_value_required"));
2272 continue;
2273 }
2274 return Ok(InteractiveAnswer::Value(JsonValue::Null));
2275 }
2276 if let Ok(n) = trimmed.parse::<usize>()
2277 && n > 0
2278 && n <= choices.len()
2279 {
2280 return Ok(InteractiveAnswer::Value(JsonValue::String(
2281 choices[n - 1].clone(),
2282 )));
2283 }
2284 if choices.iter().any(|choice| choice == trimmed) {
2285 return Ok(InteractiveAnswer::Value(JsonValue::String(
2286 trimmed.to_string(),
2287 )));
2288 }
2289 println!("{}", tr("cli.wizard.result.qa_invalid_choice"));
2290 }
2291}
2292
2293fn enum_choice_label<'a>(question_id: &str, choice: &'a str) -> &'a str {
2294 let _ = question_id;
2295 choice
2296}
2297
2298fn collect_interactive_question_map(
2299 args: &WizardArgs,
2300 questions: Vec<JsonValue>,
2301) -> Result<Option<JsonMap<String, JsonValue>>> {
2302 collect_interactive_question_map_with_answers(args, questions, JsonMap::new())
2303}
2304
2305fn collect_interactive_question_map_with_answers(
2306 args: &WizardArgs,
2307 questions: Vec<JsonValue>,
2308 answered: JsonMap<String, JsonValue>,
2309) -> Result<Option<JsonMap<String, JsonValue>>> {
2310 collect_interactive_question_map_with_skip(
2311 args,
2312 questions,
2313 answered,
2314 |_question_id, _answered| false,
2315 )
2316}
2317
2318fn collect_interactive_question_map_with_skip(
2319 args: &WizardArgs,
2320 questions: Vec<JsonValue>,
2321 mut answered: JsonMap<String, JsonValue>,
2322 should_skip: fn(&str, &JsonMap<String, JsonValue>) -> bool,
2323) -> Result<Option<JsonMap<String, JsonValue>>> {
2324 let mut index = 0usize;
2325 while index < questions.len() {
2326 let question = &questions[index];
2327 let question_id = question
2328 .get("id")
2329 .and_then(JsonValue::as_str)
2330 .ok_or_else(|| anyhow!("{}", tr("cli.wizard.error.create_missing_question_id")))?
2331 .to_string();
2332
2333 if should_skip(&question_id, &answered) {
2334 index += 1;
2335 continue;
2336 }
2337
2338 match prompt_for_wizard_answer(
2339 &question_id,
2340 question,
2341 fallback_default_for_question(args, &question_id, &answered),
2342 )
2343 .map_err(|err| anyhow!("{err}"))?
2344 {
2345 InteractiveAnswer::MainMenu => return Ok(None),
2346 InteractiveAnswer::Back => {
2347 if let Some(previous) =
2348 previous_interactive_question_index(&questions, index, &answered, should_skip)
2349 {
2350 if let Some(previous_id) =
2351 questions[previous].get("id").and_then(JsonValue::as_str)
2352 {
2353 answered.remove(previous_id);
2354 if previous_id == "output_dir" {
2355 answered.remove("overwrite_output");
2356 }
2357 }
2358 index = previous;
2359 }
2360 }
2361 InteractiveAnswer::Value(answer) => {
2362 let mut advance = true;
2363 if question_id == "output_dir"
2364 && let Some(path) = answer.as_str()
2365 {
2366 let path = PathBuf::from(path);
2367 if path_exists_and_non_empty(&path)? {
2368 match prompt_yes_no(
2369 trf(
2370 "cli.wizard.prompt.overwrite_dir",
2371 &[path.to_string_lossy().as_ref()],
2372 ),
2373 false,
2374 )? {
2375 InteractiveAnswer::MainMenu => return Ok(None),
2376 InteractiveAnswer::Back => {
2377 if let Some(previous) = previous_interactive_question_index(
2378 &questions,
2379 index,
2380 &answered,
2381 should_skip,
2382 ) {
2383 if let Some(previous_id) =
2384 questions[previous].get("id").and_then(JsonValue::as_str)
2385 {
2386 answered.remove(previous_id);
2387 if previous_id == "output_dir" {
2388 answered.remove("overwrite_output");
2389 }
2390 }
2391 index = previous;
2392 }
2393 advance = false;
2394 }
2395 InteractiveAnswer::Value(JsonValue::Bool(true)) => {
2396 answered
2397 .insert("overwrite_output".to_string(), JsonValue::Bool(true));
2398 }
2399 InteractiveAnswer::Value(JsonValue::Bool(false)) => {
2400 println!("{}", tr("cli.wizard.result.choose_another_output_dir"));
2401 advance = false;
2402 }
2403 InteractiveAnswer::Value(_) => {
2404 advance = false;
2405 }
2406 }
2407 }
2408 }
2409 if advance {
2410 answered.insert(question_id, answer);
2411 index += 1;
2412 }
2413 }
2414 }
2415 }
2416 Ok(Some(answered))
2417}
2418
2419fn previous_interactive_question_index(
2420 questions: &[JsonValue],
2421 current: usize,
2422 answered: &JsonMap<String, JsonValue>,
2423 should_skip: fn(&str, &JsonMap<String, JsonValue>) -> bool,
2424) -> Option<usize> {
2425 if current == 0 {
2426 return None;
2427 }
2428 for idx in (0..current).rev() {
2429 let question_id = questions[idx]
2430 .get("id")
2431 .and_then(JsonValue::as_str)
2432 .unwrap_or_default();
2433 if !should_skip(question_id, answered) {
2434 return Some(idx);
2435 }
2436 }
2437 None
2438}
2439
2440fn tr(key: &str) -> String {
2441 i18n::tr_key(key)
2442}
2443
2444fn trf(key: &str, args: &[&str]) -> String {
2445 let mut msg = tr(key);
2446 for arg in args {
2447 msg = msg.replacen("{}", arg, 1);
2448 }
2449 msg
2450}
2451
2452#[cfg(test)]
2453mod tests {
2454 use serde_json::{Map as JsonMap, Value as JsonValue};
2455
2456 use super::{
2457 RunMode, WizardArgs, create_questions, fallback_default_for_question,
2458 parse_main_menu_selection, should_skip_create_advanced_question,
2459 };
2460
2461 #[test]
2462 fn parse_main_menu_selection_supports_numeric_options() {
2463 assert_eq!(parse_main_menu_selection("1"), Some(RunMode::Create));
2464 assert_eq!(parse_main_menu_selection("2"), Some(RunMode::AddOperation));
2465 assert_eq!(
2466 parse_main_menu_selection("3"),
2467 Some(RunMode::UpdateOperation)
2468 );
2469 assert_eq!(parse_main_menu_selection("4"), Some(RunMode::BuildTest));
2470 assert_eq!(parse_main_menu_selection("5"), Some(RunMode::Doctor));
2471 }
2472
2473 #[test]
2474 fn parse_main_menu_selection_supports_mode_aliases() {
2475 assert_eq!(parse_main_menu_selection("create"), Some(RunMode::Create));
2476 assert_eq!(
2477 parse_main_menu_selection("add_operation"),
2478 Some(RunMode::AddOperation)
2479 );
2480 assert_eq!(
2481 parse_main_menu_selection("update-operation"),
2482 Some(RunMode::UpdateOperation)
2483 );
2484 assert_eq!(
2485 parse_main_menu_selection("build_test"),
2486 Some(RunMode::BuildTest)
2487 );
2488 assert_eq!(
2489 parse_main_menu_selection("build-test"),
2490 Some(RunMode::BuildTest)
2491 );
2492 assert_eq!(parse_main_menu_selection("doctor"), Some(RunMode::Doctor));
2493 }
2494
2495 #[test]
2496 fn parse_main_menu_selection_rejects_unknown_values() {
2497 assert_eq!(parse_main_menu_selection(""), None);
2498 assert_eq!(parse_main_menu_selection("6"), None);
2499 assert_eq!(parse_main_menu_selection("unknown"), None);
2500 }
2501
2502 #[test]
2503 fn create_questions_minimal_flow_only_asks_core_fields() {
2504 let args = WizardArgs {
2505 mode: RunMode::Create,
2506 execution: super::ExecutionMode::Execute,
2507 dry_run: false,
2508 validate: false,
2509 apply: false,
2510 qa_answers: None,
2511 answers: None,
2512 qa_answers_out: None,
2513 emit_answers: None,
2514 schema_version: None,
2515 migrate: false,
2516 plan_out: None,
2517 project_root: std::path::PathBuf::from("."),
2518 template: None,
2519 full_tests: false,
2520 json: false,
2521 };
2522
2523 let questions = create_questions(&args, false);
2524 let ids = questions
2525 .iter()
2526 .filter_map(|question| question.get("id").and_then(JsonValue::as_str))
2527 .collect::<Vec<_>>();
2528 assert_eq!(ids, vec!["component_name", "output_dir", "advanced_setup"]);
2529 }
2530
2531 #[test]
2532 fn create_flow_defaults_advanced_setup_to_false() {
2533 let args = WizardArgs {
2534 mode: RunMode::Create,
2535 execution: super::ExecutionMode::Execute,
2536 dry_run: false,
2537 validate: false,
2538 apply: false,
2539 qa_answers: None,
2540 answers: None,
2541 qa_answers_out: None,
2542 emit_answers: None,
2543 schema_version: None,
2544 migrate: false,
2545 plan_out: None,
2546 project_root: std::path::PathBuf::from("/tmp/demo"),
2547 template: None,
2548 full_tests: false,
2549 json: false,
2550 };
2551
2552 assert_eq!(
2553 fallback_default_for_question(&args, "advanced_setup", &serde_json::Map::new()),
2554 Some(JsonValue::Bool(false))
2555 );
2556 assert_eq!(
2557 fallback_default_for_question(&args, "secrets_enabled", &serde_json::Map::new()),
2558 Some(JsonValue::Bool(false))
2559 );
2560 }
2561
2562 #[test]
2563 fn create_questions_advanced_flow_includes_secret_gate_before_secret_fields() {
2564 let args = WizardArgs {
2565 mode: RunMode::Create,
2566 execution: super::ExecutionMode::Execute,
2567 dry_run: false,
2568 validate: false,
2569 apply: false,
2570 qa_answers: None,
2571 answers: None,
2572 qa_answers_out: None,
2573 emit_answers: None,
2574 schema_version: None,
2575 migrate: false,
2576 plan_out: None,
2577 project_root: std::path::PathBuf::from("."),
2578 template: None,
2579 full_tests: false,
2580 json: false,
2581 };
2582
2583 let questions = create_questions(&args, true);
2584 let ids = questions
2585 .iter()
2586 .filter_map(|question| question.get("id").and_then(JsonValue::as_str))
2587 .collect::<Vec<_>>();
2588 let gate_index = ids.iter().position(|id| *id == "secrets_enabled").unwrap();
2589 let key_index = ids.iter().position(|id| *id == "secret_keys").unwrap();
2590 assert!(gate_index < key_index);
2591 }
2592
2593 #[test]
2594 fn create_questions_advanced_flow_includes_messaging_and_events_fields() {
2595 let args = WizardArgs {
2596 mode: RunMode::Create,
2597 execution: super::ExecutionMode::Execute,
2598 dry_run: false,
2599 validate: false,
2600 apply: false,
2601 qa_answers: None,
2602 answers: None,
2603 qa_answers_out: None,
2604 emit_answers: None,
2605 schema_version: None,
2606 migrate: false,
2607 plan_out: None,
2608 project_root: std::path::PathBuf::from("."),
2609 template: None,
2610 full_tests: false,
2611 json: false,
2612 };
2613
2614 let questions = create_questions(&args, true);
2615 let ids = questions
2616 .iter()
2617 .filter_map(|question| question.get("id").and_then(JsonValue::as_str))
2618 .collect::<Vec<_>>();
2619 assert!(ids.contains(&"messaging_inbound"));
2620 assert!(ids.contains(&"messaging_outbound"));
2621 assert!(ids.contains(&"events_inbound"));
2622 assert!(ids.contains(&"events_outbound"));
2623 }
2624
2625 #[test]
2626 fn advanced_create_flow_skips_questions_answered_in_minimal_pass() {
2627 let mut answered = JsonMap::new();
2628 answered.insert(
2629 "component_name".to_string(),
2630 JsonValue::String("demo".to_string()),
2631 );
2632 answered.insert(
2633 "output_dir".to_string(),
2634 JsonValue::String("/tmp/demo".to_string()),
2635 );
2636 answered.insert("advanced_setup".to_string(), JsonValue::Bool(true));
2637
2638 assert!(should_skip_create_advanced_question(
2639 "component_name",
2640 &answered
2641 ));
2642 assert!(should_skip_create_advanced_question(
2643 "output_dir",
2644 &answered
2645 ));
2646 assert!(should_skip_create_advanced_question(
2647 "advanced_setup",
2648 &answered
2649 ));
2650 assert!(!should_skip_create_advanced_question(
2651 "operation_names",
2652 &answered
2653 ));
2654 }
2655
2656 #[test]
2657 fn advanced_create_flow_skips_filesystem_mounts_when_mode_is_none() {
2658 let mut answered = JsonMap::new();
2659 answered.insert(
2660 "filesystem_mode".to_string(),
2661 JsonValue::String("none".to_string()),
2662 );
2663
2664 assert!(should_skip_create_advanced_question(
2665 "filesystem_mounts",
2666 &answered
2667 ));
2668
2669 answered.insert(
2670 "filesystem_mode".to_string(),
2671 JsonValue::String("sandbox".to_string()),
2672 );
2673
2674 assert!(!should_skip_create_advanced_question(
2675 "filesystem_mounts",
2676 &answered
2677 ));
2678 }
2679}