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