Skip to main content

greentic_component/cmd/
wizard.rs

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}