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::{ArgMatches, 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, ValidationError, 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
208pub(crate) fn maybe_run_schema_from_matches(matches: &ArgMatches) -> Option<Result<()>> {
209 let (subcommand, wizard_matches) = matches.subcommand()?;
210 if subcommand != "wizard" {
211 return None;
212 }
213 if !wizard_matches.get_flag("schema") {
214 return None;
215 }
216
217 let args = WizardArgs {
218 mode: wizard_matches
219 .get_one::<RunMode>("mode")
220 .copied()
221 .unwrap_or(RunMode::Create),
222 execution: wizard_matches
223 .get_one::<ExecutionMode>("execution")
224 .copied()
225 .unwrap_or(ExecutionMode::Execute),
226 dry_run: wizard_matches.get_flag("dry_run"),
227 validate: wizard_matches.get_flag("validate"),
228 apply: wizard_matches.get_flag("apply"),
229 qa_answers: wizard_matches.get_one::<PathBuf>("qa_answers").cloned(),
230 answers: wizard_matches.get_one::<PathBuf>("answers").cloned(),
231 qa_answers_out: wizard_matches.get_one::<PathBuf>("qa_answers_out").cloned(),
232 emit_answers: wizard_matches.get_one::<PathBuf>("emit_answers").cloned(),
233 schema_version: wizard_matches.get_one::<String>("schema_version").cloned(),
234 migrate: wizard_matches.get_flag("migrate"),
235 plan_out: wizard_matches.get_one::<PathBuf>("plan_out").cloned(),
236 project_root: wizard_matches
237 .get_one::<PathBuf>("project_root")
238 .cloned()
239 .unwrap_or_else(|| PathBuf::from(".")),
240 template: wizard_matches.get_one::<String>("template").cloned(),
241 full_tests: wizard_matches.get_flag("full_tests"),
242 json: wizard_matches.get_flag("json"),
243 };
244
245 let schema =
246 serde_json::to_string_pretty(&wizard_answer_schema(&args)).map_err(anyhow::Error::from);
247 Some(schema.map(|schema| {
248 println!("{schema}");
249 }))
250}
251
252fn is_interactive_session() -> bool {
253 if std::env::var_os("GREENTIC_FORCE_NONINTERACTIVE").is_some() {
254 return false;
255 }
256 let running_cli_binary = std::env::current_exe()
257 .ok()
258 .and_then(|path| {
259 path.file_stem()
260 .map(|stem| stem.to_string_lossy().into_owned())
261 })
262 .is_some_and(|stem| stem == "greentic-component");
263 if !running_cli_binary {
264 return false;
265 }
266 io::stdin().is_terminal() && io::stdout().is_terminal()
267}
268
269fn run_with_context(
270 args: WizardArgs,
271 execution_override: Option<ExecutionMode>,
272 legacy_new: Option<WizardLegacyNewCompat>,
273) -> Result<()> {
274 let mut args = args;
275 let interactive = is_interactive_session();
276 if args.validate && args.apply {
277 bail!("{}", tr("cli.wizard.result.validate_apply_conflict"));
278 }
279
280 let mut execution = if args.dry_run {
281 ExecutionMode::DryRun
282 } else {
283 args.execution
284 };
285 if let Some(override_mode) = execution_override {
286 execution = override_mode;
287 }
288
289 let input_answers = args.answers.as_ref().or(args.qa_answers.as_ref());
290 let loaded_answers = match input_answers {
291 Some(path) => load_answers_with_recovery(Some(path), &args, interactive, |line| {
292 println!("{line}");
293 })?,
294 None => None,
295 };
296 let mut answers = loaded_answers
297 .as_ref()
298 .map(|loaded| loaded.run_answers.clone());
299 if args.validate {
300 execution = ExecutionMode::DryRun;
301 } else if args.apply {
302 execution = ExecutionMode::Execute;
303 }
304
305 apply_legacy_wizard_new_compat(legacy_new, &mut args, &mut answers)?;
306
307 if answers.is_none() && interactive {
308 return run_interactive_loop(args, execution);
309 }
310
311 if let Some(doc) = &answers
312 && doc.mode != args.mode
313 {
314 if args.mode == RunMode::Create {
315 args.mode = doc.mode;
316 } else if interactive {
317 report_interactive_validation_error(
318 &anyhow!(
319 "{}",
320 trf(
321 "cli.wizard.result.answers_mode_mismatch",
322 &[&format!("{:?}", doc.mode), &format!("{:?}", args.mode)],
323 )
324 ),
325 |line| println!("{line}"),
326 );
327 return run_interactive_loop(args, execution);
328 } else {
329 bail!(
330 "{}",
331 trf(
332 "cli.wizard.result.answers_mode_mismatch",
333 &[&format!("{:?}", doc.mode), &format!("{:?}", args.mode)],
334 )
335 );
336 }
337 }
338
339 let Some(output) =
340 build_output_with_recovery(&args, execution, answers.as_ref(), interactive, |line| {
341 println!("{line}")
342 })?
343 else {
344 return run_interactive_loop(args, execution);
345 };
346
347 if let Some(path) = &args.qa_answers_out {
348 let doc = answers
349 .clone()
350 .unwrap_or_else(|| default_answers_for(&args));
351 let payload = serde_json::to_string_pretty(&doc)?;
352 write_json_file(path, &payload, "qa-answers-out")?;
353 }
354
355 if let Some(path) = &args.emit_answers {
356 let run_answers = answers
357 .clone()
358 .unwrap_or_else(|| default_answers_for(&args));
359 let source_document = loaded_answers
360 .as_ref()
361 .and_then(|loaded| loaded.source_document.clone());
362 let doc = answer_document_from_run_answers(&run_answers, &args, source_document);
363 let payload = serde_json::to_string_pretty(&doc)?;
364 write_json_file(path, &payload, "emit-answers")?;
365 }
366
367 match execution {
368 ExecutionMode::DryRun => {
369 let plan_out = resolve_plan_out(&args)?;
370 write_plan_json(&output.plan, &plan_out)?;
371 println!(
372 "{}",
373 trf(
374 "cli.wizard.result.plan_written",
375 &[plan_out.to_string_lossy().as_ref()],
376 )
377 );
378 }
379 ExecutionMode::Execute => {
380 execute_run_plan(&output.plan)?;
381 if args.mode == RunMode::Create {
382 println!(
383 "{}",
384 trf(
385 "cli.wizard.result.component_written",
386 &[output.plan.target_root.to_string_lossy().as_ref()],
387 )
388 );
389 } else {
390 println!("{}", tr("cli.wizard.result.execute_ok"));
391 }
392 }
393 }
394
395 if args.json {
396 let json = serde_json::to_string_pretty(&output)?;
397 println!("{json}");
398 }
399 Ok(())
400}
401
402fn run_interactive_loop(mut args: WizardArgs, execution: ExecutionMode) -> Result<()> {
403 loop {
404 let Some(mode) = prompt_main_menu_mode(args.mode)? else {
405 return Ok(());
406 };
407 args.mode = mode;
408
409 let Some(answers) = collect_interactive_answers(&args)? else {
410 continue;
411 };
412 let Some(output) =
413 build_output_with_recovery(&args, execution, Some(&answers), true, |line| {
414 println!("{line}");
415 })?
416 else {
417 continue;
418 };
419
420 match execution {
421 ExecutionMode::DryRun => {
422 let plan_out = resolve_plan_out(&args)?;
423 write_plan_json(&output.plan, &plan_out)?;
424 println!(
425 "{}",
426 trf(
427 "cli.wizard.result.plan_written",
428 &[plan_out.to_string_lossy().as_ref()],
429 )
430 );
431 }
432 ExecutionMode::Execute => {
433 execute_run_plan(&output.plan)?;
434 if args.mode == RunMode::Create {
435 println!(
436 "{}",
437 trf(
438 "cli.wizard.result.component_written",
439 &[output.plan.target_root.to_string_lossy().as_ref()],
440 )
441 );
442 } else {
443 println!("{}", tr("cli.wizard.result.execute_ok"));
444 }
445 }
446 }
447
448 if args.json {
449 let json = serde_json::to_string_pretty(&output)?;
450 println!("{json}");
451 }
452 }
453}
454
455fn apply_legacy_wizard_new_compat(
456 legacy_new: Option<WizardLegacyNewCompat>,
457 args: &mut WizardArgs,
458 answers: &mut Option<WizardRunAnswers>,
459) -> Result<()> {
460 let Some(legacy_new) = legacy_new else {
461 return Ok(());
462 };
463
464 let component_name = legacy_new.name.unwrap_or_else(|| "component".to_string());
465 ComponentName::parse(&component_name)?;
466 let output_parent = legacy_new.out.unwrap_or_else(|| args.project_root.clone());
467 let output_dir = output_parent.join(&component_name);
468
469 args.mode = RunMode::Create;
470 let mut doc = answers.take().unwrap_or_else(|| default_answers_for(args));
471 doc.mode = RunMode::Create;
472 doc.fields.insert(
473 "component_name".to_string(),
474 JsonValue::String(component_name),
475 );
476 doc.fields.insert(
477 "output_dir".to_string(),
478 JsonValue::String(output_dir.display().to_string()),
479 );
480 *answers = Some(doc);
481 Ok(())
482}
483
484fn build_run_output(
485 args: &WizardArgs,
486 execution: ExecutionMode,
487 answers: Option<&WizardRunAnswers>,
488) -> Result<WizardRunOutput> {
489 let mode = args.mode;
490
491 let (plan, warnings) = match mode {
492 RunMode::Create => build_create_plan(args, execution, answers)?,
493 RunMode::AddOperation => build_add_operation_plan(args, answers)?,
494 RunMode::UpdateOperation => build_update_operation_plan(args, answers)?,
495 RunMode::BuildTest => build_build_test_plan(args, answers),
496 RunMode::Doctor => build_doctor_plan(args, answers),
497 };
498
499 Ok(WizardRunOutput {
500 mode,
501 execution,
502 plan,
503 warnings,
504 })
505}
506
507fn resolve_plan_out(args: &WizardArgs) -> Result<PathBuf> {
508 if let Some(path) = &args.plan_out {
509 return Ok(path.clone());
510 }
511 if is_interactive_session() {
512 return prompt_path(
513 tr("cli.wizard.prompt.plan_out"),
514 Some("./answers.json".to_string()),
515 );
516 }
517 bail!(
518 "{}",
519 tr("cli.wizard.result.plan_out_required_non_interactive")
520 );
521}
522
523fn write_plan_json(plan: &WizardPlanEnvelope, path: &PathBuf) -> Result<()> {
524 let payload = serde_json::to_string_pretty(plan)?;
525 if let Some(parent) = path.parent()
526 && !parent.as_os_str().is_empty()
527 {
528 fs::create_dir_all(parent)
529 .with_context(|| format!("failed to create plan-out parent {}", parent.display()))?;
530 }
531 fs::write(path, payload).with_context(|| format!("failed to write plan {}", path.display()))
532}
533
534fn build_create_plan(
535 args: &WizardArgs,
536 execution: ExecutionMode,
537 answers: Option<&WizardRunAnswers>,
538) -> Result<(WizardPlanEnvelope, Vec<String>)> {
539 let fields = answers.map(|doc| &doc.fields);
540
541 let component_name = fields
542 .and_then(|f| f.get("component_name"))
543 .and_then(JsonValue::as_str)
544 .unwrap_or("component");
545 let component_name = ComponentName::parse(component_name)?.into_string();
546
547 let abi_version = fields
548 .and_then(|f| f.get("abi_version"))
549 .and_then(JsonValue::as_str)
550 .unwrap_or("0.6.0");
551 let abi_version = normalize_version(abi_version)?;
552
553 let output_dir = fields
554 .and_then(|f| f.get("output_dir"))
555 .and_then(JsonValue::as_str)
556 .map(PathBuf::from)
557 .unwrap_or_else(|| args.project_root.join(&component_name));
558
559 let overwrite_output = fields
560 .and_then(|f| f.get("overwrite_output"))
561 .and_then(JsonValue::as_bool)
562 .unwrap_or(false);
563
564 if overwrite_output {
565 if execution == ExecutionMode::Execute && output_dir.exists() {
566 fs::remove_dir_all(&output_dir).with_context(|| {
567 format!(
568 "failed to clear output directory before overwrite {}",
569 output_dir.display()
570 )
571 })?;
572 }
573 } else {
574 validate_output_path_available(&output_dir)?;
575 }
576
577 let template_id = args
578 .template
579 .clone()
580 .or_else(|| {
581 fields
582 .and_then(|f| f.get("template_id"))
583 .and_then(JsonValue::as_str)
584 .map(ToOwned::to_owned)
585 })
586 .unwrap_or_else(default_template_id);
587
588 let user_operations = parse_user_operations(fields)?;
589 let default_operation = parse_default_operation(fields, &user_operations);
590 let runtime_capabilities = parse_runtime_capabilities(fields)?;
591
592 let prefill = fields
593 .and_then(|f| f.get("prefill_answers"))
594 .filter(|value| value.is_object())
595 .map(|value| -> Result<AnswersPayload> {
596 let json = serde_json::to_string_pretty(value)?;
597 let cbor = greentic_types::cbor::canonical::to_canonical_cbor_allow_floats(value)
598 .map_err(|err| {
599 anyhow!(
600 "{}",
601 trf(
602 "cli.wizard.error.prefill_answers_encode",
603 &[&err.to_string()]
604 )
605 )
606 })?;
607 Ok(AnswersPayload { json, cbor })
608 })
609 .transpose()?;
610
611 let request = wizard::WizardRequest {
612 name: component_name,
613 abi_version,
614 mode: wizard::WizardMode::Default,
615 target: output_dir,
616 answers: prefill,
617 required_capabilities: Vec::new(),
618 provided_capabilities: Vec::new(),
619 user_operations,
620 default_operation,
621 runtime_capabilities,
622 config_schema: parse_config_schema(fields)?,
623 };
624
625 let result = wizard::apply_scaffold(request, true)?;
626 let mut warnings = result.warnings;
627 warnings.push(trf("cli.wizard.step.template_used", &[&template_id]));
628 Ok((result.plan, warnings))
629}
630
631fn build_add_operation_plan(
632 args: &WizardArgs,
633 answers: Option<&WizardRunAnswers>,
634) -> Result<(WizardPlanEnvelope, Vec<String>)> {
635 let fields = answers.map(|doc| &doc.fields);
636 let project_root = resolve_project_root(args, fields);
637 let manifest_path = project_root.join("component.manifest.json");
638 let lib_path = project_root.join("src/lib.rs");
639 let operation_name = fields
640 .and_then(|f| f.get("operation_name"))
641 .and_then(JsonValue::as_str)
642 .ok_or_else(|| anyhow!("{}", tr("cli.wizard.error.add_operation_name_required")))?;
643 let operation_name = normalize_operation_name(operation_name)?;
644
645 let mut manifest: JsonValue = serde_json::from_str(
646 &fs::read_to_string(&manifest_path)
647 .with_context(|| format!("failed to read {}", manifest_path.display()))?,
648 )
649 .with_context(|| format!("manifest {} must be valid JSON", manifest_path.display()))?;
650 let user_operations = add_operation_to_manifest(&mut manifest, &operation_name)?;
651 if fields
652 .and_then(|f| f.get("set_default_operation"))
653 .and_then(JsonValue::as_bool)
654 .unwrap_or(false)
655 {
656 manifest["default_operation"] = JsonValue::String(operation_name.clone());
657 }
658
659 let lib_source = fs::read_to_string(&lib_path)
660 .with_context(|| format!("failed to read {}", lib_path.display()))?;
661 let updated_lib = rewrite_lib_user_ops(&lib_source, &user_operations)?;
662
663 Ok((
664 write_files_plan(
665 "greentic.component.add_operation",
666 "mode-add-operation",
667 &project_root,
668 vec![
669 (
670 "component.manifest.json".to_string(),
671 serde_json::to_string_pretty(&manifest)?,
672 ),
673 ("src/lib.rs".to_string(), updated_lib),
674 ],
675 ),
676 Vec::new(),
677 ))
678}
679
680fn build_update_operation_plan(
681 args: &WizardArgs,
682 answers: Option<&WizardRunAnswers>,
683) -> Result<(WizardPlanEnvelope, Vec<String>)> {
684 let fields = answers.map(|doc| &doc.fields);
685 let project_root = resolve_project_root(args, fields);
686 let manifest_path = project_root.join("component.manifest.json");
687 let lib_path = project_root.join("src/lib.rs");
688 let operation_name = fields
689 .and_then(|f| f.get("operation_name"))
690 .and_then(JsonValue::as_str)
691 .ok_or_else(|| anyhow!("{}", tr("cli.wizard.error.update_operation_name_required")))?;
692 let operation_name = normalize_operation_name(operation_name)?;
693 let new_name = fields
694 .and_then(|f| f.get("new_operation_name"))
695 .and_then(JsonValue::as_str)
696 .filter(|value| !value.trim().is_empty())
697 .map(normalize_operation_name)
698 .transpose()?;
699
700 let mut manifest: JsonValue = serde_json::from_str(
701 &fs::read_to_string(&manifest_path)
702 .with_context(|| format!("failed to read {}", manifest_path.display()))?,
703 )
704 .with_context(|| format!("manifest {} must be valid JSON", manifest_path.display()))?;
705 let final_name =
706 update_operation_in_manifest(&mut manifest, &operation_name, new_name.as_deref())?;
707 if fields
708 .and_then(|f| f.get("set_default_operation"))
709 .and_then(JsonValue::as_bool)
710 .unwrap_or(false)
711 {
712 manifest["default_operation"] = JsonValue::String(final_name.clone());
713 }
714 let user_operations = collect_user_operation_names(&manifest)?;
715
716 let lib_source = fs::read_to_string(&lib_path)
717 .with_context(|| format!("failed to read {}", lib_path.display()))?;
718 let updated_lib = rewrite_lib_user_ops(&lib_source, &user_operations)?;
719
720 Ok((
721 write_files_plan(
722 "greentic.component.update_operation",
723 "mode-update-operation",
724 &project_root,
725 vec![
726 (
727 "component.manifest.json".to_string(),
728 serde_json::to_string_pretty(&manifest)?,
729 ),
730 ("src/lib.rs".to_string(), updated_lib),
731 ],
732 ),
733 Vec::new(),
734 ))
735}
736
737fn resolve_project_root(args: &WizardArgs, fields: Option<&JsonMap<String, JsonValue>>) -> PathBuf {
738 fields
739 .and_then(|f| f.get("project_root"))
740 .and_then(JsonValue::as_str)
741 .map(PathBuf::from)
742 .unwrap_or_else(|| args.project_root.clone())
743}
744
745fn normalize_operation_name(value: &str) -> Result<String> {
746 let trimmed = value.trim();
747 if trimmed.is_empty() {
748 bail!("{}", tr("cli.wizard.error.operation_name_empty"));
749 }
750 let is_valid = trimmed.chars().enumerate().all(|(idx, ch)| match idx {
751 0 => ch.is_ascii_lowercase(),
752 _ => ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '_' | '.' | ':' | '-'),
753 });
754 if !is_valid {
755 bail!(
756 "{}",
757 trf("cli.wizard.error.operation_name_invalid", &[trimmed])
758 );
759 }
760 Ok(trimmed.to_string())
761}
762
763fn parse_user_operations(fields: Option<&JsonMap<String, JsonValue>>) -> Result<Vec<String>> {
764 if let Some(csv) = fields
765 .and_then(|f| f.get("operation_names"))
766 .and_then(JsonValue::as_str)
767 .filter(|value| !value.trim().is_empty())
768 {
769 let parsed = parse_operation_names_csv(csv)?;
770 if !parsed.is_empty() {
771 return Ok(parsed);
772 }
773 }
774
775 let operations = fields
776 .and_then(|f| f.get("operations"))
777 .and_then(JsonValue::as_array)
778 .map(|values| {
779 values
780 .iter()
781 .filter_map(|value| match value {
782 JsonValue::String(name) => Some(name.clone()),
783 JsonValue::Object(map) => map
784 .get("name")
785 .and_then(JsonValue::as_str)
786 .map(ToOwned::to_owned),
787 _ => None,
788 })
789 .collect::<Vec<_>>()
790 })
791 .unwrap_or_default();
792 if !operations.is_empty() {
793 return operations
794 .into_iter()
795 .map(|name| normalize_operation_name(&name))
796 .collect();
797 }
798
799 if let Some(name) = fields
800 .and_then(|f| f.get("primary_operation_name"))
801 .and_then(JsonValue::as_str)
802 .filter(|value| !value.trim().is_empty())
803 {
804 return Ok(vec![normalize_operation_name(name)?]);
805 }
806
807 Ok(vec!["handle_message".to_string()])
808}
809
810fn parse_operation_names_csv(value: &str) -> Result<Vec<String>> {
811 value
812 .split(',')
813 .map(str::trim)
814 .filter(|entry| !entry.is_empty())
815 .map(normalize_operation_name)
816 .collect()
817}
818
819fn parse_default_operation(
820 fields: Option<&JsonMap<String, JsonValue>>,
821 user_operations: &[String],
822) -> Option<String> {
823 fields
824 .and_then(|f| f.get("default_operation"))
825 .and_then(JsonValue::as_str)
826 .and_then(|value| user_operations.iter().find(|name| name.as_str() == value))
827 .cloned()
828 .or_else(|| user_operations.first().cloned())
829}
830
831fn parse_runtime_capabilities(
832 fields: Option<&JsonMap<String, JsonValue>>,
833) -> Result<RuntimeCapabilitiesInput> {
834 let filesystem_mode = fields
835 .and_then(|f| f.get("filesystem_mode"))
836 .and_then(JsonValue::as_str)
837 .unwrap_or("none");
838 let telemetry_scope = fields
839 .and_then(|f| f.get("telemetry_scope"))
840 .and_then(JsonValue::as_str)
841 .unwrap_or("node");
842 let filesystem_mounts = parse_string_array(fields, "filesystem_mounts")
843 .into_iter()
844 .map(|value| parse_filesystem_mount(&value).map_err(anyhow::Error::from))
845 .collect::<Result<Vec<_>>>()?;
846 let telemetry_attributes =
847 parse_telemetry_attributes(&parse_string_array(fields, "telemetry_attributes"))
848 .map_err(anyhow::Error::from)?;
849 let telemetry_span_prefix = fields
850 .and_then(|f| f.get("telemetry_span_prefix"))
851 .and_then(JsonValue::as_str)
852 .map(str::trim)
853 .filter(|value| !value.is_empty())
854 .map(ToOwned::to_owned);
855
856 Ok(RuntimeCapabilitiesInput {
857 filesystem_mode: parse_filesystem_mode(filesystem_mode).map_err(anyhow::Error::from)?,
858 filesystem_mounts,
859 messaging_inbound: fields
860 .and_then(|f| f.get("messaging_inbound"))
861 .and_then(JsonValue::as_bool)
862 .unwrap_or(false),
863 messaging_outbound: fields
864 .and_then(|f| f.get("messaging_outbound"))
865 .and_then(JsonValue::as_bool)
866 .unwrap_or(false),
867 events_inbound: fields
868 .and_then(|f| f.get("events_inbound"))
869 .and_then(JsonValue::as_bool)
870 .unwrap_or(false),
871 events_outbound: fields
872 .and_then(|f| f.get("events_outbound"))
873 .and_then(JsonValue::as_bool)
874 .unwrap_or(false),
875 http_client: fields
876 .and_then(|f| f.get("http_client"))
877 .and_then(JsonValue::as_bool)
878 .unwrap_or(false),
879 http_server: fields
880 .and_then(|f| f.get("http_server"))
881 .and_then(JsonValue::as_bool)
882 .unwrap_or(false),
883 state_read: fields
884 .and_then(|f| f.get("state_read"))
885 .and_then(JsonValue::as_bool)
886 .unwrap_or(false),
887 state_write: fields
888 .and_then(|f| f.get("state_write"))
889 .and_then(JsonValue::as_bool)
890 .unwrap_or(false),
891 state_delete: fields
892 .and_then(|f| f.get("state_delete"))
893 .and_then(JsonValue::as_bool)
894 .unwrap_or(false),
895 telemetry_scope: parse_telemetry_scope(telemetry_scope).map_err(anyhow::Error::from)?,
896 telemetry_span_prefix,
897 telemetry_attributes,
898 secret_keys: parse_string_array(fields, "secret_keys"),
899 secret_env: fields
900 .and_then(|f| f.get("secret_env"))
901 .and_then(JsonValue::as_str)
902 .unwrap_or("dev")
903 .trim()
904 .to_string(),
905 secret_tenant: fields
906 .and_then(|f| f.get("secret_tenant"))
907 .and_then(JsonValue::as_str)
908 .unwrap_or("default")
909 .trim()
910 .to_string(),
911 secret_format: parse_secret_format(
912 fields
913 .and_then(|f| f.get("secret_format"))
914 .and_then(JsonValue::as_str)
915 .unwrap_or("text"),
916 )
917 .map_err(anyhow::Error::from)?,
918 })
919}
920
921fn parse_config_schema(fields: Option<&JsonMap<String, JsonValue>>) -> Result<ConfigSchemaInput> {
922 Ok(ConfigSchemaInput {
923 fields: parse_string_array(fields, "config_fields")
924 .into_iter()
925 .map(|value| parse_config_field(&value).map_err(anyhow::Error::from))
926 .collect::<Result<Vec<_>>>()?,
927 })
928}
929
930fn default_operation_schema(component_name: &str, operation_name: &str) -> JsonValue {
931 json!({
932 "name": operation_name,
933 "input_schema": {
934 "$schema": "https://json-schema.org/draft/2020-12/schema",
935 "title": format!("{component_name} {operation_name} input"),
936 "type": "object",
937 "required": ["input"],
938 "properties": {
939 "input": {
940 "type": "string",
941 "default": format!("Hello from {component_name}!")
942 }
943 },
944 "additionalProperties": false
945 },
946 "output_schema": {
947 "$schema": "https://json-schema.org/draft/2020-12/schema",
948 "title": format!("{component_name} {operation_name} output"),
949 "type": "object",
950 "required": ["message"],
951 "properties": {
952 "message": { "type": "string" }
953 },
954 "additionalProperties": false
955 }
956 })
957}
958
959fn add_operation_to_manifest(
960 manifest: &mut JsonValue,
961 operation_name: &str,
962) -> Result<Vec<String>> {
963 let component_name = manifest
964 .get("name")
965 .and_then(JsonValue::as_str)
966 .unwrap_or("component")
967 .to_string();
968 let operations = manifest
969 .get_mut("operations")
970 .and_then(JsonValue::as_array_mut)
971 .ok_or_else(|| anyhow!("{}", tr("cli.wizard.error.manifest_operations_array")))?;
972 if operations.iter().any(|entry| {
973 entry
974 .get("name")
975 .and_then(JsonValue::as_str)
976 .is_some_and(|name| name == operation_name)
977 }) {
978 bail!(
979 "{}",
980 trf("cli.wizard.error.operation_exists", &[operation_name])
981 );
982 }
983 operations.push(default_operation_schema(&component_name, operation_name));
984 collect_user_operation_names(manifest)
985}
986
987fn update_operation_in_manifest(
988 manifest: &mut JsonValue,
989 operation_name: &str,
990 new_name: Option<&str>,
991) -> Result<String> {
992 let operations = manifest
993 .get_mut("operations")
994 .and_then(JsonValue::as_array_mut)
995 .ok_or_else(|| anyhow!("{}", tr("cli.wizard.error.manifest_operations_array")))?;
996 let target_index = operations.iter().position(|entry| {
997 entry
998 .get("name")
999 .and_then(JsonValue::as_str)
1000 .is_some_and(|name| name == operation_name)
1001 });
1002 let Some(target_index) = target_index else {
1003 bail!(
1004 "{}",
1005 trf("cli.wizard.error.operation_not_found", &[operation_name])
1006 );
1007 };
1008 let final_name = new_name.unwrap_or(operation_name).to_string();
1009 if final_name != operation_name
1010 && operations.iter().any(|other| {
1011 other
1012 .get("name")
1013 .and_then(JsonValue::as_str)
1014 .is_some_and(|name| name == final_name)
1015 })
1016 {
1017 bail!(
1018 "{}",
1019 trf("cli.wizard.error.operation_exists", &[&final_name])
1020 );
1021 }
1022 let entry = operations.get_mut(target_index).ok_or_else(|| {
1023 anyhow!(
1024 "{}",
1025 trf("cli.wizard.error.operation_not_found", &[operation_name])
1026 )
1027 })?;
1028 entry["name"] = JsonValue::String(final_name.clone());
1029 if manifest
1030 .get("default_operation")
1031 .and_then(JsonValue::as_str)
1032 .is_some_and(|value| value == operation_name)
1033 {
1034 manifest["default_operation"] = JsonValue::String(final_name.clone());
1035 }
1036 Ok(final_name)
1037}
1038
1039fn collect_user_operation_names(manifest: &JsonValue) -> Result<Vec<String>> {
1040 let operations = manifest
1041 .get("operations")
1042 .and_then(JsonValue::as_array)
1043 .ok_or_else(|| anyhow!("{}", tr("cli.wizard.error.manifest_operations_array")))?;
1044 Ok(operations
1045 .iter()
1046 .filter_map(|entry| entry.get("name").and_then(JsonValue::as_str))
1047 .filter(|name| !matches!(*name, "qa-spec" | "apply-answers" | "i18n-keys"))
1048 .map(ToOwned::to_owned)
1049 .collect())
1050}
1051
1052fn write_files_plan(
1053 id: &str,
1054 digest: &str,
1055 project_root: &Path,
1056 files: Vec<(String, String)>,
1057) -> WizardPlanEnvelope {
1058 let file_map = files
1059 .into_iter()
1060 .collect::<std::collections::BTreeMap<_, _>>();
1061 WizardPlanEnvelope {
1062 plan_version: wizard::PLAN_VERSION,
1063 metadata: WizardPlanMetadata {
1064 generator: "greentic-component/wizard-runner".to_string(),
1065 template_version: "component-wizard-run/v1".to_string(),
1066 template_digest_blake3: digest.to_string(),
1067 requested_abi_version: "0.6.0".to_string(),
1068 },
1069 target_root: project_root.to_path_buf(),
1070 plan: wizard::WizardPlan {
1071 meta: wizard::WizardPlanMeta {
1072 id: id.to_string(),
1073 target: wizard::WizardTarget::Component,
1074 mode: wizard::WizardPlanMode::Scaffold,
1075 },
1076 steps: vec![WizardStep::WriteFiles { files: file_map }],
1077 },
1078 }
1079}
1080
1081fn rewrite_lib_user_ops(source: &str, user_operations: &[String]) -> Result<String> {
1082 let generated = user_operations
1083 .iter()
1084 .map(|name| {
1085 format!(
1086 r#" node::Op {{
1087 name: "{name}".to_string(),
1088 summary: Some("Handle a single message input".to_string()),
1089 input: node::IoSchema {{
1090 schema: node::SchemaSource::InlineCbor(input_schema_cbor.clone()),
1091 content_type: "application/cbor".to_string(),
1092 schema_version: None,
1093 }},
1094 output: node::IoSchema {{
1095 schema: node::SchemaSource::InlineCbor(output_schema_cbor.clone()),
1096 content_type: "application/cbor".to_string(),
1097 schema_version: None,
1098 }},
1099 examples: Vec::new(),
1100 }}"#
1101 )
1102 })
1103 .collect::<Vec<_>>()
1104 .join(",\n");
1105
1106 if let Some(start) = source.find(" ops: vec![")
1107 && let Some(end_rel) = source[start..].find(" schemas: Vec::new(),")
1108 {
1109 let end = start + end_rel;
1110 let qa_anchor = source[start..end]
1111 .find(" node::Op {\n name: \"qa-spec\".to_string(),")
1112 .map(|idx| start + idx)
1113 .ok_or_else(|| anyhow!("{}", tr("cli.wizard.error.lib_missing_qa_block")))?;
1114 let mut updated = String::new();
1115 updated.push_str(&source[..start]);
1116 updated.push_str(" ops: vec![\n");
1117 updated.push_str(&generated);
1118 updated.push_str(",\n");
1119 updated.push_str(&source[qa_anchor..end]);
1120 updated.push_str(&source[end..]);
1121 return Ok(updated);
1122 }
1123
1124 if let Some(start) = source.find(" let mut ops = vec![")
1125 && let Some(end_anchor_rel) = source[start..].find(" ops.extend(vec![")
1126 {
1127 let end = start + end_anchor_rel;
1128 let mut updated = String::new();
1129 updated.push_str(&source[..start]);
1130 updated.push_str(" let mut ops = vec![\n");
1131 updated.push_str(
1132 &user_operations
1133 .iter()
1134 .map(|name| {
1135 format!(
1136 r#" node::Op {{
1137 name: "{name}".to_string(),
1138 summary: Some("Handle a single message input".to_string()),
1139 input: node::IoSchema {{
1140 schema: node::SchemaSource::InlineCbor(input_schema_cbor.clone()),
1141 content_type: "application/cbor".to_string(),
1142 schema_version: None,
1143 }},
1144 output: node::IoSchema {{
1145 schema: node::SchemaSource::InlineCbor(output_schema_cbor.clone()),
1146 content_type: "application/cbor".to_string(),
1147 schema_version: None,
1148 }},
1149 examples: Vec::new(),
1150 }}"#
1151 )
1152 })
1153 .collect::<Vec<_>>()
1154 .join(",\n"),
1155 );
1156 updated.push_str("\n ];\n");
1157 updated.push_str(&source[end..]);
1158 return Ok(updated);
1159 }
1160
1161 bail!("{}", tr("cli.wizard.error.lib_unexpected_layout"))
1162}
1163
1164fn build_build_test_plan(
1165 args: &WizardArgs,
1166 answers: Option<&WizardRunAnswers>,
1167) -> (WizardPlanEnvelope, Vec<String>) {
1168 let fields = answers.map(|doc| &doc.fields);
1169 let project_root = fields
1170 .and_then(|f| f.get("project_root"))
1171 .and_then(JsonValue::as_str)
1172 .map(PathBuf::from)
1173 .unwrap_or_else(|| args.project_root.clone());
1174
1175 let mut steps = vec![WizardStep::BuildComponent {
1176 project_root: project_root.display().to_string(),
1177 }];
1178
1179 let full_tests = fields
1180 .and_then(|f| f.get("full_tests"))
1181 .and_then(JsonValue::as_bool)
1182 .unwrap_or(args.full_tests);
1183
1184 if full_tests {
1185 steps.push(WizardStep::TestComponent {
1186 project_root: project_root.display().to_string(),
1187 full: true,
1188 });
1189 }
1190
1191 (
1192 WizardPlanEnvelope {
1193 plan_version: wizard::PLAN_VERSION,
1194 metadata: WizardPlanMetadata {
1195 generator: "greentic-component/wizard-runner".to_string(),
1196 template_version: "component-wizard-run/v1".to_string(),
1197 template_digest_blake3: "mode-build-test".to_string(),
1198 requested_abi_version: "0.6.0".to_string(),
1199 },
1200 target_root: project_root,
1201 plan: wizard::WizardPlan {
1202 meta: wizard::WizardPlanMeta {
1203 id: "greentic.component.build_test".to_string(),
1204 target: wizard::WizardTarget::Component,
1205 mode: wizard::WizardPlanMode::Scaffold,
1206 },
1207 steps,
1208 },
1209 },
1210 Vec::new(),
1211 )
1212}
1213
1214fn build_doctor_plan(
1215 args: &WizardArgs,
1216 answers: Option<&WizardRunAnswers>,
1217) -> (WizardPlanEnvelope, Vec<String>) {
1218 let fields = answers.map(|doc| &doc.fields);
1219 let project_root = fields
1220 .and_then(|f| f.get("project_root"))
1221 .and_then(JsonValue::as_str)
1222 .map(PathBuf::from)
1223 .unwrap_or_else(|| args.project_root.clone());
1224
1225 (
1226 WizardPlanEnvelope {
1227 plan_version: wizard::PLAN_VERSION,
1228 metadata: WizardPlanMetadata {
1229 generator: "greentic-component/wizard-runner".to_string(),
1230 template_version: "component-wizard-run/v1".to_string(),
1231 template_digest_blake3: "mode-doctor".to_string(),
1232 requested_abi_version: "0.6.0".to_string(),
1233 },
1234 target_root: project_root.clone(),
1235 plan: wizard::WizardPlan {
1236 meta: wizard::WizardPlanMeta {
1237 id: "greentic.component.doctor".to_string(),
1238 target: wizard::WizardTarget::Component,
1239 mode: wizard::WizardPlanMode::Scaffold,
1240 },
1241 steps: vec![WizardStep::Doctor {
1242 project_root: project_root.display().to_string(),
1243 }],
1244 },
1245 },
1246 Vec::new(),
1247 )
1248}
1249
1250fn execute_run_plan(plan: &WizardPlanEnvelope) -> Result<()> {
1251 for step in &plan.plan.steps {
1252 match step {
1253 WizardStep::EnsureDir { .. } | WizardStep::WriteFiles { .. } => {
1254 let single = WizardPlanEnvelope {
1255 plan_version: plan.plan_version,
1256 metadata: plan.metadata.clone(),
1257 target_root: plan.target_root.clone(),
1258 plan: wizard::WizardPlan {
1259 meta: plan.plan.meta.clone(),
1260 steps: vec![step.clone()],
1261 },
1262 };
1263 wizard::execute_plan(&single)?;
1264 }
1265 WizardStep::BuildComponent { project_root } => {
1266 let manifest = PathBuf::from(project_root).join("component.manifest.json");
1267 crate::cmd::build::run(BuildArgs {
1268 manifest,
1269 cargo_bin: None,
1270 no_flow: false,
1271 no_infer_config: false,
1272 no_write_schema: false,
1273 force_write_schema: false,
1274 no_validate: false,
1275 json: false,
1276 permissive: false,
1277 })?;
1278 }
1279 WizardStep::Doctor { project_root } => {
1280 let manifest = PathBuf::from(project_root).join("component.manifest.json");
1281 crate::cmd::doctor::run(DoctorArgs {
1282 target: project_root.clone(),
1283 manifest: Some(manifest),
1284 format: DoctorFormat::Human,
1285 })
1286 .map_err(|err| anyhow!(err.to_string()))?;
1287 }
1288 WizardStep::TestComponent { project_root, full } => {
1289 if *full {
1290 let status = Command::new("cargo")
1291 .arg("test")
1292 .current_dir(project_root)
1293 .status()
1294 .with_context(|| format!("failed to run cargo test in {project_root}"))?;
1295 if !status.success() {
1296 bail!(
1297 "{}",
1298 trf("cli.wizard.error.cargo_test_failed_in", &[project_root])
1299 );
1300 }
1301 }
1302 }
1303 WizardStep::RunCli { command } => {
1304 bail!(
1305 "{}",
1306 trf("cli.wizard.error.unsupported_run_cli", &[command])
1307 );
1308 }
1309 WizardStep::Delegate { id } => {
1310 bail!(
1311 "{}",
1312 trf("cli.wizard.error.unsupported_delegate", &[id.as_str()])
1313 );
1314 }
1315 }
1316 }
1317 Ok(())
1318}
1319
1320fn parse_string_array(fields: Option<&JsonMap<String, JsonValue>>, key: &str) -> Vec<String> {
1321 match fields.and_then(|f| f.get(key)) {
1322 Some(JsonValue::Array(values)) => values
1323 .iter()
1324 .filter_map(JsonValue::as_str)
1325 .map(ToOwned::to_owned)
1326 .collect(),
1327 Some(JsonValue::String(value)) => value
1328 .split(',')
1329 .map(str::trim)
1330 .filter(|entry| !entry.is_empty())
1331 .map(ToOwned::to_owned)
1332 .collect(),
1333 _ => Vec::new(),
1334 }
1335}
1336
1337fn load_run_answers(path: &PathBuf, args: &WizardArgs) -> Result<LoadedRunAnswers> {
1338 let raw = fs::read_to_string(path)
1339 .with_context(|| format!("failed to read qa answers {}", path.display()))?;
1340 let value: JsonValue = serde_json::from_str(&raw)
1341 .with_context(|| format!("qa answers {} must be valid JSON", path.display()))?;
1342
1343 if let Some(doc) = parse_answer_document(&value)? {
1344 let migrated = maybe_migrate_document(doc, args)?;
1345 let run_answers = run_answers_from_answer_document(&migrated, args)?;
1346 return Ok(LoadedRunAnswers {
1347 run_answers,
1348 source_document: Some(migrated),
1349 });
1350 }
1351
1352 let answers: WizardRunAnswers = serde_json::from_value(value)
1353 .with_context(|| format!("qa answers {} must be valid JSON", path.display()))?;
1354 if answers.schema != WIZARD_RUN_SCHEMA {
1355 bail!(
1356 "{}",
1357 trf(
1358 "cli.wizard.result.invalid_schema",
1359 &[&answers.schema, WIZARD_RUN_SCHEMA],
1360 )
1361 );
1362 }
1363 Ok(LoadedRunAnswers {
1364 run_answers: answers,
1365 source_document: None,
1366 })
1367}
1368
1369fn load_answers_with_recovery<F>(
1370 path: Option<&PathBuf>,
1371 args: &WizardArgs,
1372 interactive: bool,
1373 mut report: F,
1374) -> Result<Option<LoadedRunAnswers>>
1375where
1376 F: FnMut(String),
1377{
1378 let Some(path) = path else {
1379 return Ok(None);
1380 };
1381 match load_run_answers(path, args) {
1382 Ok(loaded) => Ok(Some(loaded)),
1383 Err(err) if interactive => {
1384 report_interactive_validation_error(&err, &mut report);
1385 Ok(None)
1386 }
1387 Err(err) => Err(err),
1388 }
1389}
1390
1391fn parse_answer_document(value: &JsonValue) -> Result<Option<AnswerDocument>> {
1392 let JsonValue::Object(map) = value else {
1393 return Ok(None);
1394 };
1395 if map.contains_key("wizard_id")
1396 || map.contains_key("schema_id")
1397 || map.contains_key("schema_version")
1398 || map.contains_key("answers")
1399 {
1400 let doc: AnswerDocument = serde_json::from_value(value.clone())
1401 .with_context(|| tr("cli.wizard.result.answer_doc_invalid_shape"))?;
1402 return Ok(Some(doc));
1403 }
1404 Ok(None)
1405}
1406
1407fn maybe_migrate_document(doc: AnswerDocument, args: &WizardArgs) -> Result<AnswerDocument> {
1408 if doc.schema_id != ANSWER_DOC_SCHEMA_ID {
1409 bail!(
1410 "{}",
1411 trf(
1412 "cli.wizard.result.answer_schema_id_mismatch",
1413 &[&doc.schema_id, ANSWER_DOC_SCHEMA_ID],
1414 )
1415 );
1416 }
1417 let target_version = requested_schema_version(args);
1418 if doc.schema_version == target_version {
1419 return Ok(doc);
1420 }
1421 if !args.migrate {
1422 bail!(
1423 "{}",
1424 trf(
1425 "cli.wizard.result.answer_schema_version_mismatch",
1426 &[&doc.schema_version, &target_version],
1427 )
1428 );
1429 }
1430 let mut migrated = doc;
1431 migrated.schema_version = target_version;
1432 Ok(migrated)
1433}
1434
1435fn run_answers_from_answer_document(
1436 doc: &AnswerDocument,
1437 args: &WizardArgs,
1438) -> Result<WizardRunAnswers> {
1439 let mode = doc
1440 .answers
1441 .get("mode")
1442 .and_then(JsonValue::as_str)
1443 .map(parse_run_mode)
1444 .transpose()?
1445 .unwrap_or(args.mode);
1446 let fields = match doc.answers.get("fields") {
1447 Some(JsonValue::Object(fields)) => fields.clone(),
1448 _ => doc.answers.clone(),
1449 };
1450 Ok(WizardRunAnswers {
1451 schema: WIZARD_RUN_SCHEMA.to_string(),
1452 mode,
1453 fields,
1454 })
1455}
1456
1457fn parse_run_mode(value: &str) -> Result<RunMode> {
1458 match value {
1459 "create" => Ok(RunMode::Create),
1460 "add-operation" | "add_operation" => Ok(RunMode::AddOperation),
1461 "update-operation" | "update_operation" => Ok(RunMode::UpdateOperation),
1462 "build-test" | "build_test" => Ok(RunMode::BuildTest),
1463 "doctor" => Ok(RunMode::Doctor),
1464 _ => bail!(
1465 "{}",
1466 trf("cli.wizard.result.answer_mode_unsupported", &[value])
1467 ),
1468 }
1469}
1470
1471fn answer_document_from_run_answers(
1472 run_answers: &WizardRunAnswers,
1473 args: &WizardArgs,
1474 source_document: Option<AnswerDocument>,
1475) -> AnswerDocument {
1476 let locale = i18n::selected_locale().to_string();
1477 let mut answers = JsonMap::new();
1478 answers.insert(
1479 "mode".to_string(),
1480 JsonValue::String(mode_name(run_answers.mode).replace('_', "-")),
1481 );
1482 answers.insert(
1483 "fields".to_string(),
1484 JsonValue::Object(run_answers.fields.clone()),
1485 );
1486
1487 let locks = source_document
1488 .as_ref()
1489 .map(|doc| doc.locks.clone())
1490 .unwrap_or_default();
1491
1492 AnswerDocument {
1493 wizard_id: source_document
1494 .as_ref()
1495 .map(|doc| doc.wizard_id.clone())
1496 .unwrap_or_else(|| ANSWER_DOC_WIZARD_ID.to_string()),
1497 schema_id: source_document
1498 .as_ref()
1499 .map(|doc| doc.schema_id.clone())
1500 .unwrap_or_else(|| ANSWER_DOC_SCHEMA_ID.to_string()),
1501 schema_version: requested_schema_version(args),
1502 locale: Some(locale),
1503 answers,
1504 locks,
1505 }
1506}
1507
1508fn requested_schema_version(args: &WizardArgs) -> String {
1509 args.schema_version
1510 .clone()
1511 .unwrap_or_else(|| ANSWER_DOC_SCHEMA_VERSION.to_string())
1512}
1513
1514fn wizard_answer_schema(args: &WizardArgs) -> JsonValue {
1515 let selected_mode = mode_name(args.mode).replace('_', "-");
1516 let fields_schema = wizard_answer_fields_schema(args);
1517 json!({
1518 "$schema": "https://json-schema.org/draft/2020-12/schema",
1519 "$id": format!("https://greenticai.github.io/greentic-component/schemas/wizard/{selected_mode}.answers.schema.json"),
1520 "title": format!("greentic-component wizard {} answers", selected_mode),
1521 "type": "object",
1522 "additionalProperties": false,
1523 "properties": {
1524 "wizard_id": {
1525 "type": "string",
1526 "const": ANSWER_DOC_WIZARD_ID
1527 },
1528 "schema_id": {
1529 "type": "string",
1530 "const": ANSWER_DOC_SCHEMA_ID
1531 },
1532 "schema_version": {
1533 "type": "string",
1534 "const": requested_schema_version(args)
1535 },
1536 "locale": {
1537 "type": ["string", "null"]
1538 },
1539 "answers": {
1540 "type": "object",
1541 "additionalProperties": false,
1542 "properties": {
1543 "mode": {
1544 "type": "string",
1545 "const": selected_mode
1546 },
1547 "fields": fields_schema
1548 },
1549 "required": ["mode", "fields"]
1550 },
1551 "locks": {
1552 "type": "object",
1553 "additionalProperties": true
1554 }
1555 },
1556 "required": ["wizard_id", "schema_id", "schema_version", "answers"]
1557 })
1558}
1559
1560fn wizard_answer_fields_schema(args: &WizardArgs) -> JsonValue {
1561 let questions = match args.mode {
1562 RunMode::Create => create_questions(args, true),
1563 _ => interactive_questions(args),
1564 };
1565 let mut properties = JsonMap::new();
1566 let mut required = Vec::new();
1567 for question in questions {
1568 let Some(id) = question.get("id").and_then(JsonValue::as_str) else {
1569 continue;
1570 };
1571 if question
1572 .get("required")
1573 .and_then(JsonValue::as_bool)
1574 .unwrap_or(false)
1575 {
1576 required.push(JsonValue::String(id.to_string()));
1577 }
1578 properties.insert(id.to_string(), question_schema_property(&question));
1579 }
1580 JsonValue::Object(JsonMap::from_iter([
1581 ("type".to_string(), JsonValue::String("object".to_string())),
1582 ("additionalProperties".to_string(), JsonValue::Bool(false)),
1583 ("properties".to_string(), JsonValue::Object(properties)),
1584 ("required".to_string(), JsonValue::Array(required)),
1585 ]))
1586}
1587
1588fn question_schema_property(question: &JsonValue) -> JsonValue {
1589 let mut property = JsonMap::new();
1590 let question_type = question
1591 .get("type")
1592 .and_then(JsonValue::as_str)
1593 .unwrap_or("string");
1594 match question_type {
1595 "boolean" => {
1596 property.insert("type".to_string(), JsonValue::String("boolean".to_string()));
1597 }
1598 "enum" => {
1599 property.insert("type".to_string(), JsonValue::String("string".to_string()));
1600 if let Some(choices) = question.get("choices").cloned() {
1601 property.insert("enum".to_string(), choices);
1602 }
1603 }
1604 _ => {
1605 property.insert("type".to_string(), JsonValue::String("string".to_string()));
1606 }
1607 }
1608 if let Some(default) = question.get("default").cloned() {
1609 property.insert("default".to_string(), default);
1610 }
1611 JsonValue::Object(property)
1612}
1613
1614fn write_json_file(path: &PathBuf, payload: &str, label: &str) -> Result<()> {
1615 if let Some(parent) = path.parent()
1616 && !parent.as_os_str().is_empty()
1617 {
1618 fs::create_dir_all(parent)
1619 .with_context(|| format!("failed to create {label} parent {}", parent.display()))?;
1620 }
1621 fs::write(path, payload).with_context(|| format!("failed to write {label} {}", path.display()))
1622}
1623
1624fn default_answers_for(args: &WizardArgs) -> WizardRunAnswers {
1625 WizardRunAnswers {
1626 schema: WIZARD_RUN_SCHEMA.to_string(),
1627 mode: args.mode,
1628 fields: JsonMap::new(),
1629 }
1630}
1631
1632fn collect_interactive_answers(args: &WizardArgs) -> Result<Option<WizardRunAnswers>> {
1633 println!("0 = back, M = main menu");
1634 if args.mode == RunMode::Create {
1635 return collect_interactive_create_answers(args);
1636 }
1637
1638 let Some(fields) = collect_interactive_question_map(args, interactive_questions(args))? else {
1639 return Ok(None);
1640 };
1641 Ok(Some(WizardRunAnswers {
1642 schema: WIZARD_RUN_SCHEMA.to_string(),
1643 mode: args.mode,
1644 fields,
1645 }))
1646}
1647
1648fn collect_interactive_create_answers(args: &WizardArgs) -> Result<Option<WizardRunAnswers>> {
1649 let mut answered = JsonMap::new();
1650 let Some(minimal_answers) = collect_interactive_question_map_with_answers(
1651 args,
1652 create_questions(args, false),
1653 answered,
1654 )?
1655 else {
1656 return Ok(None);
1657 };
1658 answered = minimal_answers;
1659
1660 if answered
1661 .get("advanced_setup")
1662 .and_then(JsonValue::as_bool)
1663 .unwrap_or(false)
1664 {
1665 let Some(advanced_answers) = collect_interactive_question_map_with_skip(
1666 args,
1667 create_questions(args, true),
1668 answered,
1669 should_skip_create_advanced_question,
1670 )?
1671 else {
1672 return Ok(None);
1673 };
1674 answered = advanced_answers;
1675 }
1676
1677 let operations = answered
1678 .get("operation_names")
1679 .and_then(JsonValue::as_str)
1680 .filter(|value| !value.trim().is_empty())
1681 .map(parse_operation_names_csv)
1682 .transpose()?
1683 .filter(|ops| !ops.is_empty())
1684 .or_else(|| {
1685 answered
1686 .get("primary_operation_name")
1687 .and_then(JsonValue::as_str)
1688 .filter(|value| !value.trim().is_empty())
1689 .map(|value| vec![value.to_string()])
1690 });
1691 if let Some(operations) = operations {
1692 let default_operation = operations
1693 .first()
1694 .cloned()
1695 .unwrap_or_else(|| "handle_message".to_string());
1696 answered.insert(
1697 "operations".to_string(),
1698 JsonValue::Array(
1699 operations
1700 .into_iter()
1701 .map(JsonValue::String)
1702 .collect::<Vec<_>>(),
1703 ),
1704 );
1705 answered.insert(
1706 "default_operation".to_string(),
1707 JsonValue::String(default_operation),
1708 );
1709 }
1710
1711 Ok(Some(WizardRunAnswers {
1712 schema: WIZARD_RUN_SCHEMA.to_string(),
1713 mode: args.mode,
1714 fields: answered,
1715 }))
1716}
1717
1718fn interactive_questions(args: &WizardArgs) -> Vec<JsonValue> {
1719 match args.mode {
1720 RunMode::Create => create_questions(args, true),
1721 RunMode::AddOperation => vec![
1722 json!({
1723 "id": "project_root",
1724 "type": "string",
1725 "title": tr("cli.wizard.prompt.project_root"),
1726 "title_i18n": {"key":"cli.wizard.prompt.project_root"},
1727 "required": true,
1728 "default": args.project_root.display().to_string()
1729 }),
1730 json!({
1731 "id": "operation_name",
1732 "type": "string",
1733 "title": tr("cli.wizard.prompt.operation_name"),
1734 "title_i18n": {"key":"cli.wizard.prompt.operation_name"},
1735 "required": true
1736 }),
1737 json!({
1738 "id": "set_default_operation",
1739 "type": "boolean",
1740 "title": tr("cli.wizard.prompt.set_default_operation"),
1741 "title_i18n": {"key":"cli.wizard.prompt.set_default_operation"},
1742 "required": false,
1743 "default": false
1744 }),
1745 ],
1746 RunMode::UpdateOperation => vec![
1747 json!({
1748 "id": "project_root",
1749 "type": "string",
1750 "title": tr("cli.wizard.prompt.project_root"),
1751 "title_i18n": {"key":"cli.wizard.prompt.project_root"},
1752 "required": true,
1753 "default": args.project_root.display().to_string()
1754 }),
1755 json!({
1756 "id": "operation_name",
1757 "type": "string",
1758 "title": tr("cli.wizard.prompt.existing_operation_name"),
1759 "title_i18n": {"key":"cli.wizard.prompt.existing_operation_name"},
1760 "required": true
1761 }),
1762 json!({
1763 "id": "new_operation_name",
1764 "type": "string",
1765 "title": tr("cli.wizard.prompt.new_operation_name"),
1766 "title_i18n": {"key":"cli.wizard.prompt.new_operation_name"},
1767 "required": false
1768 }),
1769 json!({
1770 "id": "set_default_operation",
1771 "type": "boolean",
1772 "title": tr("cli.wizard.prompt.set_default_operation"),
1773 "title_i18n": {"key":"cli.wizard.prompt.set_default_operation"},
1774 "required": false,
1775 "default": false
1776 }),
1777 ],
1778 RunMode::BuildTest => vec![
1779 json!({
1780 "id": "project_root",
1781 "type": "string",
1782 "title": tr("cli.wizard.prompt.project_root"),
1783 "title_i18n": {"key":"cli.wizard.prompt.project_root"},
1784 "required": true,
1785 "default": args.project_root.display().to_string()
1786 }),
1787 json!({
1788 "id": "full_tests",
1789 "type": "boolean",
1790 "title": tr("cli.wizard.prompt.full_tests"),
1791 "title_i18n": {"key":"cli.wizard.prompt.full_tests"},
1792 "required": false,
1793 "default": args.full_tests
1794 }),
1795 ],
1796 RunMode::Doctor => vec![json!({
1797 "id": "project_root",
1798 "type": "string",
1799 "title": tr("cli.wizard.prompt.project_root"),
1800 "title_i18n": {"key":"cli.wizard.prompt.project_root"},
1801 "required": true,
1802 "default": args.project_root.display().to_string()
1803 })],
1804 }
1805}
1806
1807fn create_questions(args: &WizardArgs, include_advanced: bool) -> Vec<JsonValue> {
1808 let templates = available_template_ids();
1809 let mut questions = vec![
1810 json!({
1811 "id": "component_name",
1812 "type": "string",
1813 "title": tr("cli.wizard.prompt.component_name"),
1814 "title_i18n": {"key":"cli.wizard.prompt.component_name"},
1815 "required": true,
1816 "default": "component"
1817 }),
1818 json!({
1819 "id": "output_dir",
1820 "type": "string",
1821 "title": tr("cli.wizard.prompt.output_dir"),
1822 "title_i18n": {"key":"cli.wizard.prompt.output_dir"},
1823 "required": true,
1824 "default": args.project_root.join("component").display().to_string()
1825 }),
1826 json!({
1827 "id": "advanced_setup",
1828 "type": "boolean",
1829 "title": tr("cli.wizard.prompt.advanced_setup"),
1830 "title_i18n": {"key":"cli.wizard.prompt.advanced_setup"},
1831 "required": true,
1832 "default": false
1833 }),
1834 ];
1835 if !include_advanced {
1836 return questions;
1837 }
1838
1839 questions.extend([
1840 json!({
1841 "id": "abi_version",
1842 "type": "string",
1843 "title": tr("cli.wizard.prompt.abi_version"),
1844 "title_i18n": {"key":"cli.wizard.prompt.abi_version"},
1845 "required": true,
1846 "default": "0.6.0"
1847 }),
1848 json!({
1849 "id": "operation_names",
1850 "type": "string",
1851 "title": tr("cli.wizard.prompt.operation_names"),
1852 "title_i18n": {"key":"cli.wizard.prompt.operation_names"},
1853 "required": true,
1854 "default": "handle_message"
1855 }),
1856 json!({
1857 "id": "filesystem_mode",
1858 "type": "enum",
1859 "title": tr("cli.wizard.prompt.filesystem_mode"),
1860 "title_i18n": {"key":"cli.wizard.prompt.filesystem_mode"},
1861 "required": true,
1862 "default": "none",
1863 "choices": ["none", "read_only", "sandbox"]
1864 }),
1865 json!({
1866 "id": "filesystem_mounts",
1867 "type": "string",
1868 "title": tr("cli.wizard.prompt.filesystem_mounts"),
1869 "title_i18n": {"key":"cli.wizard.prompt.filesystem_mounts"},
1870 "required": false,
1871 "default": ""
1872 }),
1873 json!({
1874 "id": "http_client",
1875 "type": "boolean",
1876 "title": tr("cli.wizard.prompt.http_client"),
1877 "title_i18n": {"key":"cli.wizard.prompt.http_client"},
1878 "required": false,
1879 "default": false
1880 }),
1881 json!({
1882 "id": "messaging_inbound",
1883 "type": "boolean",
1884 "title": tr("cli.wizard.prompt.messaging_inbound"),
1885 "title_i18n": {"key":"cli.wizard.prompt.messaging_inbound"},
1886 "required": false,
1887 "default": false
1888 }),
1889 json!({
1890 "id": "messaging_outbound",
1891 "type": "boolean",
1892 "title": tr("cli.wizard.prompt.messaging_outbound"),
1893 "title_i18n": {"key":"cli.wizard.prompt.messaging_outbound"},
1894 "required": false,
1895 "default": false
1896 }),
1897 json!({
1898 "id": "events_inbound",
1899 "type": "boolean",
1900 "title": tr("cli.wizard.prompt.events_inbound"),
1901 "title_i18n": {"key":"cli.wizard.prompt.events_inbound"},
1902 "required": false,
1903 "default": false
1904 }),
1905 json!({
1906 "id": "events_outbound",
1907 "type": "boolean",
1908 "title": tr("cli.wizard.prompt.events_outbound"),
1909 "title_i18n": {"key":"cli.wizard.prompt.events_outbound"},
1910 "required": false,
1911 "default": false
1912 }),
1913 json!({
1914 "id": "http_server",
1915 "type": "boolean",
1916 "title": tr("cli.wizard.prompt.http_server"),
1917 "title_i18n": {"key":"cli.wizard.prompt.http_server"},
1918 "required": false,
1919 "default": false
1920 }),
1921 json!({
1922 "id": "state_read",
1923 "type": "boolean",
1924 "title": tr("cli.wizard.prompt.state_read"),
1925 "title_i18n": {"key":"cli.wizard.prompt.state_read"},
1926 "required": false,
1927 "default": false
1928 }),
1929 json!({
1930 "id": "state_write",
1931 "type": "boolean",
1932 "title": tr("cli.wizard.prompt.state_write"),
1933 "title_i18n": {"key":"cli.wizard.prompt.state_write"},
1934 "required": false,
1935 "default": false
1936 }),
1937 json!({
1938 "id": "state_delete",
1939 "type": "boolean",
1940 "title": tr("cli.wizard.prompt.state_delete"),
1941 "title_i18n": {"key":"cli.wizard.prompt.state_delete"},
1942 "required": false,
1943 "default": false
1944 }),
1945 json!({
1946 "id": "telemetry_scope",
1947 "type": "enum",
1948 "title": tr("cli.wizard.prompt.telemetry_scope"),
1949 "title_i18n": {"key":"cli.wizard.prompt.telemetry_scope"},
1950 "required": true,
1951 "default": "node",
1952 "choices": ["tenant", "pack", "node"]
1953 }),
1954 json!({
1955 "id": "telemetry_span_prefix",
1956 "type": "string",
1957 "title": tr("cli.wizard.prompt.telemetry_span_prefix"),
1958 "title_i18n": {"key":"cli.wizard.prompt.telemetry_span_prefix"},
1959 "required": false,
1960 "default": ""
1961 }),
1962 json!({
1963 "id": "telemetry_attributes",
1964 "type": "string",
1965 "title": tr("cli.wizard.prompt.telemetry_attributes"),
1966 "title_i18n": {"key":"cli.wizard.prompt.telemetry_attributes"},
1967 "required": false,
1968 "default": ""
1969 }),
1970 json!({
1971 "id": "secrets_enabled",
1972 "type": "boolean",
1973 "title": tr("cli.wizard.prompt.secrets_enabled"),
1974 "title_i18n": {"key":"cli.wizard.prompt.secrets_enabled"},
1975 "required": false,
1976 "default": false
1977 }),
1978 json!({
1979 "id": "secret_keys",
1980 "type": "string",
1981 "title": tr("cli.wizard.prompt.secret_keys"),
1982 "title_i18n": {"key":"cli.wizard.prompt.secret_keys"},
1983 "required": false,
1984 "default": ""
1985 }),
1986 json!({
1987 "id": "secret_env",
1988 "type": "string",
1989 "title": tr("cli.wizard.prompt.secret_env"),
1990 "title_i18n": {"key":"cli.wizard.prompt.secret_env"},
1991 "required": false,
1992 "default": "dev"
1993 }),
1994 json!({
1995 "id": "secret_tenant",
1996 "type": "string",
1997 "title": tr("cli.wizard.prompt.secret_tenant"),
1998 "title_i18n": {"key":"cli.wizard.prompt.secret_tenant"},
1999 "required": false,
2000 "default": "default"
2001 }),
2002 json!({
2003 "id": "secret_format",
2004 "type": "enum",
2005 "title": tr("cli.wizard.prompt.secret_format"),
2006 "title_i18n": {"key":"cli.wizard.prompt.secret_format"},
2007 "required": false,
2008 "default": "text",
2009 "choices": ["bytes", "text", "json"]
2010 }),
2011 json!({
2012 "id": "config_fields",
2013 "type": "string",
2014 "title": tr("cli.wizard.prompt.config_fields"),
2015 "title_i18n": {"key":"cli.wizard.prompt.config_fields"},
2016 "required": false,
2017 "default": ""
2018 }),
2019 ]);
2020 if args.template.is_none() && templates.len() > 1 {
2021 let template_choices = templates
2022 .into_iter()
2023 .map(JsonValue::String)
2024 .collect::<Vec<_>>();
2025 questions.push(json!({
2026 "id": "template_id",
2027 "type": "enum",
2028 "title": tr("cli.wizard.prompt.template_id"),
2029 "title_i18n": {"key":"cli.wizard.prompt.template_id"},
2030 "required": true,
2031 "default": "component-v0_6",
2032 "choices": template_choices
2033 }));
2034 }
2035 questions
2036}
2037
2038fn available_template_ids() -> Vec<String> {
2039 vec!["component-v0_6".to_string()]
2040}
2041
2042fn default_template_id() -> String {
2043 available_template_ids()
2044 .into_iter()
2045 .next()
2046 .unwrap_or_else(|| "component-v0_6".to_string())
2047}
2048
2049fn mode_name(mode: RunMode) -> &'static str {
2050 match mode {
2051 RunMode::Create => "create",
2052 RunMode::AddOperation => "add_operation",
2053 RunMode::UpdateOperation => "update_operation",
2054 RunMode::BuildTest => "build_test",
2055 RunMode::Doctor => "doctor",
2056 }
2057}
2058
2059enum InteractiveAnswer {
2060 Value(JsonValue),
2061 Back,
2062 MainMenu,
2063}
2064
2065fn prompt_for_wizard_answer(
2066 question_id: &str,
2067 question: &JsonValue,
2068 fallback_default: Option<JsonValue>,
2069) -> Result<InteractiveAnswer, QaLibError> {
2070 let title = question
2071 .get("title")
2072 .and_then(JsonValue::as_str)
2073 .unwrap_or(question_id);
2074 let required = question
2075 .get("required")
2076 .and_then(JsonValue::as_bool)
2077 .unwrap_or(false);
2078 let kind = question
2079 .get("type")
2080 .and_then(JsonValue::as_str)
2081 .unwrap_or("string");
2082 let default_owned = question.get("default").cloned().or(fallback_default);
2083 let default = default_owned.as_ref();
2084
2085 match kind {
2086 "string" if question_id == "component_name" => {
2087 prompt_component_name_value(title, required, default)
2088 }
2089 "string" => prompt_string_value(title, required, default),
2090 "boolean" => prompt_bool_value(title, required, default),
2091 "enum" => prompt_enum_value(question_id, title, required, question, default),
2092 _ => prompt_string_value(title, required, default),
2093 }
2094}
2095
2096fn prompt_component_name_value(
2097 title: &str,
2098 required: bool,
2099 default: Option<&JsonValue>,
2100) -> Result<InteractiveAnswer, QaLibError> {
2101 loop {
2102 let value = prompt_string_value(title, required, default)?;
2103 let InteractiveAnswer::Value(value) = value else {
2104 return Ok(value);
2105 };
2106 let Some(name) = value.as_str() else {
2107 return Ok(InteractiveAnswer::Value(value));
2108 };
2109 match ComponentName::parse(name) {
2110 Ok(_) => return Ok(InteractiveAnswer::Value(value)),
2111 Err(err) => println!("{}", render_validation_error_detail(&err.into())),
2112 }
2113 }
2114}
2115
2116fn prompt_path(label: String, default: Option<String>) -> Result<PathBuf> {
2117 loop {
2118 if let Some(value) = &default {
2119 print!("{label} [{value}]: ");
2120 } else {
2121 print!("{label}: ");
2122 }
2123 io::stdout().flush()?;
2124 let mut input = String::new();
2125 let read = io::stdin().read_line(&mut input)?;
2126 if read == 0 {
2127 bail!("{}", tr("cli.wizard.error.stdin_closed"));
2128 }
2129 let trimmed = input.trim();
2130 if trimmed.is_empty()
2131 && let Some(value) = &default
2132 {
2133 return Ok(PathBuf::from(value));
2134 }
2135 if !trimmed.is_empty() {
2136 return Ok(PathBuf::from(trimmed));
2137 }
2138 println!("{}", tr("cli.wizard.result.qa_value_required"));
2139 }
2140}
2141
2142fn path_exists_and_non_empty(path: &PathBuf) -> Result<bool> {
2143 if !path.exists() {
2144 return Ok(false);
2145 }
2146 if !path.is_dir() {
2147 return Ok(true);
2148 }
2149 let mut entries = fs::read_dir(path)
2150 .with_context(|| format!("failed to read output directory {}", path.display()))?;
2151 Ok(entries.next().is_some())
2152}
2153
2154fn validate_output_path_available(path: &PathBuf) -> Result<()> {
2155 if !path.exists() {
2156 return Ok(());
2157 }
2158 if !path.is_dir() {
2159 bail!(
2160 "{}",
2161 trf(
2162 "cli.wizard.error.target_path_not_directory",
2163 &[path.display().to_string().as_str()]
2164 )
2165 );
2166 }
2167 if path_exists_and_non_empty(path)? {
2168 bail!(
2169 "{}",
2170 trf(
2171 "cli.wizard.error.target_dir_not_empty",
2172 &[path.display().to_string().as_str()]
2173 )
2174 );
2175 }
2176 Ok(())
2177}
2178
2179fn prompt_yes_no(prompt: String, default_yes: bool) -> Result<InteractiveAnswer> {
2180 let suffix = if default_yes { "[Y/n]" } else { "[y/N]" };
2181 loop {
2182 print!("{prompt} {suffix}: ");
2183 io::stdout().flush()?;
2184 let mut line = String::new();
2185 let read = io::stdin().read_line(&mut line)?;
2186 if read == 0 {
2187 bail!("{}", tr("cli.wizard.error.stdin_closed"));
2188 }
2189 let token = line.trim().to_ascii_lowercase();
2190 if token == "0" {
2191 return Ok(InteractiveAnswer::Back);
2192 }
2193 if token == "m" {
2194 return Ok(InteractiveAnswer::MainMenu);
2195 }
2196 if token.is_empty() {
2197 return Ok(InteractiveAnswer::Value(JsonValue::Bool(default_yes)));
2198 }
2199 match token.as_str() {
2200 "y" | "yes" => return Ok(InteractiveAnswer::Value(JsonValue::Bool(true))),
2201 "n" | "no" => return Ok(InteractiveAnswer::Value(JsonValue::Bool(false))),
2202 _ => println!("{}", tr("cli.wizard.result.qa_answer_yes_no")),
2203 }
2204 }
2205}
2206
2207fn prompt_main_menu_mode(default: RunMode) -> Result<Option<RunMode>> {
2208 println!("{}", tr("cli.wizard.result.interactive_header"));
2209 println!("1) {}", tr("cli.wizard.menu.create_new_component"));
2210 println!("2) {}", tr("cli.wizard.menu.add_operation"));
2211 println!("3) {}", tr("cli.wizard.menu.update_operation"));
2212 println!("4) {}", tr("cli.wizard.menu.build_and_test_component"));
2213 println!("5) {}", tr("cli.wizard.menu.doctor_component"));
2214 println!("0) exit");
2215 let default_label = match default {
2216 RunMode::Create => "1",
2217 RunMode::AddOperation => "2",
2218 RunMode::UpdateOperation => "3",
2219 RunMode::BuildTest => "4",
2220 RunMode::Doctor => "5",
2221 };
2222 loop {
2223 print!(
2224 "{} ",
2225 trf("cli.wizard.prompt.select_option", &[default_label])
2226 );
2227 io::stdout().flush()?;
2228 let mut line = String::new();
2229 let read = io::stdin().read_line(&mut line)?;
2230 if read == 0 {
2231 bail!("{}", tr("cli.wizard.error.stdin_closed"));
2232 }
2233 let token = line.trim().to_ascii_lowercase();
2234 if token == "0" {
2235 return Ok(None);
2236 }
2237 if token == "m" {
2238 continue;
2239 }
2240 let selected = if token.is_empty() {
2241 default_label.to_string()
2242 } else {
2243 token
2244 };
2245 if let Some(mode) = parse_main_menu_selection(&selected) {
2246 return Ok(Some(mode));
2247 }
2248 println!("{}", tr("cli.wizard.result.qa_value_required"));
2249 }
2250}
2251
2252fn parse_main_menu_selection(value: &str) -> Option<RunMode> {
2253 match value.trim().to_ascii_lowercase().as_str() {
2254 "1" | "create" => Some(RunMode::Create),
2255 "2" | "add-operation" | "add_operation" => Some(RunMode::AddOperation),
2256 "3" | "update-operation" | "update_operation" => Some(RunMode::UpdateOperation),
2257 "4" | "build" | "build-test" | "build_test" => Some(RunMode::BuildTest),
2258 "5" | "doctor" => Some(RunMode::Doctor),
2259 _ => None,
2260 }
2261}
2262
2263fn fallback_default_for_question(
2264 args: &WizardArgs,
2265 question_id: &str,
2266 answered: &JsonMap<String, JsonValue>,
2267) -> Option<JsonValue> {
2268 match (args.mode, question_id) {
2269 (RunMode::Create, "component_name") => Some(JsonValue::String("component".to_string())),
2270 (RunMode::Create, "output_dir") => {
2271 let name = answered
2272 .get("component_name")
2273 .and_then(JsonValue::as_str)
2274 .unwrap_or("component");
2275 Some(JsonValue::String(
2276 args.project_root.join(name).display().to_string(),
2277 ))
2278 }
2279 (RunMode::Create, "advanced_setup") => Some(JsonValue::Bool(false)),
2280 (RunMode::Create, "secrets_enabled") => Some(JsonValue::Bool(false)),
2281 (RunMode::Create, "abi_version") => Some(JsonValue::String("0.6.0".to_string())),
2282 (RunMode::Create, "operation_names") | (RunMode::Create, "primary_operation_name") => {
2283 Some(JsonValue::String("handle_message".to_string()))
2284 }
2285 (RunMode::Create, "template_id") => Some(JsonValue::String(default_template_id())),
2286 (RunMode::AddOperation, "project_root")
2287 | (RunMode::UpdateOperation, "project_root")
2288 | (RunMode::BuildTest, "project_root")
2289 | (RunMode::Doctor, "project_root") => {
2290 Some(JsonValue::String(args.project_root.display().to_string()))
2291 }
2292 (RunMode::AddOperation, "set_default_operation")
2293 | (RunMode::UpdateOperation, "set_default_operation") => Some(JsonValue::Bool(false)),
2294 (RunMode::BuildTest, "full_tests") => Some(JsonValue::Bool(args.full_tests)),
2295 _ => None,
2296 }
2297}
2298
2299fn is_secret_question(question_id: &str) -> bool {
2300 matches!(
2301 question_id,
2302 "secret_keys" | "secret_env" | "secret_tenant" | "secret_format"
2303 )
2304}
2305
2306fn should_skip_create_advanced_question(
2307 question_id: &str,
2308 answered: &JsonMap<String, JsonValue>,
2309) -> bool {
2310 if answered.contains_key(question_id) {
2311 return true;
2312 }
2313 if question_id == "filesystem_mounts"
2314 && answered
2315 .get("filesystem_mode")
2316 .and_then(JsonValue::as_str)
2317 .is_some_and(|mode| mode == "none")
2318 {
2319 return true;
2320 }
2321 is_secret_question(question_id)
2322 && !answered
2323 .get("secrets_enabled")
2324 .and_then(JsonValue::as_bool)
2325 .unwrap_or(false)
2326}
2327
2328fn prompt_string_value(
2329 title: &str,
2330 required: bool,
2331 default: Option<&JsonValue>,
2332) -> Result<InteractiveAnswer, QaLibError> {
2333 let default_text = default.and_then(JsonValue::as_str);
2334 loop {
2335 if let Some(value) = default_text {
2336 print!("{title} [{value}]: ");
2337 } else {
2338 print!("{title}: ");
2339 }
2340 io::stdout()
2341 .flush()
2342 .map_err(|err| QaLibError::Component(err.to_string()))?;
2343 let mut input = String::new();
2344 let read = io::stdin()
2345 .read_line(&mut input)
2346 .map_err(|err| QaLibError::Component(err.to_string()))?;
2347 if read == 0 {
2348 return Err(QaLibError::Component(tr("cli.wizard.error.stdin_closed")));
2349 }
2350 let trimmed = input.trim();
2351 if trimmed.eq_ignore_ascii_case("m") {
2352 return Ok(InteractiveAnswer::MainMenu);
2353 }
2354 if trimmed == "0" {
2355 return Ok(InteractiveAnswer::Back);
2356 }
2357 if trimmed.is_empty() {
2358 if let Some(value) = default_text {
2359 return Ok(InteractiveAnswer::Value(JsonValue::String(
2360 value.to_string(),
2361 )));
2362 }
2363 if required {
2364 println!("{}", tr("cli.wizard.result.qa_value_required"));
2365 continue;
2366 }
2367 return Ok(InteractiveAnswer::Value(JsonValue::Null));
2368 }
2369 return Ok(InteractiveAnswer::Value(JsonValue::String(
2370 trimmed.to_string(),
2371 )));
2372 }
2373}
2374
2375fn prompt_bool_value(
2376 title: &str,
2377 required: bool,
2378 default: Option<&JsonValue>,
2379) -> Result<InteractiveAnswer, QaLibError> {
2380 let default_bool = default.and_then(JsonValue::as_bool);
2381 loop {
2382 let suffix = match default_bool {
2383 Some(true) => "[Y/n]",
2384 Some(false) => "[y/N]",
2385 None => "[y/n]",
2386 };
2387 print!("{title} {suffix}: ");
2388 io::stdout()
2389 .flush()
2390 .map_err(|err| QaLibError::Component(err.to_string()))?;
2391 let mut input = String::new();
2392 let read = io::stdin()
2393 .read_line(&mut input)
2394 .map_err(|err| QaLibError::Component(err.to_string()))?;
2395 if read == 0 {
2396 return Err(QaLibError::Component(tr("cli.wizard.error.stdin_closed")));
2397 }
2398 let trimmed = input.trim().to_ascii_lowercase();
2399 if trimmed == "m" {
2400 return Ok(InteractiveAnswer::MainMenu);
2401 }
2402 if trimmed == "0" {
2403 return Ok(InteractiveAnswer::Back);
2404 }
2405 if trimmed.is_empty() {
2406 if let Some(value) = default_bool {
2407 return Ok(InteractiveAnswer::Value(JsonValue::Bool(value)));
2408 }
2409 if required {
2410 println!("{}", tr("cli.wizard.result.qa_value_required"));
2411 continue;
2412 }
2413 return Ok(InteractiveAnswer::Value(JsonValue::Null));
2414 }
2415 match trimmed.as_str() {
2416 "y" | "yes" | "true" | "1" => {
2417 return Ok(InteractiveAnswer::Value(JsonValue::Bool(true)));
2418 }
2419 "n" | "no" | "false" => return Ok(InteractiveAnswer::Value(JsonValue::Bool(false))),
2420 _ => println!("{}", tr("cli.wizard.result.qa_answer_yes_no")),
2421 }
2422 }
2423}
2424
2425fn prompt_enum_value(
2426 question_id: &str,
2427 title: &str,
2428 required: bool,
2429 question: &JsonValue,
2430 default: Option<&JsonValue>,
2431) -> Result<InteractiveAnswer, QaLibError> {
2432 let choices = question
2433 .get("choices")
2434 .and_then(JsonValue::as_array)
2435 .ok_or_else(|| QaLibError::MissingField("choices".to_string()))?
2436 .iter()
2437 .filter_map(JsonValue::as_str)
2438 .map(ToString::to_string)
2439 .collect::<Vec<_>>();
2440 let default_text = default.and_then(JsonValue::as_str);
2441 if choices.is_empty() {
2442 return Err(QaLibError::MissingField("choices".to_string()));
2443 }
2444 loop {
2445 println!("{title}:");
2446 for (idx, choice) in choices.iter().enumerate() {
2447 println!(" {}. {}", idx + 1, enum_choice_label(question_id, choice));
2448 }
2449 if let Some(value) = default_text {
2450 print!(
2451 "{} [{value}] ",
2452 tr("cli.wizard.result.qa_select_number_or_value")
2453 );
2454 } else {
2455 print!("{} ", tr("cli.wizard.result.qa_select_number_or_value"));
2456 }
2457 io::stdout()
2458 .flush()
2459 .map_err(|err| QaLibError::Component(err.to_string()))?;
2460 let mut input = String::new();
2461 let read = io::stdin()
2462 .read_line(&mut input)
2463 .map_err(|err| QaLibError::Component(err.to_string()))?;
2464 if read == 0 {
2465 return Err(QaLibError::Component(tr("cli.wizard.error.stdin_closed")));
2466 }
2467 let trimmed = input.trim();
2468 if trimmed.eq_ignore_ascii_case("m") {
2469 return Ok(InteractiveAnswer::MainMenu);
2470 }
2471 if trimmed == "0" {
2472 return Ok(InteractiveAnswer::Back);
2473 }
2474 if trimmed.is_empty() {
2475 if let Some(value) = default_text {
2476 return Ok(InteractiveAnswer::Value(JsonValue::String(
2477 value.to_string(),
2478 )));
2479 }
2480 if required {
2481 println!("{}", tr("cli.wizard.result.qa_value_required"));
2482 continue;
2483 }
2484 return Ok(InteractiveAnswer::Value(JsonValue::Null));
2485 }
2486 if let Ok(n) = trimmed.parse::<usize>()
2487 && n > 0
2488 && n <= choices.len()
2489 {
2490 return Ok(InteractiveAnswer::Value(JsonValue::String(
2491 choices[n - 1].clone(),
2492 )));
2493 }
2494 if choices.iter().any(|choice| choice == trimmed) {
2495 return Ok(InteractiveAnswer::Value(JsonValue::String(
2496 trimmed.to_string(),
2497 )));
2498 }
2499 println!("{}", tr("cli.wizard.result.qa_invalid_choice"));
2500 }
2501}
2502
2503fn enum_choice_label<'a>(question_id: &str, choice: &'a str) -> &'a str {
2504 let _ = question_id;
2505 choice
2506}
2507
2508fn collect_interactive_question_map(
2509 args: &WizardArgs,
2510 questions: Vec<JsonValue>,
2511) -> Result<Option<JsonMap<String, JsonValue>>> {
2512 collect_interactive_question_map_with_answers(args, questions, JsonMap::new())
2513}
2514
2515fn collect_interactive_question_map_with_answers(
2516 args: &WizardArgs,
2517 questions: Vec<JsonValue>,
2518 answered: JsonMap<String, JsonValue>,
2519) -> Result<Option<JsonMap<String, JsonValue>>> {
2520 collect_interactive_question_map_with_skip(
2521 args,
2522 questions,
2523 answered,
2524 |_question_id, _answered| false,
2525 )
2526}
2527
2528fn collect_interactive_question_map_with_skip(
2529 args: &WizardArgs,
2530 questions: Vec<JsonValue>,
2531 mut answered: JsonMap<String, JsonValue>,
2532 should_skip: fn(&str, &JsonMap<String, JsonValue>) -> bool,
2533) -> Result<Option<JsonMap<String, JsonValue>>> {
2534 let mut index = 0usize;
2535 while index < questions.len() {
2536 let question = &questions[index];
2537 let question_id = question
2538 .get("id")
2539 .and_then(JsonValue::as_str)
2540 .ok_or_else(|| anyhow!("{}", tr("cli.wizard.error.create_missing_question_id")))?
2541 .to_string();
2542
2543 if should_skip(&question_id, &answered) {
2544 index += 1;
2545 continue;
2546 }
2547
2548 match prompt_for_wizard_answer(
2549 &question_id,
2550 question,
2551 fallback_default_for_question(args, &question_id, &answered),
2552 )
2553 .map_err(|err| anyhow!("{err}"))?
2554 {
2555 InteractiveAnswer::MainMenu => return Ok(None),
2556 InteractiveAnswer::Back => {
2557 if let Some(previous) =
2558 previous_interactive_question_index(&questions, index, &answered, should_skip)
2559 {
2560 if let Some(previous_id) =
2561 questions[previous].get("id").and_then(JsonValue::as_str)
2562 {
2563 answered.remove(previous_id);
2564 if previous_id == "output_dir" {
2565 answered.remove("overwrite_output");
2566 }
2567 }
2568 index = previous;
2569 }
2570 }
2571 InteractiveAnswer::Value(answer) => {
2572 let mut advance = true;
2573 if question_id == "output_dir"
2574 && let Some(path) = answer.as_str()
2575 {
2576 let path = PathBuf::from(path);
2577 if path_exists_and_non_empty(&path)? {
2578 match prompt_yes_no(
2579 trf(
2580 "cli.wizard.prompt.overwrite_dir",
2581 &[path.to_string_lossy().as_ref()],
2582 ),
2583 false,
2584 )? {
2585 InteractiveAnswer::MainMenu => return Ok(None),
2586 InteractiveAnswer::Back => {
2587 if let Some(previous) = previous_interactive_question_index(
2588 &questions,
2589 index,
2590 &answered,
2591 should_skip,
2592 ) {
2593 if let Some(previous_id) =
2594 questions[previous].get("id").and_then(JsonValue::as_str)
2595 {
2596 answered.remove(previous_id);
2597 if previous_id == "output_dir" {
2598 answered.remove("overwrite_output");
2599 }
2600 }
2601 index = previous;
2602 }
2603 advance = false;
2604 }
2605 InteractiveAnswer::Value(JsonValue::Bool(true)) => {
2606 answered
2607 .insert("overwrite_output".to_string(), JsonValue::Bool(true));
2608 }
2609 InteractiveAnswer::Value(JsonValue::Bool(false)) => {
2610 println!("{}", tr("cli.wizard.result.choose_another_output_dir"));
2611 advance = false;
2612 }
2613 InteractiveAnswer::Value(_) => {
2614 advance = false;
2615 }
2616 }
2617 }
2618 }
2619 if advance {
2620 answered.insert(question_id, answer);
2621 index += 1;
2622 }
2623 }
2624 }
2625 }
2626 Ok(Some(answered))
2627}
2628
2629fn build_output_with_recovery<F>(
2630 args: &WizardArgs,
2631 execution: ExecutionMode,
2632 answers: Option<&WizardRunAnswers>,
2633 interactive: bool,
2634 mut report: F,
2635) -> Result<Option<WizardRunOutput>>
2636where
2637 F: FnMut(String),
2638{
2639 match build_run_output(args, execution, answers) {
2640 Ok(output) => Ok(Some(output)),
2641 Err(err) if interactive => {
2642 report_interactive_validation_error(&err, &mut report);
2643 Ok(None)
2644 }
2645 Err(err) => Err(err),
2646 }
2647}
2648
2649fn previous_interactive_question_index(
2650 questions: &[JsonValue],
2651 current: usize,
2652 answered: &JsonMap<String, JsonValue>,
2653 should_skip: fn(&str, &JsonMap<String, JsonValue>) -> bool,
2654) -> Option<usize> {
2655 if current == 0 {
2656 return None;
2657 }
2658 for idx in (0..current).rev() {
2659 let question_id = questions[idx]
2660 .get("id")
2661 .and_then(JsonValue::as_str)
2662 .unwrap_or_default();
2663 if !should_skip(question_id, answered) {
2664 return Some(idx);
2665 }
2666 }
2667 None
2668}
2669
2670fn tr(key: &str) -> String {
2671 i18n::tr_key(key)
2672}
2673
2674fn trf(key: &str, args: &[&str]) -> String {
2675 let mut msg = tr(key);
2676 for arg in args {
2677 msg = msg.replacen("{}", arg, 1);
2678 }
2679 msg
2680}
2681
2682fn report_interactive_validation_error<F>(err: &anyhow::Error, mut report: F)
2683where
2684 F: FnMut(String),
2685{
2686 report(tr("cli.wizard.result.qa_validation_error"));
2687 report(render_validation_error_detail(err));
2688}
2689
2690fn render_validation_error_detail(err: &anyhow::Error) -> String {
2691 if let Some(validation) = err.downcast_ref::<ValidationError>() {
2692 return match validation {
2693 ValidationError::EmptyName => tr("cli.wizard.result.qa_value_required"),
2694 ValidationError::InvalidName(name) => {
2695 trf("cli.wizard.validation.component_name_invalid", &[name])
2696 }
2697 ValidationError::InvalidOperationName(name) => {
2698 trf("cli.wizard.error.operation_name_invalid", &[name])
2699 }
2700 ValidationError::InvalidFilesystemMode(mode) => {
2701 trf("cli.wizard.validation.filesystem_mode_invalid", &[mode])
2702 }
2703 ValidationError::InvalidFilesystemMount(mount) => {
2704 trf("cli.wizard.validation.filesystem_mount_invalid", &[mount])
2705 }
2706 ValidationError::InvalidTelemetryScope(scope) => {
2707 trf("cli.wizard.validation.telemetry_scope_invalid", &[scope])
2708 }
2709 ValidationError::InvalidSecretFormat(format) => {
2710 trf("cli.wizard.validation.secret_format_invalid", &[format])
2711 }
2712 ValidationError::InvalidTelemetryAttribute(attr) => {
2713 trf("cli.wizard.validation.telemetry_attribute_invalid", &[attr])
2714 }
2715 ValidationError::InvalidConfigField(field) => {
2716 trf("cli.wizard.validation.config_field_invalid", &[field])
2717 }
2718 ValidationError::InvalidConfigFieldName(name) => {
2719 trf("cli.wizard.validation.config_field_name_invalid", &[name])
2720 }
2721 ValidationError::InvalidConfigFieldType(kind) => {
2722 trf("cli.wizard.validation.config_field_type_invalid", &[kind])
2723 }
2724 ValidationError::TargetIsFile(path) => trf(
2725 "cli.wizard.validation.target_path_is_file",
2726 &[path.display().to_string().as_str()],
2727 ),
2728 ValidationError::TargetDirNotEmpty(path) => trf(
2729 "cli.wizard.error.target_dir_not_empty",
2730 &[path.display().to_string().as_str()],
2731 ),
2732 ValidationError::Io(path, source) => trf(
2733 "cli.wizard.validation.path_io",
2734 &[path.display().to_string().as_str(), &source.to_string()],
2735 ),
2736 _ => validation.to_string(),
2737 };
2738 }
2739 err.to_string()
2740}
2741
2742#[cfg(test)]
2743mod tests {
2744 use anyhow::anyhow;
2745 use serde_json::{Map as JsonMap, Value as JsonValue};
2746
2747 use super::{
2748 ExecutionMode, RunMode, WizardArgs, WizardRunAnswers, build_output_with_recovery,
2749 create_questions, fallback_default_for_question, load_answers_with_recovery,
2750 parse_main_menu_selection, render_validation_error_detail,
2751 should_skip_create_advanced_question, wizard_answer_schema,
2752 };
2753
2754 #[test]
2755 fn parse_main_menu_selection_supports_numeric_options() {
2756 assert_eq!(parse_main_menu_selection("1"), Some(RunMode::Create));
2757 assert_eq!(parse_main_menu_selection("2"), Some(RunMode::AddOperation));
2758 assert_eq!(
2759 parse_main_menu_selection("3"),
2760 Some(RunMode::UpdateOperation)
2761 );
2762 assert_eq!(parse_main_menu_selection("4"), Some(RunMode::BuildTest));
2763 assert_eq!(parse_main_menu_selection("5"), Some(RunMode::Doctor));
2764 }
2765
2766 #[test]
2767 fn parse_main_menu_selection_supports_mode_aliases() {
2768 assert_eq!(parse_main_menu_selection("create"), Some(RunMode::Create));
2769 assert_eq!(
2770 parse_main_menu_selection("add_operation"),
2771 Some(RunMode::AddOperation)
2772 );
2773 assert_eq!(
2774 parse_main_menu_selection("update-operation"),
2775 Some(RunMode::UpdateOperation)
2776 );
2777 assert_eq!(
2778 parse_main_menu_selection("build_test"),
2779 Some(RunMode::BuildTest)
2780 );
2781 assert_eq!(
2782 parse_main_menu_selection("build-test"),
2783 Some(RunMode::BuildTest)
2784 );
2785 assert_eq!(parse_main_menu_selection("doctor"), Some(RunMode::Doctor));
2786 }
2787
2788 #[test]
2789 fn parse_main_menu_selection_rejects_unknown_values() {
2790 assert_eq!(parse_main_menu_selection(""), None);
2791 assert_eq!(parse_main_menu_selection("6"), None);
2792 assert_eq!(parse_main_menu_selection("unknown"), None);
2793 }
2794
2795 #[test]
2796 fn render_validation_error_detail_localizes_component_name_errors() {
2797 let message = render_validation_error_detail(
2798 &crate::scaffold::validate::ComponentName::parse("Bad Name")
2799 .expect_err("invalid component name")
2800 .into(),
2801 );
2802 assert_eq!(
2803 message,
2804 "component name must be lowercase kebab-or-snake case (got `Bad Name`)"
2805 );
2806 }
2807
2808 #[test]
2809 fn interactive_answers_recovery_reports_malformed_answers_without_exiting() {
2810 let temp = tempfile::TempDir::new().expect("tempdir");
2811 let answers_path = temp.path().join("faulty-answers.json");
2812 std::fs::write(&answers_path, "{ this is not valid json").expect("write malformed");
2813 let args = WizardArgs {
2814 mode: RunMode::Create,
2815 execution: ExecutionMode::Execute,
2816 dry_run: false,
2817 validate: false,
2818 apply: false,
2819 qa_answers: None,
2820 answers: Some(answers_path.clone()),
2821 qa_answers_out: None,
2822 emit_answers: None,
2823 schema_version: None,
2824 migrate: false,
2825 plan_out: None,
2826 project_root: temp.path().to_path_buf(),
2827 template: None,
2828 full_tests: false,
2829 json: false,
2830 };
2831
2832 let mut reported = Vec::new();
2833 let loaded = load_answers_with_recovery(Some(&answers_path), &args, true, |line| {
2834 reported.push(line);
2835 })
2836 .expect("interactive recovery should continue");
2837
2838 assert!(
2839 loaded.is_none(),
2840 "malformed answers should fall back to interactive mode"
2841 );
2842 assert_eq!(
2843 reported.first().map(String::as_str),
2844 Some("wizard input failed validation; please correct and try again")
2845 );
2846 assert!(
2847 reported
2848 .iter()
2849 .any(|line| line.contains("must be valid JSON")),
2850 "expected specific parse failure in {reported:?}"
2851 );
2852 }
2853
2854 #[test]
2855 fn interactive_build_recovery_reports_invalid_answer_values_without_exiting() {
2856 let temp = tempfile::TempDir::new().expect("tempdir");
2857 let mut fields = JsonMap::new();
2858 fields.insert(
2859 "component_name".to_string(),
2860 JsonValue::String("Bad Name".to_string()),
2861 );
2862 fields.insert(
2863 "output_dir".to_string(),
2864 JsonValue::String(temp.path().join("component").display().to_string()),
2865 );
2866 fields.insert(
2867 "abi_version".to_string(),
2868 JsonValue::String("0.6.0".to_string()),
2869 );
2870 let answers = WizardRunAnswers {
2871 schema: "component-wizard-run/v1".to_string(),
2872 mode: RunMode::Create,
2873 fields,
2874 };
2875 let args = WizardArgs {
2876 mode: RunMode::Create,
2877 execution: ExecutionMode::Execute,
2878 dry_run: false,
2879 validate: false,
2880 apply: false,
2881 qa_answers: None,
2882 answers: None,
2883 qa_answers_out: None,
2884 emit_answers: None,
2885 schema_version: None,
2886 migrate: false,
2887 plan_out: None,
2888 project_root: temp.path().to_path_buf(),
2889 template: None,
2890 full_tests: false,
2891 json: false,
2892 };
2893
2894 let mut reported = Vec::new();
2895 let output = build_output_with_recovery(
2896 &args,
2897 ExecutionMode::Execute,
2898 Some(&answers),
2899 true,
2900 |line| {
2901 reported.push(line);
2902 },
2903 )
2904 .expect("interactive recovery should continue");
2905
2906 assert!(
2907 output.is_none(),
2908 "invalid answers should return to wizard prompts"
2909 );
2910 assert_eq!(
2911 reported.first().map(String::as_str),
2912 Some("wizard input failed validation; please correct and try again")
2913 );
2914 assert!(
2915 reported.iter().any(|line| {
2916 line.contains("component name must be lowercase kebab-or-snake case")
2917 }),
2918 "expected translated validation detail in {reported:?}"
2919 );
2920 }
2921
2922 #[test]
2923 fn interactive_build_recovery_reports_existing_i18n_errors_without_exiting() {
2924 let mut reported = Vec::new();
2925 super::report_interactive_validation_error(
2926 &anyhow!("unsupported answers mode `broken`"),
2927 |line| reported.push(line),
2928 );
2929 assert_eq!(
2930 reported,
2931 vec![
2932 "wizard input failed validation; please correct and try again".to_string(),
2933 "unsupported answers mode `broken`".to_string()
2934 ]
2935 );
2936 }
2937
2938 #[test]
2939 fn create_questions_minimal_flow_only_asks_core_fields() {
2940 let args = WizardArgs {
2941 mode: RunMode::Create,
2942 execution: super::ExecutionMode::Execute,
2943 dry_run: false,
2944 validate: false,
2945 apply: false,
2946 qa_answers: None,
2947 answers: None,
2948 qa_answers_out: None,
2949 emit_answers: None,
2950 schema_version: None,
2951 migrate: false,
2952 plan_out: None,
2953 project_root: std::path::PathBuf::from("."),
2954 template: None,
2955 full_tests: false,
2956 json: false,
2957 };
2958
2959 let questions = create_questions(&args, false);
2960 let ids = questions
2961 .iter()
2962 .filter_map(|question| question.get("id").and_then(JsonValue::as_str))
2963 .collect::<Vec<_>>();
2964 assert_eq!(ids, vec!["component_name", "output_dir", "advanced_setup"]);
2965 }
2966
2967 #[test]
2968 fn wizard_answer_schema_matches_create_answer_document_shape() {
2969 let args = WizardArgs {
2970 mode: RunMode::Create,
2971 execution: super::ExecutionMode::Execute,
2972 dry_run: false,
2973 validate: false,
2974 apply: false,
2975 qa_answers: None,
2976 answers: None,
2977 qa_answers_out: None,
2978 emit_answers: None,
2979 schema_version: None,
2980 migrate: false,
2981 plan_out: None,
2982 project_root: std::path::PathBuf::from("/tmp/demo"),
2983 template: None,
2984 full_tests: false,
2985 json: false,
2986 };
2987
2988 let schema = wizard_answer_schema(&args);
2989 assert_eq!(
2990 schema.pointer("/required"),
2991 Some(&JsonValue::Array(vec![
2992 JsonValue::String("wizard_id".to_string()),
2993 JsonValue::String("schema_id".to_string()),
2994 JsonValue::String("schema_version".to_string()),
2995 JsonValue::String("answers".to_string()),
2996 ]))
2997 );
2998 assert_eq!(
2999 schema.pointer("/properties/answers/properties/mode/const"),
3000 Some(&JsonValue::String("create".to_string()))
3001 );
3002 assert_eq!(
3003 schema.pointer("/properties/answers/properties/fields/properties/component_name/type"),
3004 Some(&JsonValue::String("string".to_string()))
3005 );
3006 assert_eq!(
3007 schema.pointer("/properties/answers/properties/fields/properties/output_dir/type"),
3008 Some(&JsonValue::String("string".to_string()))
3009 );
3010 assert_eq!(
3011 schema.pointer("/properties/answers/properties/fields/properties/advanced_setup/type"),
3012 Some(&JsonValue::String("boolean".to_string()))
3013 );
3014 assert_eq!(
3015 schema.pointer("/properties/answers/properties/fields/properties/filesystem_mode/enum"),
3016 Some(&JsonValue::Array(vec![
3017 JsonValue::String("none".to_string()),
3018 JsonValue::String("read_only".to_string()),
3019 JsonValue::String("sandbox".to_string()),
3020 ]))
3021 );
3022 }
3023
3024 #[test]
3025 fn create_flow_defaults_advanced_setup_to_false() {
3026 let args = WizardArgs {
3027 mode: RunMode::Create,
3028 execution: super::ExecutionMode::Execute,
3029 dry_run: false,
3030 validate: false,
3031 apply: false,
3032 qa_answers: None,
3033 answers: None,
3034 qa_answers_out: None,
3035 emit_answers: None,
3036 schema_version: None,
3037 migrate: false,
3038 plan_out: None,
3039 project_root: std::path::PathBuf::from("/tmp/demo"),
3040 template: None,
3041 full_tests: false,
3042 json: false,
3043 };
3044
3045 assert_eq!(
3046 fallback_default_for_question(&args, "advanced_setup", &serde_json::Map::new()),
3047 Some(JsonValue::Bool(false))
3048 );
3049 assert_eq!(
3050 fallback_default_for_question(&args, "secrets_enabled", &serde_json::Map::new()),
3051 Some(JsonValue::Bool(false))
3052 );
3053 }
3054
3055 #[test]
3056 fn create_questions_advanced_flow_includes_secret_gate_before_secret_fields() {
3057 let args = WizardArgs {
3058 mode: RunMode::Create,
3059 execution: super::ExecutionMode::Execute,
3060 dry_run: false,
3061 validate: false,
3062 apply: false,
3063 qa_answers: None,
3064 answers: None,
3065 qa_answers_out: None,
3066 emit_answers: None,
3067 schema_version: None,
3068 migrate: false,
3069 plan_out: None,
3070 project_root: std::path::PathBuf::from("."),
3071 template: None,
3072 full_tests: false,
3073 json: false,
3074 };
3075
3076 let questions = create_questions(&args, true);
3077 let ids = questions
3078 .iter()
3079 .filter_map(|question| question.get("id").and_then(JsonValue::as_str))
3080 .collect::<Vec<_>>();
3081 let gate_index = ids.iter().position(|id| *id == "secrets_enabled").unwrap();
3082 let key_index = ids.iter().position(|id| *id == "secret_keys").unwrap();
3083 assert!(gate_index < key_index);
3084 }
3085
3086 #[test]
3087 fn create_questions_advanced_flow_includes_messaging_and_events_fields() {
3088 let args = WizardArgs {
3089 mode: RunMode::Create,
3090 execution: super::ExecutionMode::Execute,
3091 dry_run: false,
3092 validate: false,
3093 apply: false,
3094 qa_answers: None,
3095 answers: None,
3096 qa_answers_out: None,
3097 emit_answers: None,
3098 schema_version: None,
3099 migrate: false,
3100 plan_out: None,
3101 project_root: std::path::PathBuf::from("."),
3102 template: None,
3103 full_tests: false,
3104 json: false,
3105 };
3106
3107 let questions = create_questions(&args, true);
3108 let ids = questions
3109 .iter()
3110 .filter_map(|question| question.get("id").and_then(JsonValue::as_str))
3111 .collect::<Vec<_>>();
3112 assert!(ids.contains(&"messaging_inbound"));
3113 assert!(ids.contains(&"messaging_outbound"));
3114 assert!(ids.contains(&"events_inbound"));
3115 assert!(ids.contains(&"events_outbound"));
3116 }
3117
3118 #[test]
3119 fn advanced_create_flow_skips_questions_answered_in_minimal_pass() {
3120 let mut answered = JsonMap::new();
3121 answered.insert(
3122 "component_name".to_string(),
3123 JsonValue::String("demo".to_string()),
3124 );
3125 answered.insert(
3126 "output_dir".to_string(),
3127 JsonValue::String("/tmp/demo".to_string()),
3128 );
3129 answered.insert("advanced_setup".to_string(), JsonValue::Bool(true));
3130
3131 assert!(should_skip_create_advanced_question(
3132 "component_name",
3133 &answered
3134 ));
3135 assert!(should_skip_create_advanced_question(
3136 "output_dir",
3137 &answered
3138 ));
3139 assert!(should_skip_create_advanced_question(
3140 "advanced_setup",
3141 &answered
3142 ));
3143 assert!(!should_skip_create_advanced_question(
3144 "operation_names",
3145 &answered
3146 ));
3147 }
3148
3149 #[test]
3150 fn advanced_create_flow_skips_filesystem_mounts_when_mode_is_none() {
3151 let mut answered = JsonMap::new();
3152 answered.insert(
3153 "filesystem_mode".to_string(),
3154 JsonValue::String("none".to_string()),
3155 );
3156
3157 assert!(should_skip_create_advanced_question(
3158 "filesystem_mounts",
3159 &answered
3160 ));
3161
3162 answered.insert(
3163 "filesystem_mode".to_string(),
3164 JsonValue::String("sandbox".to_string()),
3165 );
3166
3167 assert!(!should_skip_create_advanced_question(
3168 "filesystem_mounts",
3169 &answered
3170 ));
3171 }
3172}