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