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