Skip to main content

greentic_bundle/wizard/
mod.rs

1use std::collections::BTreeMap;
2use std::fs;
3use std::io::IsTerminal;
4use std::io::{self, BufRead, Write};
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result, bail};
8use greentic_qa_lib::{I18nConfig, WizardDriver, WizardFrontend, WizardRunConfig};
9use semver::Version;
10use serde::Serialize;
11use serde_json::{Map, Value, json};
12
13use crate::answers::{AnswerDocument, migrate::migrate_document};
14use crate::cli::wizard::{WizardApplyArgs, WizardMode, WizardRunArgs, WizardValidateArgs};
15
16pub mod i18n;
17
18mod answers_source;
19
20use answers_source::{AnswersSource, DistributorAnswersClient, resolve_answers_source};
21
22pub const WIZARD_ID: &str = "greentic-bundle.wizard.run";
23pub const ANSWER_SCHEMA_ID: &str = "greentic-bundle.wizard.answers";
24pub const DEFAULT_PROVIDER_REGISTRY: &str =
25    "oci://ghcr.io/greenticai/greentic-bundle/providers:stable";
26const DEPLOYER_AWS_REF: &str = "oci://ghcr.io/greenticai/packs/deployer/greentic.deploy.aws:stable";
27const DEPLOYER_AZURE_REF: &str =
28    "oci://ghcr.io/greenticai/packs/deployer/greentic.deploy.azure:stable";
29const DEPLOYER_GCP_REF: &str = "oci://ghcr.io/greenticai/packs/deployer/greentic.deploy.gcp:stable";
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
32#[serde(rename_all = "snake_case")]
33pub enum ExecutionMode {
34    DryRun,
35    Execute,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct NormalizedRequest {
40    pub mode: WizardMode,
41    pub locale: String,
42    pub bundle_name: String,
43    pub bundle_id: String,
44    pub output_dir: PathBuf,
45    pub local_reference_base_dir: Option<PathBuf>,
46    pub app_pack_entries: Vec<AppPackEntry>,
47    pub access_rules: Vec<AccessRuleInput>,
48    pub extension_provider_entries: Vec<ExtensionProviderEntry>,
49    pub advanced_setup: bool,
50    pub app_packs: Vec<String>,
51    pub extension_providers: Vec<String>,
52    pub remote_catalogs: Vec<String>,
53    pub setup_specs: BTreeMap<String, Value>,
54    pub setup_answers: BTreeMap<String, Value>,
55    pub setup_execution_intent: bool,
56    pub export_intent: bool,
57    pub capabilities: Vec<String>,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
61pub struct AppPackEntry {
62    pub reference: String,
63    pub detected_kind: String,
64    pub pack_id: String,
65    pub display_name: String,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub version: Option<String>,
68    pub mapping: AppPackMappingInput,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
72pub struct AppPackMappingInput {
73    pub scope: String,
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub tenant: Option<String>,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub team: Option<String>,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
81pub struct AccessRuleInput {
82    pub rule_path: String,
83    pub policy: String,
84    pub tenant: String,
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub team: Option<String>,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
90pub struct ExtensionProviderEntry {
91    pub reference: String,
92    pub detected_kind: String,
93    pub provider_id: String,
94    pub display_name: String,
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub version: Option<String>,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub source_catalog: Option<String>,
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub group: Option<String>,
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104enum ReviewAction {
105    BuildNow,
106    DryRunOnly,
107    SaveAnswersOnly,
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111enum InteractiveChoice {
112    Create,
113    Update,
114    Validate,
115    Doctor,
116    Inspect,
117    Unbundle,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum RootMenuZeroAction {
122    Exit,
123    Back,
124}
125
126#[derive(Debug)]
127struct InteractiveRequest {
128    request: NormalizedRequest,
129    review_action: ReviewAction,
130}
131
132enum InteractiveSelection {
133    Request(Box<InteractiveRequest>),
134    Handled,
135}
136
137enum BundleTarget {
138    Workspace(PathBuf),
139    Artifact(PathBuf),
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
143pub struct WizardPlanEnvelope {
144    pub metadata: PlanMetadata,
145    pub target_root: String,
146    pub requested_action: String,
147    pub normalized_input_summary: BTreeMap<String, Value>,
148    pub ordered_step_list: Vec<WizardPlanStep>,
149    pub expected_file_writes: Vec<String>,
150    pub warnings: Vec<String>,
151}
152
153#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
154pub struct PlanMetadata {
155    pub wizard_id: String,
156    pub schema_id: String,
157    pub schema_version: String,
158    pub locale: String,
159    pub execution: ExecutionMode,
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
163pub struct WizardPlanStep {
164    pub kind: StepKind,
165    pub description: String,
166}
167
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
169#[serde(rename_all = "snake_case")]
170pub enum StepKind {
171    EnsureWorkspace,
172    WriteBundleFile,
173    UpdateAccessRules,
174    ResolveRefs,
175    WriteLock,
176    BuildBundle,
177    ExportBundle,
178}
179
180#[derive(Debug)]
181pub struct WizardRunResult {
182    pub plan: WizardPlanEnvelope,
183    pub document: AnswerDocument,
184    pub applied_files: Vec<PathBuf>,
185}
186
187struct LoadedRequest {
188    request: NormalizedRequest,
189    locks: BTreeMap<String, Value>,
190    build_bundle_now: bool,
191}
192
193pub fn run_command(args: WizardRunArgs) -> Result<()> {
194    let locale = crate::i18n::current_locale();
195    let result = if let Some(path) = args.answers.as_ref() {
196        let loaded = load_and_normalize_answers(
197            path,
198            args.mode,
199            args.schema_version.as_deref(),
200            args.migrate,
201            &locale,
202        )?;
203        execute_request(
204            loaded.request,
205            execution_for_run(args.dry_run),
206            loaded.build_bundle_now && !args.dry_run,
207            args.schema_version.as_deref(),
208            args.emit_answers.as_ref(),
209            Some(loaded.locks),
210            &args.env,
211        )?
212    } else {
213        run_interactive(
214            args.mode,
215            args.emit_answers.as_ref(),
216            args.schema_version.as_deref(),
217            execution_for_run(args.dry_run),
218            &args.env,
219        )?
220    };
221    print_plan(&result.plan)?;
222    Ok(())
223}
224
225pub fn answer_document_schema(
226    mode: Option<WizardMode>,
227    schema_version: Option<&str>,
228) -> Result<Value> {
229    let schema_version = requested_schema_version(schema_version)?;
230    let selected_mode = mode.map(mode_name);
231    let mode_schema = match selected_mode {
232        Some(mode) => json!({
233            "type": "string",
234            "const": mode,
235            "description": "Wizard mode. When omitted, the CLI can also supply --mode."
236        }),
237        None => json!({
238            "type": "string",
239            "enum": ["create", "update", "doctor"],
240            "description": "Wizard mode. Defaults to create when omitted unless the CLI supplies --mode."
241        }),
242    };
243
244    Ok(json!({
245        "$schema": "https://json-schema.org/draft/2020-12/schema",
246        "$id": "https://greenticai.github.io/greentic-bundle/schemas/wizard.answers.schema.json",
247        "title": "greentic-bundle wizard answers",
248        "type": "object",
249        "additionalProperties": false,
250        "properties": {
251            "wizard_id": {
252                "type": "string",
253                "const": WIZARD_ID
254            },
255            "schema_id": {
256                "type": "string",
257                "const": ANSWER_SCHEMA_ID
258            },
259            "schema_version": {
260                "type": "string",
261                "const": schema_version.to_string()
262            },
263            "locale": {
264                "type": "string",
265                "minLength": 1
266            },
267            "env_id": {
268                "type": "string",
269                "minLength": 1,
270                "description": "Environment id the wizard ran under (C7). Absent for pre-C7 documents."
271            },
272            "answers": {
273                "type": "object",
274                "additionalProperties": false,
275                "properties": {
276                    "mode": mode_schema,
277                    "bundle_name": non_empty_string_schema("Human-friendly bundle name."),
278                    "bundle_id": non_empty_string_schema("Stable bundle id."),
279                    "output_dir": non_empty_string_schema("Workspace output directory."),
280                    "advanced_setup": {
281                        "type": "boolean"
282                    },
283                    "app_pack_entries": {
284                        "type": "array",
285                        "items": app_pack_entry_schema()
286                    },
287                    "access_rules": {
288                        "type": "array",
289                        "items": access_rule_schema()
290                    },
291                    "extension_provider_entries": {
292                        "type": "array",
293                        "items": extension_provider_entry_schema()
294                    },
295                    "app_packs": string_array_schema("App-pack references or local paths."),
296                    "extension_providers": string_array_schema("Extension provider references or local paths."),
297                    "cloud_deployer_target": {
298                        "type": "string",
299                        "enum": ["aws", "azure", "gcp"]
300                    },
301                    "remote_catalogs": string_array_schema("Additional remote catalog references."),
302                    "setup_specs": {
303                        "type": "object",
304                        "additionalProperties": true
305                    },
306                    "setup_answers": {
307                        "type": "object",
308                        "additionalProperties": true
309                    },
310                    "setup_execution_intent": {
311                        "type": "boolean"
312                    },
313                    "export_intent": {
314                        "type": "boolean"
315                    },
316                    "capabilities": string_array_schema("Requested bundle capabilities.")
317                },
318                "required": ["bundle_name", "bundle_id"]
319            },
320            "locks": {
321                "type": "object",
322                "additionalProperties": true,
323                "properties": {
324                    "execution": {
325                        "type": "string",
326                        "enum": ["dry_run", "execute"]
327                    }
328                }
329            }
330        },
331        "required": ["wizard_id", "schema_id", "schema_version", "locale", "answers"]
332    }))
333}
334
335fn non_empty_string_schema(description: &str) -> Value {
336    json!({
337        "type": "string",
338        "minLength": 1,
339        "description": description
340    })
341}
342
343fn string_array_schema(description: &str) -> Value {
344    json!({
345        "type": "array",
346        "description": description,
347        "items": {
348            "type": "string",
349            "minLength": 1
350        }
351    })
352}
353
354fn app_pack_entry_schema() -> Value {
355    json!({
356        "type": "object",
357        "additionalProperties": false,
358        "properties": {
359            "reference": non_empty_string_schema("Resolved reference or source path."),
360            "detected_kind": non_empty_string_schema("Detected source kind."),
361            "pack_id": non_empty_string_schema("Pack id."),
362            "display_name": non_empty_string_schema("Pack display name."),
363            "version": {
364                "type": ["string", "null"]
365            },
366            "mapping": app_pack_mapping_schema()
367        },
368        "required": ["reference", "detected_kind", "pack_id", "display_name", "mapping"]
369    })
370}
371
372fn app_pack_mapping_schema() -> Value {
373    json!({
374        "type": "object",
375        "additionalProperties": false,
376        "properties": {
377            "scope": {
378                "type": "string",
379                "enum": ["global", "tenant", "tenant_team"]
380            },
381            "tenant": {
382                "type": ["string", "null"]
383            },
384            "team": {
385                "type": ["string", "null"]
386            }
387        },
388        "required": ["scope"]
389    })
390}
391
392fn access_rule_schema() -> Value {
393    json!({
394        "type": "object",
395        "additionalProperties": false,
396        "properties": {
397            "rule_path": non_empty_string_schema("Resolved GMAP rule path."),
398            "policy": {
399                "type": "string",
400                "enum": ["allow", "forbid"]
401            },
402            "tenant": non_empty_string_schema("Tenant id."),
403            "team": {
404                "type": ["string", "null"]
405            }
406        },
407        "required": ["rule_path", "policy", "tenant"]
408    })
409}
410
411fn extension_provider_entry_schema() -> Value {
412    json!({
413        "type": "object",
414        "additionalProperties": false,
415        "properties": {
416            "reference": non_empty_string_schema("Resolved reference or source path."),
417            "detected_kind": non_empty_string_schema("Detected source kind."),
418            "provider_id": non_empty_string_schema("Provider id."),
419            "display_name": non_empty_string_schema("Provider display name."),
420            "version": {
421                "type": ["string", "null"]
422            },
423            "source_catalog": {
424                "type": ["string", "null"]
425            },
426            "group": {
427                "type": ["string", "null"]
428            }
429        },
430        "required": ["reference", "detected_kind", "provider_id", "display_name"]
431    })
432}
433
434pub fn validate_command(args: WizardValidateArgs) -> Result<()> {
435    let locale = crate::i18n::current_locale();
436    let loaded = load_and_normalize_answers(
437        &args.answers,
438        args.mode,
439        args.schema_version.as_deref(),
440        args.migrate,
441        &locale,
442    )?;
443    let result = execute_request(
444        loaded.request,
445        ExecutionMode::DryRun,
446        false,
447        args.schema_version.as_deref(),
448        args.emit_answers.as_ref(),
449        Some(loaded.locks),
450        &args.env,
451    )?;
452    print_plan(&result.plan)?;
453    Ok(())
454}
455
456pub fn apply_command(args: WizardApplyArgs) -> Result<()> {
457    let locale = crate::i18n::current_locale();
458    let loaded = load_and_normalize_answers(
459        &args.answers,
460        args.mode,
461        args.schema_version.as_deref(),
462        args.migrate,
463        &locale,
464    )?;
465    let execution = if args.dry_run {
466        ExecutionMode::DryRun
467    } else {
468        ExecutionMode::Execute
469    };
470    let result = execute_request(
471        loaded.request,
472        execution,
473        loaded.build_bundle_now && execution == ExecutionMode::Execute,
474        args.schema_version.as_deref(),
475        args.emit_answers.as_ref(),
476        Some(loaded.locks),
477        &args.env,
478    )?;
479    print_plan(&result.plan)?;
480    Ok(())
481}
482
483pub fn run_interactive(
484    initial_mode: Option<WizardMode>,
485    emit_answers: Option<&PathBuf>,
486    schema_version: Option<&str>,
487    execution: ExecutionMode,
488    env_id: &str,
489) -> Result<WizardRunResult> {
490    match run_interactive_with_zero_action(
491        initial_mode,
492        emit_answers,
493        schema_version,
494        execution,
495        RootMenuZeroAction::Exit,
496        env_id,
497    )? {
498        Some(result) => Ok(result),
499        None => bail!("{}", crate::i18n::tr("wizard.exit.message")),
500    }
501}
502
503pub fn run_interactive_with_zero_action(
504    initial_mode: Option<WizardMode>,
505    emit_answers: Option<&PathBuf>,
506    schema_version: Option<&str>,
507    execution: ExecutionMode,
508    zero_action: RootMenuZeroAction,
509    env_id: &str,
510) -> Result<Option<WizardRunResult>> {
511    let stdin = io::stdin();
512    let stdout = io::stdout();
513    let mut input = stdin.lock();
514    let mut output = stdout.lock();
515    loop {
516        let Some(selection) = collect_guided_interactive_request(
517            &mut input,
518            &mut output,
519            initial_mode,
520            zero_action,
521            env_id,
522        )?
523        else {
524            return Ok(None);
525        };
526        let InteractiveSelection::Request(interactive) = selection else {
527            if initial_mode.is_none() {
528                continue;
529            }
530            return Ok(None);
531        };
532        let resolved_execution = match execution {
533            ExecutionMode::DryRun => ExecutionMode::DryRun,
534            ExecutionMode::Execute => match interactive.review_action {
535                ReviewAction::BuildNow => ExecutionMode::Execute,
536                ReviewAction::DryRunOnly | ReviewAction::SaveAnswersOnly => ExecutionMode::DryRun,
537            },
538        };
539        return Ok(Some(execute_request(
540            interactive.request,
541            resolved_execution,
542            matches!(interactive.review_action, ReviewAction::BuildNow)
543                && resolved_execution == ExecutionMode::Execute,
544            schema_version,
545            emit_answers,
546            None,
547            env_id,
548        )?));
549    }
550}
551
552fn collect_guided_interactive_request<R: BufRead, W: Write>(
553    input: &mut R,
554    output: &mut W,
555    initial_mode: Option<WizardMode>,
556    zero_action: RootMenuZeroAction,
557    env_id: &str,
558) -> Result<Option<InteractiveSelection>> {
559    if let Some(mode) = initial_mode {
560        let interactive = match mode {
561            WizardMode::Create => collect_create_flow(input, output, env_id)?,
562            WizardMode::Update => collect_update_flow(input, output, false, env_id)?,
563            WizardMode::Doctor => collect_doctor_flow(input, output, env_id)?,
564        };
565        return Ok(Some(InteractiveSelection::Request(Box::new(interactive))));
566    }
567
568    let Some(choice) = choose_interactive_menu(input, output, zero_action)? else {
569        return Ok(None);
570    };
571
572    match choice {
573        InteractiveChoice::Create => Ok(Some(InteractiveSelection::Request(Box::new(
574            collect_create_flow(input, output, env_id)?,
575        )))),
576        InteractiveChoice::Update => Ok(Some(InteractiveSelection::Request(Box::new(
577            collect_update_flow(input, output, false, env_id)?,
578        )))),
579        InteractiveChoice::Validate => Ok(Some(InteractiveSelection::Request(Box::new(
580            collect_update_flow(input, output, true, env_id)?,
581        )))),
582        InteractiveChoice::Doctor => {
583            perform_doctor_action(input, output)?;
584            Ok(Some(InteractiveSelection::Handled))
585        }
586        InteractiveChoice::Inspect => {
587            perform_inspect_action(input, output)?;
588            Ok(Some(InteractiveSelection::Handled))
589        }
590        InteractiveChoice::Unbundle => {
591            perform_unbundle_action(input, output)?;
592            Ok(Some(InteractiveSelection::Handled))
593        }
594    }
595}
596
597fn choose_interactive_menu<R: BufRead, W: Write>(
598    input: &mut R,
599    output: &mut W,
600    zero_action: RootMenuZeroAction,
601) -> Result<Option<InteractiveChoice>> {
602    writeln!(output, "{}", crate::i18n::tr("wizard.menu.title"))?;
603    write_root_menu_option(
604        output,
605        "1",
606        &crate::i18n::tr("wizard.mode.create"),
607        &crate::i18n::tr("wizard.menu_desc.create"),
608    )?;
609    write_root_menu_option(
610        output,
611        "2",
612        &crate::i18n::tr("wizard.mode.update"),
613        &crate::i18n::tr("wizard.menu_desc.update"),
614    )?;
615    write_root_menu_option(
616        output,
617        "3",
618        &crate::i18n::tr("wizard.mode.validate"),
619        &crate::i18n::tr("wizard.menu_desc.validate"),
620    )?;
621    write_root_menu_option(
622        output,
623        "4",
624        &crate::i18n::tr("wizard.mode.doctor"),
625        &crate::i18n::tr("wizard.menu_desc.doctor"),
626    )?;
627    write_root_menu_option(
628        output,
629        "5",
630        &crate::i18n::tr("wizard.mode.inspect"),
631        &crate::i18n::tr("wizard.menu_desc.inspect"),
632    )?;
633    write_root_menu_option(
634        output,
635        "6",
636        &crate::i18n::tr("wizard.mode.unbundle"),
637        &crate::i18n::tr("wizard.menu_desc.unbundle"),
638    )?;
639    let zero_label = match zero_action {
640        RootMenuZeroAction::Exit => crate::i18n::tr("wizard.menu.exit"),
641        RootMenuZeroAction::Back => crate::i18n::tr("wizard.action.back"),
642    };
643    writeln!(output, "0. {zero_label}")?;
644    loop {
645        write!(output, "{} ", crate::i18n::tr("wizard.setup.enum_prompt"))?;
646        output.flush()?;
647        let mut line = String::new();
648        if input.read_line(&mut line)? == 0 {
649            bail!("{}", crate::i18n::tr("wizard.error.input_ended"));
650        }
651        match line.trim() {
652            "0" => match zero_action {
653                RootMenuZeroAction::Exit => bail!("{}", crate::i18n::tr("wizard.exit.message")),
654                RootMenuZeroAction::Back => return Ok(None),
655            },
656            "1" | "create" => return Ok(Some(InteractiveChoice::Create)),
657            "2" | "update" | "open" => return Ok(Some(InteractiveChoice::Update)),
658            "3" | "validate" => return Ok(Some(InteractiveChoice::Validate)),
659            "4" | "doctor" => return Ok(Some(InteractiveChoice::Doctor)),
660            "5" | "inspect" => return Ok(Some(InteractiveChoice::Inspect)),
661            "6" | "unbundle" => return Ok(Some(InteractiveChoice::Unbundle)),
662            _ => writeln!(output, "{}", crate::i18n::tr("wizard.error.invalid_choice"))?,
663        }
664    }
665}
666
667fn write_root_menu_option<W: Write>(
668    output: &mut W,
669    number: &str,
670    title: &str,
671    description: &str,
672) -> Result<()> {
673    writeln!(output, "{number}. {title}")?;
674    writeln!(output, "   {description}")?;
675    Ok(())
676}
677
678fn collect_create_flow<R: BufRead, W: Write>(
679    input: &mut R,
680    output: &mut W,
681    _env_id: &str,
682) -> Result<InteractiveRequest> {
683    let locale = crate::i18n::current_locale();
684    let bundle_name = prompt_required_string(
685        input,
686        output,
687        &crate::i18n::tr("wizard.prompt.bundle_name"),
688        None,
689    )?;
690    let bundle_id = normalize_bundle_id(&prompt_required_string(
691        input,
692        output,
693        &crate::i18n::tr("wizard.prompt.bundle_id"),
694        None,
695    )?);
696    let mut state = normalize_request(SeedRequest {
697        mode: WizardMode::Create,
698        locale,
699        bundle_name,
700        bundle_id: bundle_id.clone(),
701        output_dir: PathBuf::from(prompt_required_string(
702            input,
703            output,
704            &crate::i18n::tr("wizard.prompt.output_dir"),
705            Some(&default_bundle_output_dir(&bundle_id).display().to_string()),
706        )?),
707        app_pack_entries: Vec::new(),
708        access_rules: Vec::new(),
709        extension_provider_entries: Vec::new(),
710        cloud_deployer_target: None,
711        advanced_setup: false,
712        app_packs: Vec::new(),
713        extension_providers: Vec::new(),
714        remote_catalogs: Vec::new(),
715        setup_specs: BTreeMap::new(),
716        setup_answers: BTreeMap::new(),
717        setup_execution_intent: false,
718        export_intent: false,
719        capabilities: Vec::new(),
720    });
721    state = edit_app_packs(input, output, state, false)?;
722    state = edit_extension_providers(input, output, state, false)?;
723    state = edit_cloud_deployer_target(input, output, state)?;
724    state = edit_bundle_capabilities(input, output, state)?;
725    let review_action = review_summary(input, output, &state, false)?;
726    Ok(InteractiveRequest {
727        request: state,
728        review_action,
729    })
730}
731
732fn collect_update_flow<R: BufRead, W: Write>(
733    input: &mut R,
734    output: &mut W,
735    validate_only: bool,
736    _env_id: &str,
737) -> Result<InteractiveRequest> {
738    let (target, mut state) = prompt_request_from_bundle_target(
739        input,
740        output,
741        &crate::i18n::tr("wizard.prompt.current_bundle_root"),
742        WizardMode::Update,
743    )?;
744    if matches!(target, BundleTarget::Artifact(_)) && !validate_only {
745        state.output_dir = PathBuf::from(prompt_required_string(
746            input,
747            output,
748            &crate::i18n::tr("wizard.prompt.output_dir"),
749            Some(&state.output_dir.display().to_string()),
750        )?);
751    }
752    state.bundle_name = prompt_required_string(
753        input,
754        output,
755        &crate::i18n::tr("wizard.prompt.bundle_name"),
756        Some(&state.bundle_name),
757    )?;
758    state.bundle_id = normalize_bundle_id(&prompt_required_string(
759        input,
760        output,
761        &crate::i18n::tr("wizard.prompt.bundle_id"),
762        Some(&state.bundle_id),
763    )?);
764    if !validate_only {
765        state = edit_app_packs(input, output, state, true)?;
766        state = edit_extension_providers(input, output, state, true)?;
767        state = edit_cloud_deployer_target(input, output, state)?;
768        state = edit_bundle_capabilities(input, output, state)?;
769        let review_action = review_summary(input, output, &state, true)?;
770        Ok(InteractiveRequest {
771            request: state,
772            review_action,
773        })
774    } else {
775        Ok(InteractiveRequest {
776            request: state,
777            review_action: ReviewAction::DryRunOnly,
778        })
779    }
780}
781
782fn collect_doctor_flow<R: BufRead, W: Write>(
783    input: &mut R,
784    output: &mut W,
785    _env_id: &str,
786) -> Result<InteractiveRequest> {
787    Ok(InteractiveRequest {
788        request: prompt_request_from_bundle_target(
789            input,
790            output,
791            &crate::i18n::tr("wizard.prompt.current_bundle_root"),
792            WizardMode::Doctor,
793        )?
794        .1,
795        review_action: ReviewAction::DryRunOnly,
796    })
797}
798
799fn perform_doctor_action<R: BufRead, W: Write>(input: &mut R, output: &mut W) -> Result<()> {
800    run_prompted_bundle_target_action(
801        input,
802        output,
803        &crate::i18n::tr("wizard.prompt.bundle_target"),
804        |target| match target {
805            BundleTarget::Workspace(root) => crate::build::doctor_target(Some(root), None),
806            BundleTarget::Artifact(artifact) => crate::build::doctor_target(None, Some(artifact)),
807        },
808    )
809}
810
811fn perform_inspect_action<R: BufRead, W: Write>(input: &mut R, output: &mut W) -> Result<()> {
812    loop {
813        let target = prompt_bundle_target(
814            input,
815            output,
816            &crate::i18n::tr("wizard.prompt.bundle_target"),
817        )?;
818        match inspect_bundle_target(output, &target) {
819            Ok(()) => return Ok(()),
820            Err(error) => writeln!(output, "{error}")?,
821        }
822    }
823}
824
825fn perform_unbundle_action<R: BufRead, W: Write>(input: &mut R, output: &mut W) -> Result<()> {
826    loop {
827        let artifact = prompt_bundle_artifact_path(input, output)?;
828        let out = prompt_optional_string(
829            input,
830            output,
831            &crate::i18n::tr("wizard.prompt.unbundle_output_dir"),
832            Some("."),
833        )?;
834        match crate::build::unbundle_artifact(&artifact, Path::new(&out)) {
835            Ok(result) => {
836                writeln!(output, "{}", serde_json::to_string_pretty(&result)?)?;
837                return Ok(());
838            }
839            Err(error) => writeln!(output, "{error}")?,
840        }
841    }
842}
843
844fn prompt_bundle_target<R: BufRead, W: Write>(
845    input: &mut R,
846    output: &mut W,
847    title: &str,
848) -> Result<BundleTarget> {
849    loop {
850        let raw = prompt_required_string(input, output, title, None)?;
851        match parse_bundle_target(PathBuf::from(raw)) {
852            Ok(target) => return Ok(target),
853            Err(error) => writeln!(output, "{error}")?,
854        }
855    }
856}
857
858fn prompt_bundle_artifact_path<R: BufRead, W: Write>(
859    input: &mut R,
860    output: &mut W,
861) -> Result<PathBuf> {
862    loop {
863        let raw = prompt_required_string(
864            input,
865            output,
866            &crate::i18n::tr("wizard.prompt.bundle_artifact"),
867            None,
868        )?;
869        let path = PathBuf::from(raw);
870        if !is_bundle_artifact_path(&path) {
871            writeln!(
872                output,
873                "{}",
874                crate::i18n::tr("wizard.error.bundle_artifact_required")
875            )?;
876            continue;
877        }
878        if !path.exists() {
879            writeln!(
880                output,
881                "{}: {}",
882                crate::i18n::tr("wizard.error.bundle_target_missing"),
883                path.display()
884            )?;
885            continue;
886        }
887        return Ok(path);
888    }
889}
890
891fn prompt_request_from_bundle_target<R: BufRead, W: Write>(
892    input: &mut R,
893    output: &mut W,
894    title: &str,
895    mode: WizardMode,
896) -> Result<(BundleTarget, NormalizedRequest)> {
897    loop {
898        let target = prompt_bundle_target(input, output, title)?;
899        match request_from_bundle_target(&target, mode) {
900            Ok(request) => return Ok((target, request)),
901            Err(error) => writeln!(output, "{error}")?,
902        }
903    }
904}
905
906fn run_prompted_bundle_target_action<R: BufRead, W: Write, T, F>(
907    input: &mut R,
908    output: &mut W,
909    title: &str,
910    action: F,
911) -> Result<()>
912where
913    T: Serialize,
914    F: Fn(&BundleTarget) -> Result<T>,
915{
916    loop {
917        let target = prompt_bundle_target(input, output, title)?;
918        match action(&target) {
919            Ok(report) => {
920                writeln!(output, "{}", serde_json::to_string_pretty(&report)?)?;
921                return Ok(());
922            }
923            Err(error) => writeln!(output, "{error}")?,
924        }
925    }
926}
927
928fn inspect_bundle_target<W: Write>(output: &mut W, target: &BundleTarget) -> Result<()> {
929    let report = match target {
930        BundleTarget::Workspace(root) => crate::build::inspect_target(Some(root), None)?,
931        BundleTarget::Artifact(artifact) => crate::build::inspect_target(None, Some(artifact))?,
932    };
933    if report.kind == "artifact" {
934        for entry in report.contents.as_deref().unwrap_or(&[]) {
935            writeln!(output, "{entry}")?;
936        }
937    } else {
938        writeln!(output, "{}", serde_json::to_string_pretty(&report)?)?;
939    }
940    Ok(())
941}
942
943fn parse_bundle_target(path: PathBuf) -> Result<BundleTarget> {
944    if !path.exists() {
945        bail!(
946            "{}: {}",
947            crate::i18n::tr("wizard.error.bundle_target_missing"),
948            path.display()
949        );
950    }
951    if is_bundle_artifact_path(&path) {
952        Ok(BundleTarget::Artifact(path))
953    } else {
954        Ok(BundleTarget::Workspace(path))
955    }
956}
957
958fn request_from_bundle_target(
959    target: &BundleTarget,
960    mode: WizardMode,
961) -> Result<NormalizedRequest> {
962    match target {
963        BundleTarget::Workspace(root) => {
964            let workspace = crate::project::read_bundle_workspace(root)
965                .with_context(|| format!("read current bundle workspace {}", root.display()))?;
966            Ok(request_from_workspace(&workspace, root, mode))
967        }
968        BundleTarget::Artifact(artifact) => {
969            let staging = tempfile::tempdir().with_context(|| {
970                format!("create temporary workspace for {}", artifact.display())
971            })?;
972            crate::build::unbundle_artifact(artifact, staging.path())?;
973            let workspace =
974                crate::project::read_bundle_workspace(staging.path()).with_context(|| {
975                    format!("read unbundled bundle workspace {}", artifact.display())
976                })?;
977            let mut request = request_from_workspace(&workspace, staging.path(), mode);
978            request.output_dir = default_workspace_dir_for_artifact(artifact);
979            Ok(request)
980        }
981    }
982}
983
984fn default_workspace_dir_for_artifact(artifact: &Path) -> PathBuf {
985    let stem = artifact
986        .file_stem()
987        .map(|value| value.to_os_string())
988        .unwrap_or_else(|| "bundle".into());
989    artifact
990        .parent()
991        .unwrap_or_else(|| Path::new("."))
992        .join(stem)
993}
994
995fn is_bundle_artifact_path(path: &Path) -> bool {
996    path.extension()
997        .and_then(|value| value.to_str())
998        .is_some_and(|value| value.eq_ignore_ascii_case("gtbundle"))
999}
1000
1001fn execution_for_run(dry_run: bool) -> ExecutionMode {
1002    if dry_run {
1003        ExecutionMode::DryRun
1004    } else {
1005        ExecutionMode::Execute
1006    }
1007}
1008
1009fn execute_request(
1010    request: NormalizedRequest,
1011    execution: ExecutionMode,
1012    build_bundle_now: bool,
1013    schema_version: Option<&str>,
1014    emit_answers: Option<&PathBuf>,
1015    source_locks: Option<BTreeMap<String, Value>>,
1016    env_id: &str,
1017) -> Result<WizardRunResult> {
1018    let target_version = requested_schema_version(schema_version)?;
1019    if !request.remote_catalogs.is_empty() {
1020        eprintln!(
1021            "[resolve] Resolving {} remote catalog(s)...",
1022            request.remote_catalogs.len()
1023        );
1024    }
1025    let catalog_resolution = crate::catalog::resolve::resolve_catalogs(
1026        &request.output_dir,
1027        &request.remote_catalogs,
1028        &crate::catalog::resolve::CatalogResolveOptions {
1029            offline: crate::runtime::offline(),
1030            write_cache: execution == ExecutionMode::Execute,
1031        },
1032    )?;
1033    if !request.remote_catalogs.is_empty() {
1034        eprintln!(
1035            "[resolve] Catalog resolution complete ({} entries)",
1036            catalog_resolution.entries.len()
1037        );
1038    }
1039    let request = discover_setup_specs(request, &catalog_resolution);
1040    let setup_writes = preview_setup_writes(&request, execution, env_id)?;
1041    let bundle_lock = build_bundle_lock(
1042        &request,
1043        execution,
1044        &catalog_resolution,
1045        &setup_writes,
1046        env_id,
1047    );
1048    let plan = build_plan(
1049        &request,
1050        execution,
1051        build_bundle_now,
1052        &target_version,
1053        &catalog_resolution.cache_writes,
1054        &setup_writes,
1055    );
1056    let mut document = answer_document_from_request(&request, Some(&target_version.to_string()))?;
1057    document.env_id = Some(env_id.to_string());
1058    let mut locks = source_locks.unwrap_or_default();
1059    locks.extend(bundle_lock_to_answer_locks(&bundle_lock));
1060    document.locks = locks;
1061    let applied_files = if execution == ExecutionMode::Execute {
1062        let mut applied_files = apply_plan(&request, &bundle_lock, env_id)?;
1063        if build_bundle_now {
1064            let build_result =
1065                crate::build::build_workspace(&request.output_dir, None, false, false, None)?;
1066            applied_files.push(PathBuf::from(build_result.artifact_path));
1067        }
1068        applied_files.sort();
1069        applied_files.dedup();
1070        applied_files
1071    } else {
1072        Vec::new()
1073    };
1074    if let Some(path) = emit_answers {
1075        write_answer_document(path, &document)?;
1076    }
1077    Ok(WizardRunResult {
1078        plan,
1079        document,
1080        applied_files,
1081    })
1082}
1083
1084#[allow(dead_code)]
1085fn collect_interactive_request<R: BufRead, W: Write>(
1086    input: &mut R,
1087    output: &mut W,
1088    env_id: &str,
1089    initial_mode: Option<WizardMode>,
1090    last_compact_title: &mut Option<String>,
1091) -> Result<NormalizedRequest> {
1092    let mode = match initial_mode {
1093        Some(mode) => mode,
1094        None => choose_mode_via_qa(input, output, env_id, last_compact_title)?,
1095    };
1096    let request = match mode {
1097        WizardMode::Update => collect_update_request(input, output, env_id, last_compact_title)?,
1098        WizardMode::Create | WizardMode::Doctor => {
1099            let answers = run_qa_form(
1100                input,
1101                output,
1102                env_id,
1103                &wizard_request_form_spec_json(mode, None)?,
1104                None,
1105                "root wizard",
1106                last_compact_title,
1107            )?;
1108            normalized_request_from_qa_answers(answers, crate::i18n::current_locale(), mode)?
1109        }
1110    };
1111    collect_interactive_setup_answers(input, output, env_id, request, last_compact_title)
1112}
1113
1114#[allow(dead_code)]
1115fn parse_csv_answers(raw: &str) -> Vec<String> {
1116    raw.split(',')
1117        .map(str::trim)
1118        .filter(|entry| !entry.is_empty())
1119        .map(ToOwned::to_owned)
1120        .collect()
1121}
1122
1123#[allow(dead_code)]
1124fn choose_mode_via_qa<R: BufRead, W: Write>(
1125    input: &mut R,
1126    output: &mut W,
1127    env_id: &str,
1128    last_compact_title: &mut Option<String>,
1129) -> Result<WizardMode> {
1130    let config = WizardRunConfig {
1131        spec_json: json!({
1132            "id": "greentic-bundle-wizard-mode",
1133            "title": crate::i18n::tr("wizard.menu.title"),
1134            "version": "1.0.0",
1135            "presentation": {
1136                "default_locale": crate::i18n::current_locale()
1137            },
1138            "progress_policy": {
1139                "skip_answered": true,
1140                "autofill_defaults": false,
1141                "treat_default_as_answered": false
1142            },
1143            "questions": [{
1144                "id": "mode",
1145                "type": "enum",
1146                "title": crate::i18n::tr("wizard.prompt.main_choice"),
1147                "required": true,
1148                "choices": ["create", "update", "doctor"]
1149            }]
1150        })
1151        .to_string(),
1152        initial_answers_json: None,
1153        frontend: WizardFrontend::JsonUi,
1154        i18n: I18nConfig {
1155            locale: Some(crate::i18n::current_locale()),
1156            resolved: None,
1157            debug: false,
1158        },
1159        verbose: false,
1160        env_id: env_id.to_string(),
1161    };
1162    let mut driver =
1163        WizardDriver::new(config).context("initialize greentic-qa-lib wizard mode form")?;
1164
1165    loop {
1166        driver
1167            .next_payload_json()
1168            .context("render greentic-qa-lib wizard mode payload")?;
1169        if driver.is_complete() {
1170            break;
1171        }
1172
1173        let ui_raw = driver
1174            .last_ui_json()
1175            .ok_or_else(|| anyhow::anyhow!("greentic-qa-lib wizard mode missing UI state"))?;
1176        let ui: Value =
1177            serde_json::from_str(ui_raw).context("parse greentic-qa-lib wizard mode UI payload")?;
1178        let question = ui
1179            .get("questions")
1180            .and_then(Value::as_array)
1181            .and_then(|questions| questions.first())
1182            .ok_or_else(|| anyhow::anyhow!("greentic-qa-lib wizard mode missing question"))?;
1183
1184        let answer = prompt_wizard_mode_question(input, output, question)?;
1185        driver
1186            .submit_patch_json(&json!({ "mode": answer }).to_string())
1187            .context("submit greentic-qa-lib wizard mode answer")?;
1188    }
1189    *last_compact_title = Some(crate::i18n::tr("wizard.menu.title"));
1190
1191    let answers = driver
1192        .finish()
1193        .context("finish greentic-qa-lib wizard mode")?
1194        .answer_set
1195        .answers;
1196
1197    Ok(
1198        match answers
1199            .get("mode")
1200            .and_then(Value::as_str)
1201            .unwrap_or("create")
1202        {
1203            "update" => WizardMode::Update,
1204            "doctor" => WizardMode::Doctor,
1205            _ => WizardMode::Create,
1206        },
1207    )
1208}
1209
1210#[allow(dead_code)]
1211fn prompt_wizard_mode_question<R: BufRead, W: Write>(
1212    input: &mut R,
1213    output: &mut W,
1214    question: &Value,
1215) -> Result<Value> {
1216    writeln!(output, "{}", crate::i18n::tr("wizard.menu.title"))?;
1217    let choices = question
1218        .get("choices")
1219        .and_then(Value::as_array)
1220        .ok_or_else(|| anyhow::anyhow!("wizard mode question missing choices"))?;
1221    for (index, choice) in choices.iter().enumerate() {
1222        let choice = choice
1223            .as_str()
1224            .ok_or_else(|| anyhow::anyhow!("wizard mode choice must be a string"))?;
1225        writeln!(
1226            output,
1227            "{}. {}",
1228            index + 1,
1229            crate::i18n::tr(&format!("wizard.mode.{choice}"))
1230        )?;
1231    }
1232    prompt_compact_enum(
1233        input,
1234        output,
1235        question,
1236        true,
1237        question_default_value(question, "enum"),
1238    )
1239}
1240
1241#[allow(dead_code)]
1242fn prompt_compact_enum<R: BufRead, W: Write>(
1243    input: &mut R,
1244    output: &mut W,
1245    question: &Value,
1246    required: bool,
1247    default_value: Option<Value>,
1248) -> Result<Value> {
1249    let choices = question
1250        .get("choices")
1251        .and_then(Value::as_array)
1252        .ok_or_else(|| anyhow::anyhow!("qa enum question missing choices"))?
1253        .iter()
1254        .filter_map(Value::as_str)
1255        .map(ToOwned::to_owned)
1256        .collect::<Vec<_>>();
1257
1258    loop {
1259        write!(output, "{} ", crate::i18n::tr("wizard.setup.enum_prompt"))?;
1260        output.flush()?;
1261
1262        let mut line = String::new();
1263        if input.read_line(&mut line)? == 0 {
1264            bail!("{}", crate::i18n::tr("wizard.error.input_ended"));
1265        }
1266        let trimmed = line.trim();
1267        if trimmed.is_empty() {
1268            if let Some(default) = &default_value {
1269                return Ok(default.clone());
1270            }
1271            if required {
1272                writeln!(output, "{}", crate::i18n::tr("wizard.error.empty_answer"))?;
1273                continue;
1274            }
1275            return Ok(Value::Null);
1276        }
1277        if let Ok(number) = trimmed.parse::<usize>()
1278            && number > 0
1279            && number <= choices.len()
1280        {
1281            return Ok(Value::String(choices[number - 1].clone()));
1282        }
1283        if choices.iter().any(|choice| choice == trimmed) {
1284            return Ok(Value::String(trimmed.to_string()));
1285        }
1286        writeln!(output, "{}", crate::i18n::tr("wizard.error.invalid_choice"))?;
1287    }
1288}
1289
1290#[allow(dead_code)]
1291fn collect_update_request<R: BufRead, W: Write>(
1292    input: &mut R,
1293    output: &mut W,
1294    env_id: &str,
1295    last_compact_title: &mut Option<String>,
1296) -> Result<NormalizedRequest> {
1297    let root_answers = run_qa_form(
1298        input,
1299        output,
1300        env_id,
1301        &json!({
1302            "id": "greentic-bundle-update-root",
1303            "title": crate::i18n::tr("wizard.menu.update"),
1304            "version": "1.0.0",
1305            "presentation": {
1306                "default_locale": crate::i18n::current_locale()
1307            },
1308            "progress_policy": {
1309                "skip_answered": true,
1310                "autofill_defaults": false,
1311                "treat_default_as_answered": false
1312            },
1313            "questions": [{
1314                "id": "output_dir",
1315                "type": "string",
1316                "title": crate::i18n::tr("wizard.prompt.current_bundle_root"),
1317                "required": true
1318            }]
1319        })
1320        .to_string(),
1321        None,
1322        "update bundle root",
1323        last_compact_title,
1324    )?;
1325    let root = PathBuf::from(
1326        root_answers
1327            .get("output_dir")
1328            .and_then(Value::as_str)
1329            .ok_or_else(|| anyhow::anyhow!("update wizard missing current bundle root"))?,
1330    );
1331    let workspace = crate::project::read_bundle_workspace(&root)
1332        .with_context(|| format!("read current bundle workspace {}", root.display()))?;
1333    let defaults = request_defaults_from_workspace(&workspace, &root);
1334    let answers = run_qa_form(
1335        input,
1336        output,
1337        env_id,
1338        &wizard_request_form_spec_json(WizardMode::Update, Some(&defaults))?,
1339        None,
1340        "update wizard",
1341        last_compact_title,
1342    )?;
1343    normalized_request_from_qa_answers(answers, crate::i18n::current_locale(), WizardMode::Update)
1344}
1345
1346#[allow(dead_code)]
1347fn request_defaults_from_workspace(
1348    workspace: &crate::project::BundleWorkspaceDefinition,
1349    root: &Path,
1350) -> RequestDefaults {
1351    RequestDefaults {
1352        bundle_name: Some(workspace.bundle_name.clone()),
1353        bundle_id: Some(workspace.bundle_id.clone()),
1354        output_dir: Some(root.display().to_string()),
1355        advanced_setup: Some(workspace.advanced_setup.to_string()),
1356        app_packs: Some(workspace.app_packs.join(", ")),
1357        extension_providers: Some(workspace.extension_providers.join(", ")),
1358        cloud_deployer_target: detect_cloud_deployer_target(&workspace.extension_providers),
1359        remote_catalogs: Some(workspace.remote_catalogs.join(", ")),
1360        setup_execution_intent: Some(workspace.setup_execution_intent.to_string()),
1361        export_intent: Some(workspace.export_intent.to_string()),
1362    }
1363}
1364
1365#[allow(dead_code)]
1366fn run_qa_form<R: BufRead, W: Write>(
1367    input: &mut R,
1368    output: &mut W,
1369    env_id: &str,
1370    spec_json: &str,
1371    initial_answers_json: Option<String>,
1372    context_label: &str,
1373    last_compact_title: &mut Option<String>,
1374) -> Result<Value> {
1375    let config = WizardRunConfig {
1376        spec_json: spec_json.to_string(),
1377        initial_answers_json,
1378        frontend: WizardFrontend::Text,
1379        i18n: I18nConfig {
1380            locale: Some(crate::i18n::current_locale()),
1381            resolved: None,
1382            debug: false,
1383        },
1384        verbose: false,
1385        env_id: env_id.to_string(),
1386    };
1387    let mut driver = WizardDriver::new(config)
1388        .with_context(|| format!("initialize greentic-qa-lib {context_label}"))?;
1389    loop {
1390        let payload_raw = driver
1391            .next_payload_json()
1392            .with_context(|| format!("render greentic-qa-lib {context_label} payload"))?;
1393        let payload: Value = serde_json::from_str(&payload_raw)
1394            .with_context(|| format!("parse greentic-qa-lib {context_label} payload"))?;
1395        if let Some(text) = payload.get("text").and_then(Value::as_str) {
1396            render_qa_driver_text(output, text, last_compact_title)?;
1397        }
1398        if driver.is_complete() {
1399            break;
1400        }
1401
1402        let ui_raw = driver.last_ui_json().ok_or_else(|| {
1403            anyhow::anyhow!("greentic-qa-lib {context_label} payload missing UI state")
1404        })?;
1405        let ui: Value = serde_json::from_str(ui_raw)
1406            .with_context(|| format!("parse greentic-qa-lib {context_label} UI payload"))?;
1407        let question_id = ui
1408            .get("next_question_id")
1409            .and_then(Value::as_str)
1410            .ok_or_else(|| {
1411                anyhow::anyhow!("greentic-qa-lib {context_label} missing next_question_id")
1412            })?
1413            .to_string();
1414        let question = ui
1415            .get("questions")
1416            .and_then(Value::as_array)
1417            .and_then(|questions| {
1418                questions.iter().find(|question| {
1419                    question.get("id").and_then(Value::as_str) == Some(question_id.as_str())
1420                })
1421            })
1422            .ok_or_else(|| {
1423                anyhow::anyhow!("greentic-qa-lib {context_label} missing question {question_id}")
1424            })?;
1425
1426        let answer = prompt_qa_question_answer(input, output, &question_id, question)?;
1427        driver
1428            .submit_patch_json(&json!({ question_id: answer }).to_string())
1429            .with_context(|| format!("submit greentic-qa-lib {context_label} answer"))?;
1430    }
1431
1432    let result = driver
1433        .finish()
1434        .with_context(|| format!("finish greentic-qa-lib {context_label}"))?;
1435    Ok(result.answer_set.answers)
1436}
1437
1438#[allow(dead_code)]
1439#[derive(Debug, Clone, Default)]
1440struct RequestDefaults {
1441    bundle_name: Option<String>,
1442    bundle_id: Option<String>,
1443    output_dir: Option<String>,
1444    advanced_setup: Option<String>,
1445    app_packs: Option<String>,
1446    extension_providers: Option<String>,
1447    cloud_deployer_target: Option<String>,
1448    remote_catalogs: Option<String>,
1449    setup_execution_intent: Option<String>,
1450    export_intent: Option<String>,
1451}
1452
1453#[allow(dead_code)]
1454fn wizard_request_form_spec_json(
1455    mode: WizardMode,
1456    defaults: Option<&RequestDefaults>,
1457) -> Result<String> {
1458    let defaults = defaults.cloned().unwrap_or_default();
1459    let output_dir_default = defaults.output_dir.clone().or_else(|| {
1460        if matches!(mode, WizardMode::Create) {
1461            defaults
1462                .bundle_id
1463                .as_deref()
1464                .map(default_bundle_output_dir)
1465                .map(|path| path.display().to_string())
1466        } else {
1467            None
1468        }
1469    });
1470    Ok(json!({
1471        "id": format!("greentic-bundle-root-wizard-{}", mode_name(mode)),
1472        "title": crate::i18n::tr("wizard.menu.title"),
1473        "version": "1.0.0",
1474        "presentation": {
1475            "default_locale": crate::i18n::current_locale()
1476        },
1477        "progress_policy": {
1478            "skip_answered": true,
1479            "autofill_defaults": false,
1480            "treat_default_as_answered": false
1481        },
1482        "questions": [
1483            {
1484                "id": "bundle_name",
1485                "type": "string",
1486                "title": crate::i18n::tr("wizard.prompt.bundle_name"),
1487                "required": true,
1488                "default_value": defaults.bundle_name
1489            },
1490            {
1491                "id": "bundle_id",
1492                "type": "string",
1493                "title": crate::i18n::tr("wizard.prompt.bundle_id"),
1494                "required": true,
1495                "default_value": defaults.bundle_id
1496            },
1497            {
1498                "id": "output_dir",
1499                "type": "string",
1500                "title": crate::i18n::tr("wizard.prompt.output_dir"),
1501                "required": !matches!(mode, WizardMode::Create),
1502                "default_value": output_dir_default
1503            },
1504            {
1505                "id": "advanced_setup",
1506                "type": "boolean",
1507                "title": crate::i18n::tr("wizard.prompt.advanced_setup"),
1508                "required": true,
1509                "default_value": defaults.advanced_setup.unwrap_or_else(|| "false".to_string())
1510            },
1511            {
1512                "id": "app_packs",
1513                "type": "string",
1514                "title": crate::i18n::tr("wizard.prompt.app_packs"),
1515                "required": false,
1516                "default_value": defaults.app_packs,
1517                "visible_if": { "op": "var", "path": "/advanced_setup" }
1518            },
1519            {
1520                "id": "extension_providers",
1521                "type": "string",
1522                "title": crate::i18n::tr("wizard.prompt.extension_providers"),
1523                "required": false,
1524                "default_value": defaults.extension_providers,
1525                "visible_if": { "op": "var", "path": "/advanced_setup" }
1526            },
1527            {
1528                "id": "cloud_deployer_target",
1529                "type": "string",
1530                "title": "Cloud deployer target",
1531                "required": false,
1532                "default_value": defaults.cloud_deployer_target,
1533                "enum": ["", "aws", "azure", "gcp"],
1534                "enum_labels": ["None", "AWS", "Azure", "GCP"],
1535                "visible_if": { "op": "var", "path": "/advanced_setup" }
1536            },
1537            {
1538                "id": "remote_catalogs",
1539                "type": "string",
1540                "title": crate::i18n::tr("wizard.prompt.remote_catalogs"),
1541                "required": false,
1542                "default_value": defaults.remote_catalogs,
1543                "visible_if": { "op": "var", "path": "/advanced_setup" }
1544            },
1545            {
1546                "id": "setup_execution_intent",
1547                "type": "boolean",
1548                "title": crate::i18n::tr("wizard.prompt.setup_execution"),
1549                "required": true,
1550                "default_value": defaults
1551                    .setup_execution_intent
1552                    .unwrap_or_else(|| "false".to_string()),
1553                "visible_if": { "op": "var", "path": "/advanced_setup" }
1554            },
1555            {
1556                "id": "export_intent",
1557                "type": "boolean",
1558                "title": crate::i18n::tr("wizard.prompt.export_intent"),
1559                "required": true,
1560                "default_value": defaults.export_intent.unwrap_or_else(|| "false".to_string()),
1561                "visible_if": { "op": "var", "path": "/advanced_setup" }
1562            }
1563        ]
1564    })
1565    .to_string())
1566}
1567
1568#[derive(Debug)]
1569struct SeedRequest {
1570    mode: WizardMode,
1571    locale: String,
1572    bundle_name: String,
1573    bundle_id: String,
1574    output_dir: PathBuf,
1575    app_pack_entries: Vec<AppPackEntry>,
1576    access_rules: Vec<AccessRuleInput>,
1577    extension_provider_entries: Vec<ExtensionProviderEntry>,
1578    cloud_deployer_target: Option<String>,
1579    advanced_setup: bool,
1580    app_packs: Vec<String>,
1581    extension_providers: Vec<String>,
1582    remote_catalogs: Vec<String>,
1583    setup_specs: BTreeMap<String, Value>,
1584    setup_answers: BTreeMap<String, Value>,
1585    setup_execution_intent: bool,
1586    export_intent: bool,
1587    capabilities: Vec<String>,
1588}
1589
1590fn normalize_request(seed: SeedRequest) -> NormalizedRequest {
1591    let bundle_id = normalize_bundle_id(&seed.bundle_id);
1592    let mut app_pack_entries = seed.app_pack_entries;
1593    if app_pack_entries.is_empty() {
1594        app_pack_entries = seed
1595            .app_packs
1596            .iter()
1597            .map(|reference| AppPackEntry {
1598                reference: reference.clone(),
1599                detected_kind: "legacy".to_string(),
1600                pack_id: inferred_reference_id(reference),
1601                display_name: inferred_display_name(reference),
1602                version: inferred_reference_version(reference),
1603                mapping: AppPackMappingInput {
1604                    scope: "global".to_string(),
1605                    tenant: None,
1606                    team: None,
1607                },
1608            })
1609            .collect();
1610    }
1611    app_pack_entries.sort_by(|left, right| left.reference.cmp(&right.reference));
1612    app_pack_entries.dedup_by(|left, right| {
1613        left.reference == right.reference
1614            && left.mapping.scope == right.mapping.scope
1615            && left.mapping.tenant == right.mapping.tenant
1616            && left.mapping.team == right.mapping.team
1617    });
1618    let mut app_packs = seed.app_packs;
1619    app_packs.extend(app_pack_entries.iter().map(|entry| entry.reference.clone()));
1620
1621    let mut extension_provider_entries = seed.extension_provider_entries;
1622    if let Some(target) = seed.cloud_deployer_target.as_deref() {
1623        apply_cloud_deployer_target_to_entries(&mut extension_provider_entries, target);
1624    }
1625    if extension_provider_entries.is_empty() {
1626        extension_provider_entries = seed
1627            .extension_providers
1628            .iter()
1629            .map(|reference| ExtensionProviderEntry {
1630                reference: reference.clone(),
1631                detected_kind: "legacy".to_string(),
1632                provider_id: inferred_reference_id(reference),
1633                display_name: inferred_display_name(reference),
1634                version: inferred_reference_version(reference),
1635                source_catalog: None,
1636                group: None,
1637            })
1638            .collect();
1639    }
1640    extension_provider_entries.sort_by(|left, right| left.reference.cmp(&right.reference));
1641    extension_provider_entries.dedup_by(|left, right| left.reference == right.reference);
1642    let mut extension_providers = seed.extension_providers;
1643    extension_providers.extend(
1644        extension_provider_entries
1645            .iter()
1646            .map(|entry| entry.reference.clone()),
1647    );
1648
1649    let mut remote_catalogs = seed.remote_catalogs;
1650    remote_catalogs.extend(
1651        extension_provider_entries
1652            .iter()
1653            .filter_map(|entry| entry.source_catalog.clone()),
1654    );
1655
1656    let access_rules = if seed.access_rules.is_empty() {
1657        derive_access_rules_from_entries(&app_pack_entries)
1658    } else {
1659        normalize_access_rules(seed.access_rules)
1660    };
1661
1662    NormalizedRequest {
1663        mode: seed.mode,
1664        locale: crate::i18n::normalize_locale(&seed.locale).unwrap_or_else(|| "en".to_string()),
1665        bundle_name: seed.bundle_name.trim().to_string(),
1666        bundle_id,
1667        output_dir: normalize_output_dir(seed.output_dir),
1668        local_reference_base_dir: None,
1669        app_pack_entries,
1670        access_rules,
1671        extension_provider_entries,
1672        advanced_setup: seed.advanced_setup,
1673        app_packs: sorted_unique(app_packs),
1674        extension_providers: sorted_unique(extension_providers),
1675        remote_catalogs: sorted_unique(remote_catalogs),
1676        setup_specs: seed.setup_specs,
1677        setup_answers: seed.setup_answers,
1678        setup_execution_intent: seed.setup_execution_intent,
1679        export_intent: seed.export_intent,
1680        capabilities: sorted_unique(seed.capabilities),
1681    }
1682}
1683
1684fn normalize_access_rules(mut rules: Vec<AccessRuleInput>) -> Vec<AccessRuleInput> {
1685    rules.retain(|rule| !rule.rule_path.trim().is_empty() && !rule.tenant.trim().is_empty());
1686    rules.sort_by(|left, right| {
1687        left.tenant
1688            .cmp(&right.tenant)
1689            .then(left.team.cmp(&right.team))
1690            .then(left.rule_path.cmp(&right.rule_path))
1691            .then(left.policy.cmp(&right.policy))
1692    });
1693    rules.dedup_by(|left, right| {
1694        left.tenant == right.tenant
1695            && left.team == right.team
1696            && left.rule_path == right.rule_path
1697            && left.policy == right.policy
1698    });
1699    rules
1700}
1701
1702fn request_from_workspace(
1703    workspace: &crate::project::BundleWorkspaceDefinition,
1704    root: &Path,
1705    mode: WizardMode,
1706) -> NormalizedRequest {
1707    let app_pack_entries = if workspace.app_pack_mappings.is_empty() {
1708        workspace
1709            .app_packs
1710            .iter()
1711            .map(|reference| AppPackEntry {
1712                pack_id: inferred_reference_id(reference),
1713                display_name: inferred_display_name(reference),
1714                version: inferred_reference_version(reference),
1715                detected_kind: detected_reference_kind(root, reference).to_string(),
1716                reference: reference.clone(),
1717                mapping: AppPackMappingInput {
1718                    scope: "global".to_string(),
1719                    tenant: None,
1720                    team: None,
1721                },
1722            })
1723            .collect::<Vec<_>>()
1724    } else {
1725        workspace
1726            .app_pack_mappings
1727            .iter()
1728            .map(|mapping| AppPackEntry {
1729                pack_id: inferred_reference_id(&mapping.reference),
1730                display_name: inferred_display_name(&mapping.reference),
1731                version: inferred_reference_version(&mapping.reference),
1732                detected_kind: detected_reference_kind(root, &mapping.reference).to_string(),
1733                reference: mapping.reference.clone(),
1734                mapping: AppPackMappingInput {
1735                    scope: match mapping.scope {
1736                        crate::project::MappingScope::Global => "global".to_string(),
1737                        crate::project::MappingScope::Tenant => "tenant".to_string(),
1738                        crate::project::MappingScope::Team => "tenant_team".to_string(),
1739                    },
1740                    tenant: mapping.tenant.clone(),
1741                    team: mapping.team.clone(),
1742                },
1743            })
1744            .collect::<Vec<_>>()
1745    };
1746
1747    let access_rules = derive_access_rules_from_entries(&app_pack_entries);
1748    let extension_provider_entries = workspace
1749        .extension_providers
1750        .iter()
1751        .map(|reference| ExtensionProviderEntry {
1752            provider_id: inferred_reference_id(reference),
1753            display_name: inferred_display_name(reference),
1754            version: inferred_reference_version(reference),
1755            detected_kind: detected_reference_kind(root, reference).to_string(),
1756            reference: reference.clone(),
1757            source_catalog: workspace.remote_catalogs.first().cloned(),
1758            group: None,
1759        })
1760        .collect();
1761
1762    normalize_request(SeedRequest {
1763        mode,
1764        locale: workspace.locale.clone(),
1765        bundle_name: workspace.bundle_name.clone(),
1766        bundle_id: workspace.bundle_id.clone(),
1767        output_dir: root.to_path_buf(),
1768        app_pack_entries,
1769        access_rules,
1770        extension_provider_entries,
1771        cloud_deployer_target: detect_cloud_deployer_target(&workspace.extension_providers),
1772        advanced_setup: false,
1773        app_packs: workspace.app_packs.clone(),
1774        extension_providers: workspace.extension_providers.clone(),
1775        remote_catalogs: workspace.remote_catalogs.clone(),
1776        setup_specs: BTreeMap::new(),
1777        setup_answers: BTreeMap::new(),
1778        setup_execution_intent: false,
1779        export_intent: false,
1780        capabilities: workspace.capabilities.clone(),
1781    })
1782}
1783
1784fn prompt_required_string<R: BufRead, W: Write>(
1785    input: &mut R,
1786    output: &mut W,
1787    title: &str,
1788    default: Option<&str>,
1789) -> Result<String> {
1790    loop {
1791        let value = prompt_optional_string(input, output, title, default)?;
1792        if !value.trim().is_empty() {
1793            return Ok(value);
1794        }
1795        writeln!(output, "{}", crate::i18n::tr("wizard.error.empty_answer"))?;
1796    }
1797}
1798
1799fn prompt_optional_string<R: BufRead, W: Write>(
1800    input: &mut R,
1801    output: &mut W,
1802    title: &str,
1803    default: Option<&str>,
1804) -> Result<String> {
1805    let default_value = default.map(|value| Value::String(value.to_string()));
1806    let value = prompt_qa_string_like(input, output, title, false, false, default_value)?;
1807    Ok(value.as_str().unwrap_or_default().to_string())
1808}
1809
1810fn edit_app_packs<R: BufRead, W: Write>(
1811    input: &mut R,
1812    output: &mut W,
1813    mut state: NormalizedRequest,
1814    allow_back: bool,
1815) -> Result<NormalizedRequest> {
1816    loop {
1817        writeln!(output, "{}", crate::i18n::tr("wizard.stage.app_packs"))?;
1818        render_pack_entries(output, &state.app_pack_entries)?;
1819        writeln!(
1820            output,
1821            "1. {}",
1822            crate::i18n::tr("wizard.action.add_app_pack")
1823        )?;
1824        writeln!(
1825            output,
1826            "2. {}",
1827            crate::i18n::tr("wizard.action.edit_app_pack_mapping")
1828        )?;
1829        writeln!(
1830            output,
1831            "3. {}",
1832            crate::i18n::tr("wizard.action.remove_app_pack")
1833        )?;
1834        writeln!(output, "4. {}", crate::i18n::tr("wizard.action.continue"))?;
1835        if allow_back {
1836            writeln!(output, "0. {}", crate::i18n::tr("wizard.action.back"))?;
1837        }
1838
1839        let answer = prompt_menu_value(input, output)?;
1840        match answer.as_str() {
1841            "1" => {
1842                if let Some(entry) = add_app_pack(input, output, &state)? {
1843                    state.app_pack_entries.push(entry);
1844                    state.access_rules = derive_access_rules_from_entries(&state.app_pack_entries);
1845                    state = rebuild_request(state);
1846                }
1847            }
1848            "2" => {
1849                if !state.app_pack_entries.is_empty() {
1850                    state = edit_pack_access(input, output, state, true)?;
1851                }
1852            }
1853            "3" => {
1854                remove_app_pack(input, output, &mut state)?;
1855                state.access_rules = derive_access_rules_from_entries(&state.app_pack_entries);
1856                state = rebuild_request(state);
1857            }
1858            "4" => {
1859                if state.app_pack_entries.is_empty() {
1860                    writeln!(
1861                        output,
1862                        "{}",
1863                        crate::i18n::tr("wizard.error.app_pack_required")
1864                    )?;
1865                    continue;
1866                }
1867                return Ok(state);
1868            }
1869            "0" if allow_back => return Ok(state),
1870            _ => writeln!(output, "{}", crate::i18n::tr("wizard.error.invalid_choice"))?,
1871        }
1872    }
1873}
1874
1875fn edit_pack_access<R: BufRead, W: Write>(
1876    input: &mut R,
1877    output: &mut W,
1878    mut state: NormalizedRequest,
1879    allow_back: bool,
1880) -> Result<NormalizedRequest> {
1881    loop {
1882        writeln!(output, "{}", crate::i18n::tr("wizard.stage.pack_access"))?;
1883        render_pack_entries(output, &state.app_pack_entries)?;
1884        writeln!(
1885            output,
1886            "1. {}",
1887            crate::i18n::tr("wizard.action.change_scope")
1888        )?;
1889        writeln!(
1890            output,
1891            "2. {}",
1892            crate::i18n::tr("wizard.action.add_tenant_access")
1893        )?;
1894        writeln!(
1895            output,
1896            "3. {}",
1897            crate::i18n::tr("wizard.action.add_tenant_team_access")
1898        )?;
1899        writeln!(
1900            output,
1901            "4. {}",
1902            crate::i18n::tr("wizard.action.remove_scope")
1903        )?;
1904        writeln!(output, "5. {}", crate::i18n::tr("wizard.action.continue"))?;
1905        writeln!(
1906            output,
1907            "6. {}",
1908            crate::i18n::tr("wizard.action.advanced_access_rules")
1909        )?;
1910        if allow_back {
1911            writeln!(output, "0. {}", crate::i18n::tr("wizard.action.back"))?;
1912        }
1913        let answer = prompt_menu_value(input, output)?;
1914        match answer.as_str() {
1915            "1" => change_pack_scope(input, output, &mut state)?,
1916            "2" => add_pack_scope(input, output, &mut state, false)?,
1917            "3" => add_pack_scope(input, output, &mut state, true)?,
1918            "4" => remove_pack_scope(input, output, &mut state)?,
1919            "5" => return Ok(rebuild_request(state)),
1920            "6" => edit_advanced_access_rules(input, output, &mut state)?,
1921            "0" if allow_back => return Ok(rebuild_request(state)),
1922            _ => writeln!(output, "{}", crate::i18n::tr("wizard.error.invalid_choice"))?,
1923        }
1924        state.access_rules = derive_access_rules_from_entries(&state.app_pack_entries);
1925    }
1926}
1927
1928fn edit_extension_providers<R: BufRead, W: Write>(
1929    input: &mut R,
1930    output: &mut W,
1931    mut state: NormalizedRequest,
1932    allow_back: bool,
1933) -> Result<NormalizedRequest> {
1934    loop {
1935        writeln!(
1936            output,
1937            "{}",
1938            crate::i18n::tr("wizard.stage.extension_providers")
1939        )?;
1940        render_named_entries(
1941            output,
1942            &crate::i18n::tr("wizard.stage.current_extension_providers"),
1943            &state
1944                .extension_provider_entries
1945                .iter()
1946                .map(|entry| format!("{} [{}]", entry.display_name, entry.reference))
1947                .collect::<Vec<_>>(),
1948        )?;
1949        writeln!(
1950            output,
1951            "1. {}",
1952            crate::i18n::tr("wizard.action.add_common_extension_provider")
1953        )?;
1954        writeln!(
1955            output,
1956            "2. {}",
1957            crate::i18n::tr("wizard.action.add_custom_extension_provider")
1958        )?;
1959        writeln!(
1960            output,
1961            "3. {}",
1962            crate::i18n::tr("wizard.action.remove_extension_provider")
1963        )?;
1964        writeln!(output, "4. {}", crate::i18n::tr("wizard.action.continue"))?;
1965        if allow_back {
1966            writeln!(output, "0. {}", crate::i18n::tr("wizard.action.back"))?;
1967        }
1968        let answer = prompt_menu_value(input, output)?;
1969        match answer.as_str() {
1970            "1" => {
1971                if let Some(entry) = add_common_extension_provider(input, output, &state)? {
1972                    state.extension_provider_entries.push(entry);
1973                    state = rebuild_request(state);
1974                }
1975            }
1976            "2" => {
1977                if let Some(entry) = add_custom_extension_provider(input, output, &state)? {
1978                    state.extension_provider_entries.push(entry);
1979                    state = rebuild_request(state);
1980                }
1981            }
1982            "3" => {
1983                remove_extension_provider(input, output, &mut state)?;
1984                state = rebuild_request(state);
1985            }
1986            "4" => return Ok(state),
1987            "0" if allow_back => return Ok(state),
1988            _ => writeln!(output, "{}", crate::i18n::tr("wizard.error.invalid_choice"))?,
1989        }
1990    }
1991}
1992
1993fn review_summary<R: BufRead, W: Write>(
1994    input: &mut R,
1995    output: &mut W,
1996    state: &NormalizedRequest,
1997    include_edit_paths: bool,
1998) -> Result<ReviewAction> {
1999    loop {
2000        writeln!(output, "{}", crate::i18n::tr("wizard.stage.review"))?;
2001        writeln!(
2002            output,
2003            "{}: {}",
2004            crate::i18n::tr("wizard.prompt.bundle_name"),
2005            state.bundle_name
2006        )?;
2007        writeln!(
2008            output,
2009            "{}: {}",
2010            crate::i18n::tr("wizard.prompt.bundle_id"),
2011            state.bundle_id
2012        )?;
2013        writeln!(
2014            output,
2015            "{}: {}",
2016            crate::i18n::tr("wizard.prompt.output_dir"),
2017            state.output_dir.display()
2018        )?;
2019        render_named_entries(
2020            output,
2021            &crate::i18n::tr("wizard.stage.current_app_packs"),
2022            &state
2023                .app_pack_entries
2024                .iter()
2025                .map(|entry| {
2026                    format!(
2027                        "{} [{} -> {}]",
2028                        entry.display_name,
2029                        entry.reference,
2030                        format_mapping(&entry.mapping)
2031                    )
2032                })
2033                .collect::<Vec<_>>(),
2034        )?;
2035        render_named_entries(
2036            output,
2037            &crate::i18n::tr("wizard.stage.current_access_rules"),
2038            &state
2039                .access_rules
2040                .iter()
2041                .map(format_access_rule)
2042                .collect::<Vec<_>>(),
2043        )?;
2044        render_named_entries(
2045            output,
2046            &crate::i18n::tr("wizard.stage.current_extension_providers"),
2047            &state
2048                .extension_provider_entries
2049                .iter()
2050                .map(|entry| format!("{} [{}]", entry.display_name, entry.reference))
2051                .collect::<Vec<_>>(),
2052        )?;
2053        if !state.capabilities.is_empty() {
2054            render_named_entries(
2055                output,
2056                &crate::i18n::tr("wizard.stage.capabilities"),
2057                &state.capabilities,
2058            )?;
2059        }
2060        writeln!(
2061            output,
2062            "1. {}",
2063            crate::i18n::tr("wizard.action.build_bundle")
2064        )?;
2065        writeln!(
2066            output,
2067            "2. {}",
2068            crate::i18n::tr("wizard.action.dry_run_only")
2069        )?;
2070        writeln!(
2071            output,
2072            "3. {}",
2073            crate::i18n::tr("wizard.action.save_answers_only")
2074        )?;
2075        if include_edit_paths {
2076            writeln!(output, "4. {}", crate::i18n::tr("wizard.action.finish"))?;
2077        }
2078        writeln!(output, "0. {}", crate::i18n::tr("wizard.action.back"))?;
2079        let answer = prompt_menu_value(input, output)?;
2080        match answer.as_str() {
2081            "1" => return Ok(ReviewAction::BuildNow),
2082            "2" => return Ok(ReviewAction::DryRunOnly),
2083            "3" => return Ok(ReviewAction::SaveAnswersOnly),
2084            "4" if include_edit_paths => return Ok(ReviewAction::BuildNow),
2085            "0" => return Ok(ReviewAction::DryRunOnly),
2086            _ => writeln!(output, "{}", crate::i18n::tr("wizard.error.invalid_choice"))?,
2087        }
2088    }
2089}
2090
2091fn prompt_menu_value<R: BufRead, W: Write>(input: &mut R, output: &mut W) -> Result<String> {
2092    write!(output, "{} ", crate::i18n::tr("wizard.setup.enum_prompt"))?;
2093    output.flush()?;
2094    let mut line = String::new();
2095    if input.read_line(&mut line)? == 0 {
2096        bail!("{}", crate::i18n::tr("wizard.error.input_ended"));
2097    }
2098    Ok(line.trim().to_string())
2099}
2100
2101fn render_named_entries<W: Write>(output: &mut W, title: &str, entries: &[String]) -> Result<()> {
2102    writeln!(output, "{title}:")?;
2103    if entries.is_empty() {
2104        writeln!(output, "- {}", crate::i18n::tr("wizard.list.none"))?;
2105    } else {
2106        for entry in entries {
2107            writeln!(output, "- {entry}")?;
2108        }
2109    }
2110    Ok(())
2111}
2112
2113#[derive(Debug, Clone)]
2114struct PackGroup {
2115    reference: String,
2116    display_name: String,
2117    scopes: Vec<AppPackMappingInput>,
2118}
2119
2120fn render_pack_entries<W: Write>(output: &mut W, entries: &[AppPackEntry]) -> Result<()> {
2121    writeln!(
2122        output,
2123        "{}",
2124        crate::i18n::tr("wizard.stage.current_app_packs")
2125    )?;
2126    let groups = group_pack_entries(entries);
2127    if groups.is_empty() {
2128        writeln!(output, "- {}", crate::i18n::tr("wizard.list.none"))?;
2129        return Ok(());
2130    }
2131    for (index, group) in groups.iter().enumerate() {
2132        writeln!(output, "{}) {}", index + 1, group.display_name)?;
2133        writeln!(
2134            output,
2135            "   {}: {}",
2136            crate::i18n::tr("wizard.label.source"),
2137            group.reference
2138        )?;
2139        writeln!(
2140            output,
2141            "   {}: {}",
2142            crate::i18n::tr("wizard.label.scope"),
2143            group
2144                .scopes
2145                .iter()
2146                .map(format_mapping)
2147                .collect::<Vec<_>>()
2148                .join(", ")
2149        )?;
2150    }
2151    Ok(())
2152}
2153
2154fn group_pack_entries(entries: &[AppPackEntry]) -> Vec<PackGroup> {
2155    let mut groups = Vec::<PackGroup>::new();
2156    for entry in entries {
2157        if let Some(group) = groups
2158            .iter_mut()
2159            .find(|group| group.reference == entry.reference)
2160        {
2161            group.scopes.push(entry.mapping.clone());
2162        } else {
2163            groups.push(PackGroup {
2164                reference: entry.reference.clone(),
2165                display_name: entry.display_name.clone(),
2166                scopes: vec![entry.mapping.clone()],
2167            });
2168        }
2169    }
2170    groups
2171}
2172
2173fn edit_bundle_capabilities<R: BufRead, W: Write>(
2174    input: &mut R,
2175    output: &mut W,
2176    mut state: NormalizedRequest,
2177) -> Result<NormalizedRequest> {
2178    let cap = crate::project::CAP_BUNDLE_ASSETS_READ_V1.to_string();
2179    let already_enabled = state.capabilities.contains(&cap);
2180    let default_value = if already_enabled {
2181        Some(Value::Bool(true))
2182    } else {
2183        Some(Value::Bool(false))
2184    };
2185    let answer = prompt_qa_boolean(
2186        input,
2187        output,
2188        &crate::i18n::tr("wizard.prompt.enable_bundle_assets"),
2189        false,
2190        default_value,
2191    )?;
2192    let enable = answer.as_bool().unwrap_or(false);
2193    if enable && !state.capabilities.contains(&cap) {
2194        state.capabilities.push(cap);
2195    } else if !enable {
2196        state.capabilities.retain(|c| c != &cap);
2197    }
2198    Ok(state)
2199}
2200
2201fn rebuild_request(request: NormalizedRequest) -> NormalizedRequest {
2202    normalize_request(SeedRequest {
2203        mode: request.mode,
2204        locale: request.locale,
2205        bundle_name: request.bundle_name,
2206        bundle_id: request.bundle_id,
2207        output_dir: request.output_dir,
2208        app_pack_entries: request.app_pack_entries,
2209        access_rules: request.access_rules,
2210        extension_provider_entries: request.extension_provider_entries,
2211        cloud_deployer_target: detect_cloud_deployer_target(&request.extension_providers),
2212        advanced_setup: false,
2213        app_packs: Vec::new(),
2214        extension_providers: Vec::new(),
2215        remote_catalogs: request.remote_catalogs,
2216        setup_specs: BTreeMap::new(),
2217        setup_answers: BTreeMap::new(),
2218        setup_execution_intent: false,
2219        export_intent: false,
2220        capabilities: request.capabilities,
2221    })
2222}
2223
2224fn edit_cloud_deployer_target<R: BufRead, W: Write>(
2225    input: &mut R,
2226    output: &mut W,
2227    mut state: NormalizedRequest,
2228) -> Result<NormalizedRequest> {
2229    let current = detect_cloud_deployer_target(&state.extension_providers);
2230    let labels = vec![
2231        "None".to_string(),
2232        "AWS".to_string(),
2233        "Azure".to_string(),
2234        "GCP".to_string(),
2235    ];
2236    let prompt = format!(
2237        "Choose cloud deployer target (current: {})",
2238        current.as_deref().unwrap_or("none")
2239    );
2240    let Some(index) = choose_named_index(input, output, &prompt, &labels)? else {
2241        return Ok(state);
2242    };
2243    let target = match index {
2244        1 => "aws",
2245        2 => "azure",
2246        3 => "gcp",
2247        _ => "",
2248    };
2249    apply_cloud_deployer_target_to_entries(&mut state.extension_provider_entries, target);
2250    Ok(rebuild_request(state))
2251}
2252
2253fn apply_cloud_deployer_target_to_entries(entries: &mut Vec<ExtensionProviderEntry>, target: &str) {
2254    entries.retain(|entry| !is_cloud_deployer_entry(entry));
2255    if let Some(entry) = cloud_deployer_entry_for_target(target) {
2256        entries.push(entry);
2257    }
2258}
2259
2260fn cloud_deployer_entry_for_target(target: &str) -> Option<ExtensionProviderEntry> {
2261    let (provider_id, display_name, reference) = match target.trim() {
2262        "aws" => ("deployer-aws", "AWS Deployer", DEPLOYER_AWS_REF),
2263        "azure" => ("deployer-azure", "Azure Deployer", DEPLOYER_AZURE_REF),
2264        "gcp" => ("deployer-gcp", "GCP Deployer", DEPLOYER_GCP_REF),
2265        _ => return None,
2266    };
2267    Some(ExtensionProviderEntry {
2268        reference: reference.to_string(),
2269        detected_kind: "catalog".to_string(),
2270        provider_id: provider_id.to_string(),
2271        display_name: display_name.to_string(),
2272        version: inferred_reference_version(reference),
2273        source_catalog: Some(DEFAULT_PROVIDER_REGISTRY.to_string()),
2274        group: Some("deployer".to_string()),
2275    })
2276}
2277
2278fn is_cloud_deployer_entry(entry: &ExtensionProviderEntry) -> bool {
2279    matches!(
2280        entry.provider_id.as_str(),
2281        "deployer-aws" | "deployer-azure" | "deployer-gcp"
2282    ) || detect_cloud_deployer_target(std::slice::from_ref(&entry.reference)).is_some()
2283}
2284
2285fn detect_cloud_deployer_target(references: &[String]) -> Option<String> {
2286    for reference in references {
2287        let normalized = reference.trim().to_ascii_lowercase();
2288        if normalized.contains("greentic.deploy.aws") || normalized.ends_with("/aws.gtpack") {
2289            return Some("aws".to_string());
2290        }
2291        if normalized.contains("greentic.deploy.azure") || normalized.ends_with("/azure.gtpack") {
2292            return Some("azure".to_string());
2293        }
2294        if normalized.contains("greentic.deploy.gcp") || normalized.ends_with("/gcp.gtpack") {
2295            return Some("gcp".to_string());
2296        }
2297    }
2298    None
2299}
2300
2301fn format_mapping(mapping: &AppPackMappingInput) -> String {
2302    match mapping.scope.as_str() {
2303        "tenant" => format!("tenant:{}", mapping.tenant.clone().unwrap_or_default()),
2304        "tenant_team" => format!(
2305            "tenant/team:{}/{}",
2306            mapping.tenant.clone().unwrap_or_default(),
2307            mapping.team.clone().unwrap_or_default()
2308        ),
2309        _ => "global".to_string(),
2310    }
2311}
2312
2313fn format_access_rule(rule: &AccessRuleInput) -> String {
2314    match &rule.team {
2315        Some(team) => format!(
2316            "{}/{team}: {} = {}",
2317            rule.tenant, rule.rule_path, rule.policy
2318        ),
2319        None => format!("{}: {} = {}", rule.tenant, rule.rule_path, rule.policy),
2320    }
2321}
2322
2323fn derive_access_rules_from_entries(entries: &[AppPackEntry]) -> Vec<AccessRuleInput> {
2324    normalize_access_rules(
2325        entries
2326            .iter()
2327            .map(|entry| match entry.mapping.scope.as_str() {
2328                "tenant" => AccessRuleInput {
2329                    rule_path: entry.pack_id.clone(),
2330                    policy: "public".to_string(),
2331                    tenant: entry
2332                        .mapping
2333                        .tenant
2334                        .clone()
2335                        .unwrap_or_else(|| "default".to_string()),
2336                    team: None,
2337                },
2338                "tenant_team" => AccessRuleInput {
2339                    rule_path: entry.pack_id.clone(),
2340                    policy: "public".to_string(),
2341                    tenant: entry
2342                        .mapping
2343                        .tenant
2344                        .clone()
2345                        .unwrap_or_else(|| "default".to_string()),
2346                    team: entry.mapping.team.clone(),
2347                },
2348                _ => AccessRuleInput {
2349                    rule_path: entry.pack_id.clone(),
2350                    policy: "public".to_string(),
2351                    tenant: "default".to_string(),
2352                    team: None,
2353                },
2354            })
2355            .collect(),
2356    )
2357}
2358
2359fn choose_pack_group_index<R: BufRead, W: Write>(
2360    input: &mut R,
2361    output: &mut W,
2362    entries: &[AppPackEntry],
2363) -> Result<Option<usize>> {
2364    let groups = group_pack_entries(entries);
2365    choose_named_index(
2366        input,
2367        output,
2368        &crate::i18n::tr("wizard.prompt.choose_app_pack"),
2369        &groups
2370            .iter()
2371            .map(|group| format!("{} [{}]", group.display_name, group.reference))
2372            .collect::<Vec<_>>(),
2373    )
2374}
2375
2376fn change_pack_scope<R: BufRead, W: Write>(
2377    input: &mut R,
2378    output: &mut W,
2379    state: &mut NormalizedRequest,
2380) -> Result<()> {
2381    let Some(group_index) = choose_pack_group_index(input, output, &state.app_pack_entries)? else {
2382        return Ok(());
2383    };
2384    let groups = group_pack_entries(&state.app_pack_entries);
2385    let group = &groups[group_index];
2386    let template = state
2387        .app_pack_entries
2388        .iter()
2389        .find(|entry| entry.reference == group.reference)
2390        .cloned()
2391        .ok_or_else(|| anyhow::anyhow!("missing pack entry template"))?;
2392    let mapping = prompt_app_pack_mapping(input, output, &template.pack_id)?;
2393    state
2394        .app_pack_entries
2395        .retain(|entry| entry.reference != group.reference);
2396    let mut replacement = template;
2397    replacement.mapping = mapping;
2398    state.app_pack_entries.push(replacement);
2399    Ok(())
2400}
2401
2402fn add_pack_scope<R: BufRead, W: Write>(
2403    input: &mut R,
2404    output: &mut W,
2405    state: &mut NormalizedRequest,
2406    include_team: bool,
2407) -> Result<()> {
2408    let Some(group_index) = choose_pack_group_index(input, output, &state.app_pack_entries)? else {
2409        return Ok(());
2410    };
2411    let groups = group_pack_entries(&state.app_pack_entries);
2412    let group = &groups[group_index];
2413    let template = state
2414        .app_pack_entries
2415        .iter()
2416        .find(|entry| entry.reference == group.reference)
2417        .cloned()
2418        .ok_or_else(|| anyhow::anyhow!("missing pack entry template"))?;
2419    let mapping = if include_team {
2420        let tenant = prompt_required_string(
2421            input,
2422            output,
2423            &crate::i18n::tr("wizard.prompt.tenant_id"),
2424            Some("default"),
2425        )?;
2426        let team = prompt_required_string(
2427            input,
2428            output,
2429            &crate::i18n::tr("wizard.prompt.team_id"),
2430            None,
2431        )?;
2432        AppPackMappingInput {
2433            scope: "tenant_team".to_string(),
2434            tenant: Some(tenant),
2435            team: Some(team),
2436        }
2437    } else {
2438        let tenant = prompt_required_string(
2439            input,
2440            output,
2441            &crate::i18n::tr("wizard.prompt.tenant_id"),
2442            Some("default"),
2443        )?;
2444        AppPackMappingInput {
2445            scope: "tenant".to_string(),
2446            tenant: Some(tenant),
2447            team: None,
2448        }
2449    };
2450    if state
2451        .app_pack_entries
2452        .iter()
2453        .any(|entry| entry.reference == group.reference && entry.mapping == mapping)
2454    {
2455        return Ok(());
2456    }
2457    let mut addition = template;
2458    addition.mapping = mapping;
2459    state.app_pack_entries.push(addition);
2460    Ok(())
2461}
2462
2463fn remove_pack_scope<R: BufRead, W: Write>(
2464    input: &mut R,
2465    output: &mut W,
2466    state: &mut NormalizedRequest,
2467) -> Result<()> {
2468    let groups = group_pack_entries(&state.app_pack_entries);
2469    let Some(group_index) = choose_pack_group_index(input, output, &state.app_pack_entries)? else {
2470        return Ok(());
2471    };
2472    let group = &groups[group_index];
2473    let Some(scope_index) = choose_named_index(
2474        input,
2475        output,
2476        &crate::i18n::tr("wizard.prompt.choose_scope"),
2477        &group.scopes.iter().map(format_mapping).collect::<Vec<_>>(),
2478    )?
2479    else {
2480        return Ok(());
2481    };
2482    let target_scope = &group.scopes[scope_index];
2483    state
2484        .app_pack_entries
2485        .retain(|entry| !(entry.reference == group.reference && &entry.mapping == target_scope));
2486    Ok(())
2487}
2488
2489fn edit_advanced_access_rules<R: BufRead, W: Write>(
2490    input: &mut R,
2491    output: &mut W,
2492    state: &mut NormalizedRequest,
2493) -> Result<()> {
2494    writeln!(
2495        output,
2496        "{}",
2497        crate::i18n::tr("wizard.stage.advanced_access_rules")
2498    )?;
2499    render_named_entries(
2500        output,
2501        &crate::i18n::tr("wizard.stage.current_access_rules"),
2502        &state
2503            .access_rules
2504            .iter()
2505            .map(format_access_rule)
2506            .collect::<Vec<_>>(),
2507    )?;
2508    writeln!(
2509        output,
2510        "1. {}",
2511        crate::i18n::tr("wizard.action.add_allow_rule")
2512    )?;
2513    writeln!(
2514        output,
2515        "2. {}",
2516        crate::i18n::tr("wizard.action.remove_rule")
2517    )?;
2518    writeln!(
2519        output,
2520        "3. {}",
2521        crate::i18n::tr("wizard.action.return_simple_mode")
2522    )?;
2523    loop {
2524        match prompt_menu_value(input, output)?.as_str() {
2525            "1" => add_manual_access_rule(input, output, state, "public")?,
2526            "2" => remove_access_rule(input, output, state)?,
2527            "3" => return Ok(()),
2528            _ => writeln!(output, "{}", crate::i18n::tr("wizard.error.invalid_choice"))?,
2529        }
2530        state.access_rules = normalize_access_rules(state.access_rules.clone());
2531    }
2532}
2533
2534fn add_app_pack<R: BufRead, W: Write>(
2535    input: &mut R,
2536    output: &mut W,
2537    state: &NormalizedRequest,
2538) -> Result<Option<AppPackEntry>> {
2539    loop {
2540        let raw = prompt_required_string(
2541            input,
2542            output,
2543            &crate::i18n::tr("wizard.prompt.app_pack_reference"),
2544            None,
2545        )?;
2546        let resolved = match resolve_reference_metadata(&state.output_dir, &raw) {
2547            Ok(resolved) => resolved,
2548            Err(error) => {
2549                writeln!(output, "{error}")?;
2550                continue;
2551            }
2552        };
2553        writeln!(output, "{}", crate::i18n::tr("wizard.confirm.app_pack"))?;
2554        writeln!(
2555            output,
2556            "{}: {}",
2557            crate::i18n::tr("wizard.label.pack_id"),
2558            resolved.id
2559        )?;
2560        writeln!(
2561            output,
2562            "{}: {}",
2563            crate::i18n::tr("wizard.label.name"),
2564            resolved.display_name
2565        )?;
2566        if let Some(version) = &resolved.version {
2567            writeln!(
2568                output,
2569                "{}: {}",
2570                crate::i18n::tr("wizard.label.version"),
2571                version
2572            )?;
2573        }
2574        writeln!(
2575            output,
2576            "{}: {}",
2577            crate::i18n::tr("wizard.label.source"),
2578            resolved.reference
2579        )?;
2580        writeln!(
2581            output,
2582            "1. {}",
2583            crate::i18n::tr("wizard.action.add_this_app_pack")
2584        )?;
2585        writeln!(
2586            output,
2587            "2. {}",
2588            crate::i18n::tr("wizard.action.reenter_reference")
2589        )?;
2590        writeln!(output, "0. {}", crate::i18n::tr("wizard.action.back"))?;
2591        match prompt_menu_value(input, output)?.as_str() {
2592            "1" => {
2593                let mapping = prompt_app_pack_mapping(input, output, &resolved.id)?;
2594                return Ok(Some(AppPackEntry {
2595                    reference: resolved.reference,
2596                    detected_kind: resolved.detected_kind,
2597                    pack_id: resolved.id,
2598                    display_name: resolved.display_name,
2599                    version: resolved.version,
2600                    mapping,
2601                }));
2602            }
2603            "2" => continue,
2604            "0" => return Ok(None),
2605            _ => writeln!(output, "{}", crate::i18n::tr("wizard.error.invalid_choice"))?,
2606        }
2607    }
2608}
2609
2610fn remove_app_pack<R: BufRead, W: Write>(
2611    input: &mut R,
2612    output: &mut W,
2613    state: &mut NormalizedRequest,
2614) -> Result<()> {
2615    let Some(index) = choose_named_index(
2616        input,
2617        output,
2618        &crate::i18n::tr("wizard.prompt.choose_app_pack"),
2619        &state
2620            .app_pack_entries
2621            .iter()
2622            .map(|entry| format!("{} [{}]", entry.display_name, entry.reference))
2623            .collect::<Vec<_>>(),
2624    )?
2625    else {
2626        return Ok(());
2627    };
2628    state.app_pack_entries.remove(index);
2629    Ok(())
2630}
2631
2632fn prompt_app_pack_mapping<R: BufRead, W: Write>(
2633    input: &mut R,
2634    output: &mut W,
2635    pack_id: &str,
2636) -> Result<AppPackMappingInput> {
2637    writeln!(output, "{}", crate::i18n::tr("wizard.stage.map_app_pack"))?;
2638    writeln!(output, "{}", pack_id)?;
2639    writeln!(output, "1. {}", crate::i18n::tr("wizard.mapping.global"))?;
2640    writeln!(output, "2. {}", crate::i18n::tr("wizard.mapping.tenant"))?;
2641    writeln!(
2642        output,
2643        "3. {}",
2644        crate::i18n::tr("wizard.mapping.tenant_team")
2645    )?;
2646    writeln!(output, "0. {}", crate::i18n::tr("wizard.action.back"))?;
2647    loop {
2648        match prompt_menu_value(input, output)?.as_str() {
2649            "1" => {
2650                return Ok(AppPackMappingInput {
2651                    scope: "global".to_string(),
2652                    tenant: None,
2653                    team: None,
2654                });
2655            }
2656            "2" => {
2657                let tenant = prompt_required_string(
2658                    input,
2659                    output,
2660                    &crate::i18n::tr("wizard.prompt.tenant_id"),
2661                    Some("default"),
2662                )?;
2663                return Ok(AppPackMappingInput {
2664                    scope: "tenant".to_string(),
2665                    tenant: Some(tenant),
2666                    team: None,
2667                });
2668            }
2669            "3" => {
2670                let tenant = prompt_required_string(
2671                    input,
2672                    output,
2673                    &crate::i18n::tr("wizard.prompt.tenant_id"),
2674                    Some("default"),
2675                )?;
2676                let team = prompt_required_string(
2677                    input,
2678                    output,
2679                    &crate::i18n::tr("wizard.prompt.team_id"),
2680                    None,
2681                )?;
2682                return Ok(AppPackMappingInput {
2683                    scope: "tenant_team".to_string(),
2684                    tenant: Some(tenant),
2685                    team: Some(team),
2686                });
2687            }
2688            "0" => {
2689                return Ok(AppPackMappingInput {
2690                    scope: "global".to_string(),
2691                    tenant: None,
2692                    team: None,
2693                });
2694            }
2695            _ => writeln!(output, "{}", crate::i18n::tr("wizard.error.invalid_choice"))?,
2696        }
2697    }
2698}
2699
2700fn add_manual_access_rule<R: BufRead, W: Write>(
2701    input: &mut R,
2702    output: &mut W,
2703    state: &mut NormalizedRequest,
2704    policy: &str,
2705) -> Result<()> {
2706    let target = prompt_access_target(input, output)?;
2707    let rule_path = prompt_required_string(
2708        input,
2709        output,
2710        &crate::i18n::tr("wizard.prompt.rule_path"),
2711        None,
2712    )?;
2713    state.access_rules.push(AccessRuleInput {
2714        rule_path,
2715        policy: policy.to_string(),
2716        tenant: target.0,
2717        team: target.1,
2718    });
2719    Ok(())
2720}
2721
2722fn remove_access_rule<R: BufRead, W: Write>(
2723    input: &mut R,
2724    output: &mut W,
2725    state: &mut NormalizedRequest,
2726) -> Result<()> {
2727    let Some(index) = choose_named_index(
2728        input,
2729        output,
2730        &crate::i18n::tr("wizard.prompt.choose_access_rule"),
2731        &state
2732            .access_rules
2733            .iter()
2734            .map(format_access_rule)
2735            .collect::<Vec<_>>(),
2736    )?
2737    else {
2738        return Ok(());
2739    };
2740    state.access_rules.remove(index);
2741    Ok(())
2742}
2743
2744fn prompt_access_target<R: BufRead, W: Write>(
2745    input: &mut R,
2746    output: &mut W,
2747) -> Result<(String, Option<String>)> {
2748    writeln!(output, "1. {}", crate::i18n::tr("wizard.mapping.global"))?;
2749    writeln!(output, "2. {}", crate::i18n::tr("wizard.mapping.tenant"))?;
2750    writeln!(
2751        output,
2752        "3. {}",
2753        crate::i18n::tr("wizard.mapping.tenant_team")
2754    )?;
2755    loop {
2756        match prompt_menu_value(input, output)?.as_str() {
2757            "1" => return Ok(("default".to_string(), None)),
2758            "2" => {
2759                let tenant = prompt_required_string(
2760                    input,
2761                    output,
2762                    &crate::i18n::tr("wizard.prompt.tenant_id"),
2763                    Some("default"),
2764                )?;
2765                return Ok((tenant, None));
2766            }
2767            "3" => {
2768                let tenant = prompt_required_string(
2769                    input,
2770                    output,
2771                    &crate::i18n::tr("wizard.prompt.tenant_id"),
2772                    Some("default"),
2773                )?;
2774                let team = prompt_required_string(
2775                    input,
2776                    output,
2777                    &crate::i18n::tr("wizard.prompt.team_id"),
2778                    None,
2779                )?;
2780                return Ok((tenant, Some(team)));
2781            }
2782            _ => writeln!(output, "{}", crate::i18n::tr("wizard.error.invalid_choice"))?,
2783        }
2784    }
2785}
2786
2787/// Resolves extension provider catalog entries.
2788///
2789/// If `remote_catalogs` contains explicit references, they are used.
2790/// Otherwise, tries to fetch from OCI registry, falling back to bundled
2791/// provider registry if the fetch fails (network error, timeout, etc.).
2792///
2793/// Returns (should_persist_catalog_ref, catalog_ref, entries).
2794fn resolve_extension_provider_catalog(
2795    output_dir: &Path,
2796    remote_catalogs: &[String],
2797) -> Result<(
2798    bool,
2799    Option<String>,
2800    Vec<crate::catalog::registry::CatalogEntry>,
2801)> {
2802    // If explicit catalogs are provided, use them
2803    if let Some(catalog_ref) = remote_catalogs.first() {
2804        let resolution = crate::catalog::resolve::resolve_catalogs(
2805            output_dir,
2806            std::slice::from_ref(catalog_ref),
2807            &crate::catalog::resolve::CatalogResolveOptions {
2808                offline: crate::runtime::offline(),
2809                write_cache: false,
2810            },
2811        )?;
2812        return Ok((true, Some(catalog_ref.clone()), resolution.discovered_items));
2813    }
2814
2815    // Check for bundled-only mode (used in tests and CI)
2816    let use_bundled_only = std::env::var("GREENTIC_BUNDLE_USE_BUNDLED_CATALOG")
2817        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
2818        .unwrap_or(false);
2819
2820    // No explicit catalogs - try OCI registry first, fall back to bundled
2821    if !crate::runtime::offline() && !use_bundled_only {
2822        let catalog_ref = DEFAULT_PROVIDER_REGISTRY.to_string();
2823        match crate::catalog::resolve::resolve_catalogs(
2824            output_dir,
2825            std::slice::from_ref(&catalog_ref),
2826            &crate::catalog::resolve::CatalogResolveOptions {
2827                offline: false,
2828                write_cache: false,
2829            },
2830        ) {
2831            Ok(resolution) if !resolution.discovered_items.is_empty() => {
2832                return Ok((true, Some(catalog_ref), resolution.discovered_items));
2833            }
2834            _ => {
2835                // OCI fetch failed or returned empty, fall back to bundled
2836            }
2837        }
2838    }
2839
2840    // Fall back to bundled provider registry
2841    let entries = crate::catalog::registry::bundled_provider_registry_entries()?;
2842    Ok((false, None, entries))
2843}
2844
2845fn add_common_extension_provider<R: BufRead, W: Write>(
2846    input: &mut R,
2847    output: &mut W,
2848    state: &NormalizedRequest,
2849) -> Result<Option<ExtensionProviderEntry>> {
2850    let (persist_catalog_ref, catalog_ref, entries) =
2851        resolve_extension_provider_catalog(&state.output_dir, &state.remote_catalogs)?;
2852    if entries.is_empty() {
2853        writeln!(output, "{}", crate::i18n::tr("wizard.error.empty_catalog"))?;
2854        return Ok(None);
2855    }
2856    let grouped_entries = group_catalog_entries_by_category(&entries);
2857    let category_key = if grouped_entries.len() > 1 {
2858        let labels = grouped_entries
2859            .iter()
2860            .map(|(category_id, category_label, description, _)| {
2861                // Use category_label if available, otherwise fall back to category_id
2862                let display_name = category_label.as_deref().unwrap_or(category_id);
2863                format_extension_category_label(display_name, description.as_deref())
2864            })
2865            .collect::<Vec<_>>();
2866        let Some(index) = choose_named_index(input, output, "Choose extension category", &labels)?
2867        else {
2868            return Ok(None);
2869        };
2870        Some(grouped_entries[index].0.clone())
2871    } else {
2872        None
2873    };
2874    let selected_entries = category_key
2875        .as_deref()
2876        .map(|category| {
2877            entries
2878                .iter()
2879                .filter(|entry| entry.category.as_deref().unwrap_or("other") == category)
2880                .collect::<Vec<_>>()
2881        })
2882        .unwrap_or_else(|| entries.iter().collect::<Vec<_>>());
2883    let options = build_extension_provider_options(&selected_entries);
2884    let labels = options
2885        .iter()
2886        .map(|option| option.display_name.clone())
2887        .collect::<Vec<_>>();
2888    let Some(index) = choose_named_index(
2889        input,
2890        output,
2891        &crate::i18n::tr("wizard.prompt.choose_extension_provider"),
2892        &labels,
2893    )?
2894    else {
2895        return Ok(None);
2896    };
2897    let selected = &options[index];
2898    let entry = selected.entry;
2899    let reference = resolve_catalog_entry_reference(input, output, &entry.reference)?;
2900    Ok(Some(ExtensionProviderEntry {
2901        detected_kind: detected_reference_kind(&state.output_dir, &reference).to_string(),
2902        reference,
2903        provider_id: entry.id.clone(),
2904        display_name: selected.display_name.clone(),
2905        version: inferred_reference_version(&entry.reference),
2906        source_catalog: if persist_catalog_ref {
2907            catalog_ref
2908        } else {
2909            None
2910        },
2911        group: None,
2912    }))
2913}
2914
2915fn build_extension_provider_options<'a>(
2916    entries: &'a [&'a crate::catalog::registry::CatalogEntry],
2917) -> Vec<ResolvedExtensionProviderOption<'a>> {
2918    let mut options = Vec::<ResolvedExtensionProviderOption<'a>>::new();
2919    for entry in entries {
2920        let display_name = clean_extension_provider_label(entry);
2921        if options
2922            .iter()
2923            .any(|existing| existing.display_name == display_name)
2924        {
2925            continue;
2926        }
2927        options.push(ResolvedExtensionProviderOption {
2928            entry,
2929            display_name,
2930        });
2931    }
2932    options
2933}
2934
2935#[derive(Clone)]
2936struct ResolvedExtensionProviderOption<'a> {
2937    entry: &'a crate::catalog::registry::CatalogEntry,
2938    display_name: String,
2939}
2940
2941fn clean_extension_provider_label(entry: &crate::catalog::registry::CatalogEntry) -> String {
2942    let raw = entry
2943        .label
2944        .clone()
2945        .unwrap_or_else(|| inferred_display_name(&entry.reference));
2946    let trimmed = raw.trim();
2947    for suffix in [" (latest)", " (Latest)", " (LATEST)"] {
2948        if let Some(base) = trimmed.strip_suffix(suffix) {
2949            return base.trim().to_string();
2950        }
2951    }
2952    trimmed.to_string()
2953}
2954
2955/// (category_id, category_label, category_description, entry_indices)
2956type CategoryGroup = (String, Option<String>, Option<String>, Vec<usize>);
2957
2958fn group_catalog_entries_by_category(
2959    entries: &[crate::catalog::registry::CatalogEntry],
2960) -> Vec<CategoryGroup> {
2961    let mut grouped = Vec::<CategoryGroup>::new();
2962    for (index, entry) in entries.iter().enumerate() {
2963        let category = entry
2964            .category
2965            .clone()
2966            .unwrap_or_else(|| "other".to_string());
2967        let label = entry.category_label.clone();
2968        let description = entry.category_description.clone();
2969        if let Some((_, existing_label, existing_description, indices)) =
2970            grouped.iter_mut().find(|(name, _, _, _)| name == &category)
2971        {
2972            if existing_label.is_none() {
2973                *existing_label = label.clone();
2974            }
2975            if existing_description.is_none() {
2976                *existing_description = description.clone();
2977            }
2978            indices.push(index);
2979        } else {
2980            grouped.push((category, label, description, vec![index]));
2981        }
2982    }
2983    grouped
2984}
2985
2986fn format_extension_category_label(category: &str, description: Option<&str>) -> String {
2987    match description
2988        .map(str::trim)
2989        .filter(|description| !description.is_empty())
2990    {
2991        Some(description) => format!("{category} -> {description}"),
2992        None => category.to_string(),
2993    }
2994}
2995
2996fn add_custom_extension_provider<R: BufRead, W: Write>(
2997    input: &mut R,
2998    output: &mut W,
2999    state: &NormalizedRequest,
3000) -> Result<Option<ExtensionProviderEntry>> {
3001    loop {
3002        let raw = prompt_required_string(
3003            input,
3004            output,
3005            &crate::i18n::tr("wizard.prompt.extension_provider_reference"),
3006            None,
3007        )?;
3008        let resolved = match resolve_reference_metadata(&state.output_dir, &raw) {
3009            Ok(resolved) => resolved,
3010            Err(error) => {
3011                writeln!(output, "{error}")?;
3012                continue;
3013            }
3014        };
3015        return Ok(Some(ExtensionProviderEntry {
3016            reference: resolved.reference,
3017            detected_kind: resolved.detected_kind,
3018            provider_id: resolved.id.clone(),
3019            display_name: resolved.display_name,
3020            version: resolved.version,
3021            source_catalog: None,
3022            group: None,
3023        }));
3024    }
3025}
3026
3027fn remove_extension_provider<R: BufRead, W: Write>(
3028    input: &mut R,
3029    output: &mut W,
3030    state: &mut NormalizedRequest,
3031) -> Result<()> {
3032    let Some(index) = choose_named_index(
3033        input,
3034        output,
3035        &crate::i18n::tr("wizard.prompt.choose_extension_provider"),
3036        &state
3037            .extension_provider_entries
3038            .iter()
3039            .map(|entry| format!("{} [{}]", entry.display_name, entry.reference))
3040            .collect::<Vec<_>>(),
3041    )?
3042    else {
3043        return Ok(());
3044    };
3045    state.extension_provider_entries.remove(index);
3046    Ok(())
3047}
3048
3049fn choose_named_index<R: BufRead, W: Write>(
3050    input: &mut R,
3051    output: &mut W,
3052    title: &str,
3053    entries: &[String],
3054) -> Result<Option<usize>> {
3055    if entries.is_empty() {
3056        return Ok(None);
3057    }
3058    writeln!(output, "{title}:")?;
3059    for (index, entry) in entries.iter().enumerate() {
3060        writeln!(output, "{}. {}", index + 1, entry)?;
3061    }
3062    writeln!(output, "0. {}", crate::i18n::tr("wizard.action.back"))?;
3063    loop {
3064        let answer = prompt_menu_value(input, output)?;
3065        if answer == "0" {
3066            return Ok(None);
3067        }
3068        if let Ok(index) = answer.parse::<usize>()
3069            && index > 0
3070            && index <= entries.len()
3071        {
3072            return Ok(Some(index - 1));
3073        }
3074        writeln!(output, "{}", crate::i18n::tr("wizard.error.invalid_choice"))?;
3075    }
3076}
3077
3078struct ResolvedReferenceMetadata {
3079    reference: String,
3080    detected_kind: String,
3081    id: String,
3082    display_name: String,
3083    version: Option<String>,
3084}
3085
3086fn resolve_reference_metadata(root: &Path, raw: &str) -> Result<ResolvedReferenceMetadata> {
3087    let raw = raw.trim();
3088    if raw.is_empty() {
3089        bail!("{}", crate::i18n::tr("wizard.error.empty_answer"));
3090    }
3091    validate_reference_input(root, raw)?;
3092    let detected_kind = detected_reference_kind(root, raw).to_string();
3093    Ok(ResolvedReferenceMetadata {
3094        id: inferred_reference_id(raw),
3095        display_name: inferred_display_name(raw),
3096        version: inferred_reference_version(raw),
3097        reference: raw.to_string(),
3098        detected_kind,
3099    })
3100}
3101
3102fn resolve_catalog_entry_reference<R: BufRead, W: Write>(
3103    input: &mut R,
3104    output: &mut W,
3105    raw: &str,
3106) -> Result<String> {
3107    if !raw.contains("<pr-version>") {
3108        return Ok(raw.to_string());
3109    }
3110    let version = prompt_required_string(input, output, "PR version or tag", None)?;
3111    Ok(raw.replace("<pr-version>", version.trim()))
3112}
3113
3114fn validate_reference_input(root: &Path, raw: &str) -> Result<()> {
3115    if raw.contains("<pr-version>") {
3116        bail!("Reference contains an unresolved <pr-version> placeholder.");
3117    }
3118    if let Some(path) = parse_local_gtpack_reference(root, raw) {
3119        let metadata = fs::metadata(&path)
3120            .with_context(|| format!("read local .gtpack {}", path.display()))?;
3121        if !metadata.is_file() {
3122            bail!(
3123                "Local .gtpack reference must point to a file: {}",
3124                path.display()
3125            );
3126        }
3127    }
3128    Ok(())
3129}
3130
3131fn parse_local_gtpack_reference(root: &Path, raw: &str) -> Option<PathBuf> {
3132    if let Some(path) = raw.strip_prefix("file://") {
3133        let path = PathBuf::from(path.trim());
3134        return Some(path);
3135    }
3136    if raw.contains("://") || !raw.ends_with(".gtpack") {
3137        return None;
3138    }
3139    let candidate = PathBuf::from(raw);
3140    Some(if candidate.is_absolute() {
3141        candidate
3142    } else {
3143        root.join(candidate)
3144    })
3145}
3146
3147fn detected_reference_kind(root: &Path, raw: &str) -> &'static str {
3148    if raw.starts_with("file://") {
3149        return "file_uri";
3150    }
3151    if raw.starts_with("https://") {
3152        return "https";
3153    }
3154    if raw.starts_with("oci://") {
3155        return "oci";
3156    }
3157    if raw.starts_with("repo://") {
3158        return "repo";
3159    }
3160    if raw.starts_with("store://") {
3161        return "store";
3162    }
3163    if raw.contains("://") {
3164        return "unknown";
3165    }
3166    let path = PathBuf::from(raw);
3167    let resolved = if path.is_absolute() {
3168        path
3169    } else {
3170        root.join(&path)
3171    };
3172    if resolved.is_dir() {
3173        "local_dir"
3174    } else {
3175        "local_file"
3176    }
3177}
3178
3179fn inferred_reference_id(raw: &str) -> String {
3180    let cleaned = raw
3181        .trim_end_matches('/')
3182        .rsplit('/')
3183        .next()
3184        .unwrap_or(raw)
3185        .split('@')
3186        .next()
3187        .unwrap_or(raw)
3188        .split(':')
3189        .next()
3190        .unwrap_or(raw)
3191        .trim_end_matches(".json")
3192        .trim_end_matches(".gtpack")
3193        .trim_end_matches(".yaml")
3194        .trim_end_matches(".yml");
3195    normalize_bundle_id(cleaned)
3196}
3197
3198fn inferred_display_name(raw: &str) -> String {
3199    inferred_reference_id(raw)
3200        .split('-')
3201        .filter(|part| !part.is_empty())
3202        .map(|part| {
3203            let mut chars = part.chars();
3204            match chars.next() {
3205                Some(first) => format!("{}{}", first.to_ascii_uppercase(), chars.as_str()),
3206                None => String::new(),
3207            }
3208        })
3209        .collect::<Vec<_>>()
3210        .join(" ")
3211}
3212
3213fn inferred_reference_version(raw: &str) -> Option<String> {
3214    raw.split('@').nth(1).map(ToOwned::to_owned).or_else(|| {
3215        raw.rsplit_once(':')
3216            .and_then(|(_, version)| (!version.contains('/')).then(|| version.to_string()))
3217    })
3218}
3219
3220fn load_and_normalize_answers(
3221    path: &Path,
3222    mode_override: Option<WizardMode>,
3223    schema_version: Option<&str>,
3224    migrate: bool,
3225    locale: &str,
3226) -> Result<LoadedRequest> {
3227    let source = resolve_answers_source(&path.to_string_lossy(), &DistributorAnswersClient)?;
3228    let (raw, base_dir, origin) = match source {
3229        AnswersSource::Local(local_path) => {
3230            let raw = fs::read_to_string(&local_path)
3231                .with_context(|| format!("failed to read answers file {}", local_path.display()))?;
3232            let base_dir = answer_reference_base_dir(&local_path)?;
3233            let origin = local_path.display().to_string();
3234            (raw, Some(base_dir), origin)
3235        }
3236        AnswersSource::Remote { reference, bytes } => {
3237            let raw = String::from_utf8(bytes)
3238                .with_context(|| format!("answers document {reference} is not valid UTF-8"))?;
3239            (raw, None, reference)
3240        }
3241    };
3242    let value: Value = serde_json::from_str(&raw).map_err(|_| {
3243        anyhow::anyhow!(crate::i18n::trf(
3244            "errors.answer_document.invalid_json",
3245            &[("path", &origin)],
3246        ))
3247    })?;
3248    let document = parse_answer_document(value, schema_version, migrate, locale)?;
3249    let locks = document.locks.clone();
3250    let build_bundle_now = answer_document_requests_bundle_build(&document);
3251    let request = normalized_request_from_document(document, mode_override)?;
3252    // A remote answer document has no local base directory, so every pack /
3253    // provider reference inside it must be absolutely resolvable on its own.
3254    if base_dir.is_none() {
3255        ensure_remote_references_absolute(&request, &origin)?;
3256    }
3257    let request = NormalizedRequest {
3258        local_reference_base_dir: base_dir,
3259        ..request
3260    };
3261    Ok(LoadedRequest {
3262        request,
3263        locks,
3264        build_bundle_now,
3265    })
3266}
3267
3268/// Rejects a remotely-loaded answer document that carries a relative or
3269/// local-filesystem reference, which cannot be resolved without a local base
3270/// directory. Absolute `https` / `oci` / `repo` / `store` references pass.
3271fn ensure_remote_references_absolute(request: &NormalizedRequest, origin: &str) -> Result<()> {
3272    let probe_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
3273    let mut references: Vec<&str> = Vec::new();
3274    references.extend(
3275        request
3276            .app_pack_entries
3277            .iter()
3278            .map(|entry| entry.reference.as_str()),
3279    );
3280    references.extend(
3281        request
3282            .extension_provider_entries
3283            .iter()
3284            .map(|entry| entry.reference.as_str()),
3285    );
3286    references.extend(request.app_packs.iter().map(String::as_str));
3287    references.extend(request.extension_providers.iter().map(String::as_str));
3288    references.extend(request.remote_catalogs.iter().map(String::as_str));
3289
3290    for reference in references {
3291        match detected_reference_kind(&probe_root, reference) {
3292            "https" | "oci" | "repo" | "store" => {}
3293            other => bail!(
3294                "answers loaded from {origin} must use absolute references \
3295                 (https://, oci://, repo://, store://); found {other} reference `{reference}`"
3296            ),
3297        }
3298    }
3299    Ok(())
3300}
3301
3302fn answer_reference_base_dir(path: &Path) -> Result<PathBuf> {
3303    let base = path
3304        .parent()
3305        .filter(|parent| !parent.as_os_str().is_empty());
3306    match base {
3307        Some(parent) => parent
3308            .canonicalize()
3309            .with_context(|| format!("canonicalize answers parent {}", parent.display())),
3310        None => std::env::current_dir().context("resolve current working directory for answers"),
3311    }
3312}
3313
3314fn reference_roots_for_apply(request: &NormalizedRequest) -> Result<Vec<PathBuf>> {
3315    let mut roots = Vec::new();
3316    if let Some(base_dir) = &request.local_reference_base_dir {
3317        roots.push(base_dir.clone());
3318    }
3319    let current_dir = std::env::current_dir().context("resolve current working directory")?;
3320    if !roots.iter().any(|root| root == &current_dir) {
3321        roots.push(current_dir);
3322    }
3323    Ok(roots)
3324}
3325
3326fn answer_document_requests_bundle_build(document: &AnswerDocument) -> bool {
3327    matches!(
3328        document.locks.get("execution").and_then(Value::as_str),
3329        Some("execute")
3330    )
3331}
3332
3333fn parse_answer_document(
3334    value: Value,
3335    schema_version: Option<&str>,
3336    migrate: bool,
3337    locale: &str,
3338) -> Result<AnswerDocument> {
3339    let object = value
3340        .as_object()
3341        .cloned()
3342        .ok_or_else(|| anyhow::anyhow!(crate::i18n::tr("errors.answer_document.invalid_root")))?;
3343
3344    let has_metadata = object.contains_key("wizard_id")
3345        || object.contains_key("schema_id")
3346        || object.contains_key("schema_version")
3347        || object.contains_key("locale");
3348
3349    let document = if has_metadata {
3350        let document: AnswerDocument =
3351            serde_json::from_value(Value::Object(object)).map_err(|_| {
3352                anyhow::anyhow!(crate::i18n::tr("errors.answer_document.invalid_document"))
3353            })?;
3354        document.validate()?;
3355        document
3356    } else if migrate {
3357        let mut document = AnswerDocument::new(locale);
3358        if let Some(Value::Object(answers)) = object.get("answers") {
3359            document.answers = answers
3360                .iter()
3361                .map(|(key, value)| (key.clone(), value.clone()))
3362                .collect();
3363        } else {
3364            document.answers = object
3365                .iter()
3366                .filter(|(key, _)| key.as_str() != "locks")
3367                .map(|(key, value)| (key.clone(), value.clone()))
3368                .collect();
3369        }
3370        if let Some(Value::Object(locks)) = object.get("locks") {
3371            document.locks = locks
3372                .iter()
3373                .map(|(key, value)| (key.clone(), value.clone()))
3374                .collect();
3375        }
3376        document
3377    } else {
3378        bail!(
3379            "{}",
3380            crate::i18n::tr("errors.answer_document.metadata_missing")
3381        );
3382    };
3383
3384    if document.schema_id != ANSWER_SCHEMA_ID {
3385        bail!(
3386            "{}",
3387            crate::i18n::tr("errors.answer_document.schema_id_mismatch")
3388        );
3389    }
3390
3391    let target_version = requested_schema_version(schema_version)?;
3392    let migrated = migrate_document(document, &target_version)?;
3393    if migrated.migrated && !migrate {
3394        bail!(
3395            "{}",
3396            crate::i18n::tr("errors.answer_document.migrate_required")
3397        );
3398    }
3399    Ok(migrated.document)
3400}
3401
3402fn normalized_request_from_document(
3403    document: AnswerDocument,
3404    mode_override: Option<WizardMode>,
3405) -> Result<NormalizedRequest> {
3406    let answers = normalized_answers_from_document(&document.answers)?;
3407    let mode = match mode_override {
3408        Some(mode) => mode,
3409        None => mode_from_answers(&answers)?,
3410    };
3411    let bundle_name = required_string(&answers, "bundle_name")?;
3412    let bundle_id = required_string(&answers, "bundle_id")?;
3413    let normalized_bundle_id = normalize_bundle_id(&bundle_id);
3414    let output_dir = answers
3415        .get("output_dir")
3416        .and_then(Value::as_str)
3417        .map(str::trim)
3418        .filter(|value| !value.is_empty())
3419        .map(PathBuf::from)
3420        .unwrap_or_else(|| default_bundle_output_dir(&normalized_bundle_id));
3421    let request = normalize_request(SeedRequest {
3422        mode,
3423        locale: document.locale,
3424        bundle_name,
3425        bundle_id,
3426        output_dir,
3427        app_pack_entries: optional_app_pack_entries(&answers, "app_pack_entries")?,
3428        access_rules: optional_access_rules(&answers, "access_rules")?,
3429        extension_provider_entries: optional_extension_provider_entries(
3430            &answers,
3431            "extension_provider_entries",
3432        )?,
3433        cloud_deployer_target: optional_string(&answers, "cloud_deployer_target")?,
3434        advanced_setup: optional_bool(&answers, "advanced_setup")?,
3435        app_packs: optional_string_list(&answers, "app_packs")?,
3436        extension_providers: optional_string_list(&answers, "extension_providers")?,
3437        remote_catalogs: optional_string_list(&answers, "remote_catalogs")?,
3438        setup_specs: optional_object_map(&answers, "setup_specs")?,
3439        setup_answers: optional_object_map(&answers, "setup_answers")?,
3440        setup_execution_intent: optional_bool(&answers, "setup_execution_intent")?,
3441        export_intent: optional_bool(&answers, "export_intent")?,
3442        capabilities: optional_string_list(&answers, "capabilities")?,
3443    });
3444    validate_normalized_answer_request(&request)?;
3445    Ok(request)
3446}
3447
3448fn normalized_answers_from_document(
3449    answers: &BTreeMap<String, Value>,
3450) -> Result<BTreeMap<String, Value>> {
3451    let mut normalized = answers.clone();
3452
3453    if let Some(Value::Object(bundle)) = answers.get("bundle") {
3454        copy_nested_string(bundle, "name", &mut normalized, "bundle_name")?;
3455        copy_nested_string(bundle, "id", &mut normalized, "bundle_id")?;
3456        copy_nested_string(bundle, "output_dir", &mut normalized, "output_dir")?;
3457    }
3458
3459    if !normalized.contains_key("mode")
3460        && let Some(Value::String(action)) = answers.get("selected_action")
3461    {
3462        if action == "bundle" {
3463            normalized.insert("mode".to_string(), Value::String("create".to_string()));
3464        } else {
3465            return Err(invalid_answer_field("selected_action"));
3466        }
3467    }
3468
3469    if !normalized.contains_key("app_packs")
3470        && !normalized.contains_key("app_pack_entries")
3471        && let Some(Value::Array(apps)) = answers.get("apps")
3472    {
3473        normalized.insert(
3474            "app_packs".to_string(),
3475            Value::Array(
3476                apps.iter()
3477                    .map(app_reference_from_launcher_entry)
3478                    .collect::<Result<Vec<_>>>()?
3479                    .into_iter()
3480                    .map(Value::String)
3481                    .collect(),
3482            ),
3483        );
3484    }
3485
3486    if !normalized.contains_key("extension_providers")
3487        && !normalized.contains_key("extension_provider_entries")
3488        && let Some(Value::Object(providers)) = answers.get("providers")
3489    {
3490        let mut refs = Vec::new();
3491        for key in ["messaging", "events"] {
3492            if let Some(value) = providers.get(key) {
3493                refs.extend(string_list_from_launcher_value(value, "providers")?);
3494            }
3495        }
3496        normalized.insert(
3497            "extension_providers".to_string(),
3498            Value::Array(refs.into_iter().map(Value::String).collect()),
3499        );
3500    }
3501
3502    Ok(normalized)
3503}
3504
3505fn copy_nested_string(
3506    object: &Map<String, Value>,
3507    source_key: &str,
3508    normalized: &mut BTreeMap<String, Value>,
3509    target_key: &str,
3510) -> Result<()> {
3511    if normalized.contains_key(target_key) {
3512        return Ok(());
3513    }
3514    match object.get(source_key) {
3515        None => Ok(()),
3516        Some(Value::String(value)) => {
3517            normalized.insert(target_key.to_string(), Value::String(value.clone()));
3518            Ok(())
3519        }
3520        Some(_) => Err(invalid_answer_field(target_key)),
3521    }
3522}
3523
3524fn app_reference_from_launcher_entry(value: &Value) -> Result<String> {
3525    let object = value
3526        .as_object()
3527        .ok_or_else(|| invalid_answer_field("apps"))?;
3528    object
3529        .get("source")
3530        .and_then(Value::as_str)
3531        .filter(|value| !value.trim().is_empty())
3532        .map(ToOwned::to_owned)
3533        .ok_or_else(|| invalid_answer_field("apps"))
3534}
3535
3536fn string_list_from_launcher_value(value: &Value, key: &str) -> Result<Vec<String>> {
3537    match value {
3538        Value::Array(entries) => entries
3539            .iter()
3540            .map(|entry| {
3541                entry
3542                    .as_str()
3543                    .filter(|value| !value.trim().is_empty())
3544                    .map(ToOwned::to_owned)
3545                    .ok_or_else(|| invalid_answer_field(key))
3546            })
3547            .collect(),
3548        _ => Err(invalid_answer_field(key)),
3549    }
3550}
3551
3552#[allow(dead_code)]
3553fn normalized_request_from_qa_answers(
3554    answers: Value,
3555    locale: String,
3556    mode: WizardMode,
3557) -> Result<NormalizedRequest> {
3558    let object = answers
3559        .as_object()
3560        .ok_or_else(|| anyhow::anyhow!("wizard answers must be a JSON object"))?;
3561    let bundle_name = object
3562        .get("bundle_name")
3563        .and_then(Value::as_str)
3564        .ok_or_else(|| anyhow::anyhow!("wizard answer missing bundle_name"))?
3565        .to_string();
3566    let bundle_id = normalize_bundle_id(
3567        object
3568            .get("bundle_id")
3569            .and_then(Value::as_str)
3570            .ok_or_else(|| anyhow::anyhow!("wizard answer missing bundle_id"))?,
3571    );
3572    let output_dir = object
3573        .get("output_dir")
3574        .and_then(Value::as_str)
3575        .map(str::trim)
3576        .filter(|value| !value.is_empty())
3577        .map(PathBuf::from)
3578        .unwrap_or_else(|| default_bundle_output_dir(&bundle_id));
3579
3580    Ok(normalize_request(SeedRequest {
3581        mode,
3582        locale,
3583        bundle_name,
3584        bundle_id,
3585        output_dir,
3586        app_pack_entries: Vec::new(),
3587        access_rules: Vec::new(),
3588        extension_provider_entries: Vec::new(),
3589        cloud_deployer_target: object
3590            .get("cloud_deployer_target")
3591            .and_then(Value::as_str)
3592            .map(str::trim)
3593            .filter(|value| !value.is_empty())
3594            .map(ToOwned::to_owned),
3595        advanced_setup: object
3596            .get("advanced_setup")
3597            .and_then(Value::as_bool)
3598            .unwrap_or(false),
3599        app_packs: parse_csv_answers(
3600            object
3601                .get("app_packs")
3602                .and_then(Value::as_str)
3603                .unwrap_or_default(),
3604        ),
3605        extension_providers: parse_csv_answers(
3606            object
3607                .get("extension_providers")
3608                .and_then(Value::as_str)
3609                .unwrap_or_default(),
3610        ),
3611        remote_catalogs: parse_csv_answers(
3612            object
3613                .get("remote_catalogs")
3614                .and_then(Value::as_str)
3615                .unwrap_or_default(),
3616        ),
3617        setup_specs: BTreeMap::new(),
3618        setup_answers: BTreeMap::new(),
3619        setup_execution_intent: object
3620            .get("setup_execution_intent")
3621            .and_then(Value::as_bool)
3622            .unwrap_or(false),
3623        export_intent: object
3624            .get("export_intent")
3625            .and_then(Value::as_bool)
3626            .unwrap_or(false),
3627        capabilities: object
3628            .get("capabilities")
3629            .and_then(Value::as_array)
3630            .map(|arr| {
3631                arr.iter()
3632                    .filter_map(Value::as_str)
3633                    .map(ToOwned::to_owned)
3634                    .collect()
3635            })
3636            .unwrap_or_default(),
3637    }))
3638}
3639
3640fn mode_from_answers(answers: &BTreeMap<String, Value>) -> Result<WizardMode> {
3641    match answers.get("mode") {
3642        None => Ok(WizardMode::Create),
3643        Some(Value::String(value)) => match value.to_ascii_lowercase().as_str() {
3644            "create" => Ok(WizardMode::Create),
3645            "update" => Ok(WizardMode::Update),
3646            "doctor" => Ok(WizardMode::Doctor),
3647            _ => Err(invalid_answer_field("mode")),
3648        },
3649        Some(_) => Err(invalid_answer_field("mode")),
3650    }
3651}
3652
3653fn required_string(answers: &BTreeMap<String, Value>, key: &str) -> Result<String> {
3654    let value = answers.get(key).ok_or_else(|| missing_answer_field(key))?;
3655    let text = value.as_str().ok_or_else(|| invalid_answer_field(key))?;
3656    if text.trim().is_empty() {
3657        return Err(invalid_answer_field(key));
3658    }
3659    Ok(text.to_string())
3660}
3661
3662fn optional_bool(answers: &BTreeMap<String, Value>, key: &str) -> Result<bool> {
3663    match answers.get(key) {
3664        None => Ok(false),
3665        Some(Value::Bool(value)) => Ok(*value),
3666        Some(_) => Err(invalid_answer_field(key)),
3667    }
3668}
3669
3670fn optional_string(answers: &BTreeMap<String, Value>, key: &str) -> Result<Option<String>> {
3671    match answers.get(key) {
3672        None => Ok(None),
3673        Some(Value::String(value)) => {
3674            let trimmed = value.trim();
3675            if trimmed.is_empty() {
3676                Ok(None)
3677            } else {
3678                Ok(Some(trimmed.to_string()))
3679            }
3680        }
3681        Some(_) => Err(invalid_answer_field(key)),
3682    }
3683}
3684
3685fn optional_string_list(answers: &BTreeMap<String, Value>, key: &str) -> Result<Vec<String>> {
3686    match answers.get(key) {
3687        None => Ok(Vec::new()),
3688        Some(Value::Array(entries)) => entries
3689            .iter()
3690            .map(|entry| {
3691                entry
3692                    .as_str()
3693                    .map(ToOwned::to_owned)
3694                    .ok_or_else(|| invalid_answer_field(key))
3695            })
3696            .collect(),
3697        Some(_) => Err(invalid_answer_field(key)),
3698    }
3699}
3700
3701fn optional_object_map(
3702    answers: &BTreeMap<String, Value>,
3703    key: &str,
3704) -> Result<BTreeMap<String, Value>> {
3705    match answers.get(key) {
3706        None => Ok(BTreeMap::new()),
3707        Some(Value::Object(entries)) => Ok(entries
3708            .iter()
3709            .map(|(entry_key, entry_value)| (entry_key.clone(), entry_value.clone()))
3710            .collect()),
3711        Some(_) => Err(invalid_answer_field(key)),
3712    }
3713}
3714
3715fn optional_app_pack_entries(
3716    answers: &BTreeMap<String, Value>,
3717    key: &str,
3718) -> Result<Vec<AppPackEntry>> {
3719    answers
3720        .get(key)
3721        .cloned()
3722        .map(|value| serde_json::from_value(value).map_err(|_| invalid_answer_field(key)))
3723        .transpose()
3724        .map(|value| value.unwrap_or_default())
3725}
3726
3727fn optional_access_rules(
3728    answers: &BTreeMap<String, Value>,
3729    key: &str,
3730) -> Result<Vec<AccessRuleInput>> {
3731    answers
3732        .get(key)
3733        .cloned()
3734        .map(|value| serde_json::from_value(value).map_err(|_| invalid_answer_field(key)))
3735        .transpose()
3736        .map(|value| value.unwrap_or_default())
3737}
3738
3739fn optional_extension_provider_entries(
3740    answers: &BTreeMap<String, Value>,
3741    key: &str,
3742) -> Result<Vec<ExtensionProviderEntry>> {
3743    answers
3744        .get(key)
3745        .cloned()
3746        .map(|value| serde_json::from_value(value).map_err(|_| invalid_answer_field(key)))
3747        .transpose()
3748        .map(|value| value.unwrap_or_default())
3749}
3750
3751fn missing_answer_field(key: &str) -> anyhow::Error {
3752    anyhow::anyhow!(crate::i18n::trf(
3753        "errors.answer_document.answer_missing",
3754        &[("field", key)],
3755    ))
3756}
3757
3758fn invalid_answer_field(key: &str) -> anyhow::Error {
3759    anyhow::anyhow!(crate::i18n::trf(
3760        "errors.answer_document.answer_invalid",
3761        &[("field", key)],
3762    ))
3763}
3764
3765fn validate_normalized_answer_request(request: &NormalizedRequest) -> Result<()> {
3766    if request.bundle_name.trim().is_empty() {
3767        bail!(
3768            "{}",
3769            crate::i18n::trf(
3770                "errors.answer_document.answer_invalid",
3771                &[("field", "bundle_name")]
3772            )
3773        );
3774    }
3775    if request.bundle_id.trim().is_empty() {
3776        bail!(
3777            "{}",
3778            crate::i18n::trf(
3779                "errors.answer_document.answer_invalid",
3780                &[("field", "bundle_id")]
3781            )
3782        );
3783    }
3784    Ok(())
3785}
3786
3787fn requested_schema_version(schema_version: Option<&str>) -> Result<Version> {
3788    let raw = schema_version.unwrap_or("1.0.0");
3789    Version::parse(raw).with_context(|| format!("invalid schema version {raw}"))
3790}
3791
3792fn answer_document_from_request(
3793    request: &NormalizedRequest,
3794    schema_version: Option<&str>,
3795) -> Result<AnswerDocument> {
3796    let mut document = AnswerDocument::new(&request.locale);
3797    document.schema_version = requested_schema_version(schema_version)?;
3798    document.answers = BTreeMap::from([
3799        (
3800            "mode".to_string(),
3801            Value::String(mode_name(request.mode).to_string()),
3802        ),
3803        (
3804            "bundle_name".to_string(),
3805            Value::String(request.bundle_name.clone()),
3806        ),
3807        (
3808            "bundle_id".to_string(),
3809            Value::String(request.bundle_id.clone()),
3810        ),
3811        (
3812            "output_dir".to_string(),
3813            Value::String(request.output_dir.display().to_string()),
3814        ),
3815        (
3816            "advanced_setup".to_string(),
3817            Value::Bool(request.advanced_setup),
3818        ),
3819        (
3820            "app_pack_entries".to_string(),
3821            serde_json::to_value(&request.app_pack_entries)?,
3822        ),
3823        (
3824            "app_packs".to_string(),
3825            Value::Array(
3826                request
3827                    .app_packs
3828                    .iter()
3829                    .cloned()
3830                    .map(Value::String)
3831                    .collect(),
3832            ),
3833        ),
3834        (
3835            "extension_providers".to_string(),
3836            Value::Array(
3837                request
3838                    .extension_providers
3839                    .iter()
3840                    .cloned()
3841                    .map(Value::String)
3842                    .collect(),
3843            ),
3844        ),
3845        (
3846            "extension_provider_entries".to_string(),
3847            serde_json::to_value(&request.extension_provider_entries)?,
3848        ),
3849        (
3850            "remote_catalogs".to_string(),
3851            Value::Array(
3852                request
3853                    .remote_catalogs
3854                    .iter()
3855                    .cloned()
3856                    .map(Value::String)
3857                    .collect(),
3858            ),
3859        ),
3860        (
3861            "setup_execution_intent".to_string(),
3862            Value::Bool(request.setup_execution_intent),
3863        ),
3864        (
3865            "setup_specs".to_string(),
3866            Value::Object(request.setup_specs.clone().into_iter().collect()),
3867        ),
3868        (
3869            "access_rules".to_string(),
3870            serde_json::to_value(&request.access_rules)?,
3871        ),
3872        (
3873            "setup_answers".to_string(),
3874            Value::Object(request.setup_answers.clone().into_iter().collect()),
3875        ),
3876        (
3877            "export_intent".to_string(),
3878            Value::Bool(request.export_intent),
3879        ),
3880        (
3881            "capabilities".to_string(),
3882            Value::Array(
3883                request
3884                    .capabilities
3885                    .iter()
3886                    .cloned()
3887                    .map(Value::String)
3888                    .collect(),
3889            ),
3890        ),
3891    ]);
3892    Ok(document)
3893}
3894
3895pub fn build_plan(
3896    request: &NormalizedRequest,
3897    execution: ExecutionMode,
3898    build_bundle_now: bool,
3899    schema_version: &Version,
3900    cache_writes: &[String],
3901    setup_writes: &[String],
3902) -> WizardPlanEnvelope {
3903    let mut expected_file_writes = vec![
3904        request
3905            .output_dir
3906            .join(crate::project::WORKSPACE_ROOT_FILE)
3907            .display()
3908            .to_string(),
3909        request
3910            .output_dir
3911            .join("tenants/default/tenant.gmap")
3912            .display()
3913            .to_string(),
3914        request
3915            .output_dir
3916            .join(crate::project::LOCK_FILE)
3917            .display()
3918            .to_string(),
3919    ];
3920    expected_file_writes.extend(
3921        cache_writes
3922            .iter()
3923            .map(|path| request.output_dir.join(path).display().to_string()),
3924    );
3925    expected_file_writes.extend(
3926        setup_writes
3927            .iter()
3928            .map(|path| request.output_dir.join(path).display().to_string()),
3929    );
3930    if build_bundle_now && execution == ExecutionMode::Execute {
3931        expected_file_writes.push(
3932            crate::build::default_artifact_path(&request.output_dir, &request.bundle_id)
3933                .display()
3934                .to_string(),
3935        );
3936    }
3937    expected_file_writes.sort();
3938    expected_file_writes.dedup();
3939    let mut warnings = Vec::new();
3940    if request.advanced_setup
3941        && request.app_packs.is_empty()
3942        && request.extension_providers.is_empty()
3943    {
3944        warnings.push(crate::i18n::tr("wizard.warning.advanced_without_refs"));
3945    }
3946
3947    WizardPlanEnvelope {
3948        metadata: PlanMetadata {
3949            wizard_id: WIZARD_ID.to_string(),
3950            schema_id: ANSWER_SCHEMA_ID.to_string(),
3951            schema_version: schema_version.to_string(),
3952            locale: request.locale.clone(),
3953            execution,
3954        },
3955        target_root: request.output_dir.display().to_string(),
3956        requested_action: mode_name(request.mode).to_string(),
3957        normalized_input_summary: normalized_summary(request),
3958        ordered_step_list: plan_steps(request, build_bundle_now),
3959        expected_file_writes,
3960        warnings,
3961    }
3962}
3963
3964fn normalized_summary(request: &NormalizedRequest) -> BTreeMap<String, Value> {
3965    BTreeMap::from([
3966        (
3967            "mode".to_string(),
3968            Value::String(mode_name(request.mode).to_string()),
3969        ),
3970        (
3971            "bundle_name".to_string(),
3972            Value::String(request.bundle_name.clone()),
3973        ),
3974        (
3975            "bundle_id".to_string(),
3976            Value::String(request.bundle_id.clone()),
3977        ),
3978        (
3979            "output_dir".to_string(),
3980            Value::String(request.output_dir.display().to_string()),
3981        ),
3982        (
3983            "advanced_setup".to_string(),
3984            Value::Bool(request.advanced_setup),
3985        ),
3986        (
3987            "app_pack_entries".to_string(),
3988            serde_json::to_value(&request.app_pack_entries).unwrap_or(Value::Null),
3989        ),
3990        (
3991            "app_packs".to_string(),
3992            Value::Array(
3993                request
3994                    .app_packs
3995                    .iter()
3996                    .cloned()
3997                    .map(Value::String)
3998                    .collect(),
3999            ),
4000        ),
4001        (
4002            "extension_providers".to_string(),
4003            Value::Array(
4004                request
4005                    .extension_providers
4006                    .iter()
4007                    .cloned()
4008                    .map(Value::String)
4009                    .collect(),
4010            ),
4011        ),
4012        (
4013            "extension_provider_entries".to_string(),
4014            serde_json::to_value(&request.extension_provider_entries).unwrap_or(Value::Null),
4015        ),
4016        (
4017            "remote_catalogs".to_string(),
4018            Value::Array(
4019                request
4020                    .remote_catalogs
4021                    .iter()
4022                    .cloned()
4023                    .map(Value::String)
4024                    .collect(),
4025            ),
4026        ),
4027        (
4028            "setup_execution_intent".to_string(),
4029            Value::Bool(request.setup_execution_intent),
4030        ),
4031        (
4032            "access_rules".to_string(),
4033            serde_json::to_value(&request.access_rules).unwrap_or(Value::Null),
4034        ),
4035        (
4036            "setup_spec_providers".to_string(),
4037            Value::Array(
4038                request
4039                    .setup_specs
4040                    .keys()
4041                    .cloned()
4042                    .map(Value::String)
4043                    .collect(),
4044            ),
4045        ),
4046        (
4047            "export_intent".to_string(),
4048            Value::Bool(request.export_intent),
4049        ),
4050    ])
4051}
4052
4053fn plan_steps(request: &NormalizedRequest, build_bundle_now: bool) -> Vec<WizardPlanStep> {
4054    let mut steps = vec![
4055        WizardPlanStep {
4056            kind: StepKind::EnsureWorkspace,
4057            description: crate::i18n::tr("wizard.plan.ensure_workspace"),
4058        },
4059        WizardPlanStep {
4060            kind: StepKind::WriteBundleFile,
4061            description: crate::i18n::tr("wizard.plan.write_bundle_file"),
4062        },
4063        WizardPlanStep {
4064            kind: StepKind::UpdateAccessRules,
4065            description: crate::i18n::tr("wizard.plan.update_access_rules"),
4066        },
4067        WizardPlanStep {
4068            kind: StepKind::ResolveRefs,
4069            description: crate::i18n::tr("wizard.plan.resolve_refs"),
4070        },
4071        WizardPlanStep {
4072            kind: StepKind::WriteLock,
4073            description: crate::i18n::tr("wizard.plan.write_lock"),
4074        },
4075    ];
4076    if build_bundle_now || matches!(request.mode, WizardMode::Doctor) {
4077        steps.push(WizardPlanStep {
4078            kind: StepKind::BuildBundle,
4079            description: crate::i18n::tr("wizard.plan.build_bundle"),
4080        });
4081    }
4082    if request.export_intent {
4083        steps.push(WizardPlanStep {
4084            kind: StepKind::ExportBundle,
4085            description: crate::i18n::tr("wizard.plan.export_bundle"),
4086        });
4087    }
4088    steps
4089}
4090
4091fn apply_plan(
4092    request: &NormalizedRequest,
4093    bundle_lock: &crate::project::BundleLock,
4094    env_id: &str,
4095) -> Result<Vec<PathBuf>> {
4096    fs::create_dir_all(&request.output_dir)
4097        .with_context(|| format!("create output dir {}", request.output_dir.display()))?;
4098    let bundle_yaml = request.output_dir.join(crate::project::WORKSPACE_ROOT_FILE);
4099    let tenant_gmap = request.output_dir.join("tenants/default/tenant.gmap");
4100    let lock_file = request.output_dir.join(crate::project::LOCK_FILE);
4101
4102    let workspace = workspace_definition_from_request(request);
4103    let mut writes = crate::project::init_bundle_workspace(&request.output_dir, &workspace)?;
4104
4105    for entry in &request.app_pack_entries {
4106        if let Some(tenant) = &entry.mapping.tenant {
4107            if let Some(team) = &entry.mapping.team {
4108                crate::project::ensure_team(&request.output_dir, tenant, team)?;
4109            } else {
4110                crate::project::ensure_tenant(&request.output_dir, tenant)?;
4111            }
4112        }
4113    }
4114
4115    for rule in &request.access_rules {
4116        let preview = crate::access::mutate_access(
4117            &request.output_dir,
4118            &crate::access::GmapTarget {
4119                tenant: rule.tenant.clone(),
4120                team: rule.team.clone(),
4121            },
4122            &crate::access::GmapMutation {
4123                rule_path: rule.rule_path.clone(),
4124                policy: match rule.policy.as_str() {
4125                    "forbidden" => crate::access::Policy::Forbidden,
4126                    _ => crate::access::Policy::Public,
4127                },
4128            },
4129            false,
4130        )?;
4131        writes.extend(
4132            preview
4133                .writes
4134                .into_iter()
4135                .map(|path| request.output_dir.join(path)),
4136        );
4137    }
4138
4139    let setup_result = persist_setup_state(request, ExecutionMode::Execute, env_id)?;
4140    crate::project::write_bundle_lock(&request.output_dir, bundle_lock)
4141        .with_context(|| format!("write {}", lock_file.display()))?;
4142    crate::project::sync_project_with_reference_roots(
4143        &request.output_dir,
4144        &reference_roots_for_apply(request)?,
4145    )?;
4146
4147    if request
4148        .capabilities
4149        .iter()
4150        .any(|c| c == crate::project::CAP_BUNDLE_ASSETS_READ_V1)
4151    {
4152        let scaffolded = crate::project::scaffold_assets_from_packs(&request.output_dir)?;
4153        writes.extend(scaffolded);
4154    }
4155
4156    writes.push(bundle_yaml);
4157    writes.push(tenant_gmap);
4158    writes.push(lock_file);
4159    writes.extend(
4160        setup_result
4161            .writes
4162            .into_iter()
4163            .map(|path| request.output_dir.join(path)),
4164    );
4165    writes.sort();
4166    writes.dedup();
4167    Ok(writes)
4168}
4169
4170fn workspace_definition_from_request(
4171    request: &NormalizedRequest,
4172) -> crate::project::BundleWorkspaceDefinition {
4173    let mut workspace = crate::project::BundleWorkspaceDefinition::new(
4174        request.bundle_name.clone(),
4175        request.bundle_id.clone(),
4176        request.locale.clone(),
4177        mode_name(request.mode).to_string(),
4178    );
4179    workspace.advanced_setup = request.advanced_setup;
4180    workspace.app_pack_mappings = request
4181        .app_pack_entries
4182        .iter()
4183        .map(|entry| crate::project::AppPackMapping {
4184            reference: entry.reference.clone(),
4185            scope: match entry.mapping.scope.as_str() {
4186                "tenant" => crate::project::MappingScope::Tenant,
4187                "tenant_team" => crate::project::MappingScope::Team,
4188                _ => crate::project::MappingScope::Global,
4189            },
4190            tenant: entry.mapping.tenant.clone(),
4191            team: entry.mapping.team.clone(),
4192        })
4193        .collect();
4194    workspace.app_packs = request.app_packs.clone();
4195    workspace.extension_providers = request.extension_providers.clone();
4196    workspace.remote_catalogs = request.remote_catalogs.clone();
4197    workspace.capabilities = request.capabilities.clone();
4198    workspace.setup_execution_intent = false;
4199    workspace.export_intent = false;
4200    workspace.canonicalize();
4201    workspace
4202}
4203
4204fn write_answer_document(path: &Path, document: &AnswerDocument) -> Result<()> {
4205    if let Some(parent) = path.parent()
4206        && !parent.as_os_str().is_empty()
4207    {
4208        fs::create_dir_all(parent)
4209            .with_context(|| format!("create answers parent {}", parent.display()))?;
4210    }
4211    fs::write(path, document.to_pretty_json_string()?)
4212        .with_context(|| format!("write answers file {}", path.display()))
4213}
4214
4215fn normalize_bundle_id(raw: &str) -> String {
4216    let normalized = raw
4217        .trim()
4218        .to_ascii_lowercase()
4219        .chars()
4220        .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
4221        .collect::<String>();
4222    normalized.trim_matches('-').to_string()
4223}
4224
4225fn normalize_output_dir(path: PathBuf) -> PathBuf {
4226    if path.as_os_str().is_empty() {
4227        PathBuf::from(".")
4228    } else {
4229        path
4230    }
4231}
4232
4233fn default_bundle_output_dir(bundle_id: &str) -> PathBuf {
4234    let normalized = normalize_bundle_id(bundle_id);
4235    if normalized.is_empty() {
4236        PathBuf::from("./bundle")
4237    } else {
4238        PathBuf::from(format!("./{normalized}-bundle"))
4239    }
4240}
4241
4242fn sorted_unique(entries: Vec<String>) -> Vec<String> {
4243    let mut entries = entries
4244        .into_iter()
4245        .filter(|entry| !entry.trim().is_empty())
4246        .collect::<Vec<_>>();
4247    entries.sort();
4248    entries.dedup();
4249    entries
4250}
4251
4252fn mode_name(mode: WizardMode) -> &'static str {
4253    match mode {
4254        WizardMode::Create => "create",
4255        WizardMode::Update => "update",
4256        WizardMode::Doctor => "doctor",
4257    }
4258}
4259
4260pub fn print_plan(plan: &WizardPlanEnvelope) -> Result<()> {
4261    println!("{}", serde_json::to_string_pretty(plan)?);
4262    Ok(())
4263}
4264
4265fn build_bundle_lock(
4266    request: &NormalizedRequest,
4267    execution: ExecutionMode,
4268    catalog_resolution: &crate::catalog::resolve::CatalogResolution,
4269    setup_writes: &[String],
4270    env_id: &str,
4271) -> crate::project::BundleLock {
4272    crate::project::BundleLock {
4273        schema_version: crate::project::LOCK_SCHEMA_VERSION,
4274        bundle_id: request.bundle_id.clone(),
4275        env_id: Some(env_id.to_string()),
4276        requested_mode: mode_name(request.mode).to_string(),
4277        execution: match execution {
4278            ExecutionMode::DryRun => "dry_run",
4279            ExecutionMode::Execute => "execute",
4280        }
4281        .to_string(),
4282        cache_policy: crate::catalog::DEFAULT_CACHE_POLICY.to_string(),
4283        tool_version: env!("CARGO_PKG_VERSION").to_string(),
4284        build_format_version: "bundle-lock-v1".to_string(),
4285        workspace_root: crate::project::WORKSPACE_ROOT_FILE.to_string(),
4286        lock_file: crate::project::LOCK_FILE.to_string(),
4287        catalogs: catalog_resolution.entries.clone(),
4288        app_packs: request
4289            .app_packs
4290            .iter()
4291            .cloned()
4292            .map(|reference| crate::project::DependencyLock {
4293                reference,
4294                digest: None,
4295            })
4296            .collect(),
4297        extension_providers: request
4298            .extension_providers
4299            .iter()
4300            .cloned()
4301            .map(|reference| crate::project::DependencyLock {
4302                reference,
4303                digest: None,
4304            })
4305            .collect(),
4306        setup_state_files: setup_writes.to_vec(),
4307    }
4308}
4309
4310fn bundle_lock_to_answer_locks(lock: &crate::project::BundleLock) -> BTreeMap<String, Value> {
4311    let catalogs = lock
4312        .catalogs
4313        .iter()
4314        .map(|entry| {
4315            serde_json::json!({
4316                "requested_ref": entry.requested_ref,
4317                "resolved_ref": entry.resolved_ref,
4318                "digest": entry.digest,
4319                "source": entry.source,
4320                "item_count": entry.item_count,
4321                "item_ids": entry.item_ids,
4322                "cache_path": entry.cache_path,
4323            })
4324        })
4325        .collect::<Vec<_>>();
4326
4327    BTreeMap::from([
4328        (
4329            "cache_policy".to_string(),
4330            Value::String(lock.cache_policy.clone()),
4331        ),
4332        (
4333            "workspace_root".to_string(),
4334            Value::String(lock.workspace_root.clone()),
4335        ),
4336        (
4337            "lock_file".to_string(),
4338            Value::String(lock.lock_file.clone()),
4339        ),
4340        (
4341            "requested_mode".to_string(),
4342            Value::String(lock.requested_mode.clone()),
4343        ),
4344        (
4345            "execution".to_string(),
4346            Value::String(lock.execution.clone()),
4347        ),
4348        ("catalogs".to_string(), Value::Array(catalogs)),
4349        (
4350            "setup_state_files".to_string(),
4351            Value::Array(
4352                lock.setup_state_files
4353                    .iter()
4354                    .cloned()
4355                    .map(Value::String)
4356                    .collect(),
4357            ),
4358        ),
4359    ])
4360}
4361
4362fn preview_setup_writes(
4363    request: &NormalizedRequest,
4364    execution: ExecutionMode,
4365    env_id: &str,
4366) -> Result<Vec<String>> {
4367    let _ = execution;
4368    let instructions = collect_setup_instructions(request)?;
4369    if instructions.is_empty() {
4370        return Ok(Vec::new());
4371    }
4372    let scope = crate::setup::persist::SetupScope {
4373        env_id,
4374        bundle_id: &request.bundle_id,
4375    };
4376    Ok(crate::setup::persist::persist_setup(
4377        &request.output_dir,
4378        &instructions,
4379        &crate::setup::backend::NoopSetupBackend,
4380        &scope,
4381    )?
4382    .writes)
4383}
4384
4385fn persist_setup_state(
4386    request: &NormalizedRequest,
4387    execution: ExecutionMode,
4388    env_id: &str,
4389) -> Result<crate::setup::persist::SetupPersistenceResult> {
4390    let instructions = collect_setup_instructions(request)?;
4391    if instructions.is_empty() {
4392        return Ok(crate::setup::persist::SetupPersistenceResult {
4393            states: Vec::new(),
4394            writes: Vec::new(),
4395        });
4396    }
4397
4398    let backend: Box<dyn crate::setup::backend::SetupBackend> = match execution {
4399        ExecutionMode::Execute => Box::new(crate::setup::backend::FileSetupBackend::new(
4400            &request.output_dir,
4401        )),
4402        ExecutionMode::DryRun => Box::new(crate::setup::backend::NoopSetupBackend),
4403    };
4404    let scope = crate::setup::persist::SetupScope {
4405        env_id,
4406        bundle_id: &request.bundle_id,
4407    };
4408    crate::setup::persist::persist_setup(
4409        &request.output_dir,
4410        &instructions,
4411        backend.as_ref(),
4412        &scope,
4413    )
4414}
4415
4416fn collect_setup_instructions(
4417    request: &NormalizedRequest,
4418) -> Result<Vec<crate::setup::persist::SetupInstruction>> {
4419    if !request.setup_execution_intent {
4420        return Ok(Vec::new());
4421    }
4422    crate::setup::persist::collect_setup_instructions(&request.setup_specs, &request.setup_answers)
4423}
4424
4425#[allow(dead_code)]
4426fn collect_interactive_setup_answers<R: BufRead, W: Write>(
4427    input: &mut R,
4428    output: &mut W,
4429    env_id: &str,
4430    request: NormalizedRequest,
4431    last_compact_title: &mut Option<String>,
4432) -> Result<NormalizedRequest> {
4433    if !request.setup_execution_intent {
4434        return Ok(request);
4435    }
4436
4437    let catalog_resolution = crate::catalog::resolve::resolve_catalogs(
4438        &request.output_dir,
4439        &request.remote_catalogs,
4440        &crate::catalog::resolve::CatalogResolveOptions {
4441            offline: crate::runtime::offline(),
4442            write_cache: false,
4443        },
4444    )?;
4445    let mut request = discover_setup_specs(request, &catalog_resolution);
4446    let provider_ids = request.setup_specs.keys().cloned().collect::<Vec<_>>();
4447    for provider_id in provider_ids {
4448        let needs_answers = request
4449            .setup_answers
4450            .get(&provider_id)
4451            .and_then(Value::as_object)
4452            .map(|answers| answers.is_empty())
4453            .unwrap_or(true);
4454        if !needs_answers {
4455            continue;
4456        }
4457
4458        let spec_input = request
4459            .setup_specs
4460            .get(&provider_id)
4461            .cloned()
4462            .ok_or_else(|| anyhow::anyhow!("missing setup spec for {provider_id}"))?;
4463        let parsed = serde_json::from_value::<crate::setup::SetupSpecInput>(spec_input)?;
4464        let (_, form) = crate::setup::form_spec_from_input(&parsed, &provider_id)?;
4465        let answers = prompt_setup_form_answers(
4466            input,
4467            output,
4468            env_id,
4469            &provider_id,
4470            &form,
4471            last_compact_title,
4472        )?;
4473        request
4474            .setup_answers
4475            .insert(provider_id, Value::Object(answers.into_iter().collect()));
4476    }
4477
4478    Ok(request)
4479}
4480
4481#[allow(dead_code)]
4482fn prompt_setup_form_answers<R: BufRead, W: Write>(
4483    input: &mut R,
4484    output: &mut W,
4485    env_id: &str,
4486    provider_id: &str,
4487    form: &crate::setup::FormSpec,
4488    last_compact_title: &mut Option<String>,
4489) -> Result<BTreeMap<String, Value>> {
4490    writeln!(
4491        output,
4492        "{} {} ({provider_id})",
4493        crate::i18n::tr("wizard.setup.form_prefix"),
4494        form.title
4495    )?;
4496    let spec_json = serde_json::to_string(&qa_form_spec_from_setup_form(form)?)?;
4497    let config = WizardRunConfig {
4498        spec_json,
4499        initial_answers_json: None,
4500        frontend: WizardFrontend::Text,
4501        i18n: I18nConfig {
4502            locale: Some(crate::i18n::current_locale()),
4503            resolved: None,
4504            debug: false,
4505        },
4506        verbose: false,
4507        env_id: env_id.to_string(),
4508    };
4509    let mut driver =
4510        WizardDriver::new(config).context("initialize greentic-qa-lib setup wizard")?;
4511    loop {
4512        let payload_raw = driver
4513            .next_payload_json()
4514            .context("render greentic-qa-lib setup payload")?;
4515        let payload: Value =
4516            serde_json::from_str(&payload_raw).context("parse greentic-qa-lib setup payload")?;
4517
4518        if let Some(text) = payload.get("text").and_then(Value::as_str) {
4519            render_qa_driver_text(output, text, last_compact_title)?;
4520        }
4521
4522        if driver.is_complete() {
4523            break;
4524        }
4525
4526        let ui_raw = driver
4527            .last_ui_json()
4528            .ok_or_else(|| anyhow::anyhow!("greentic-qa-lib setup payload missing UI state"))?;
4529        let ui: Value = serde_json::from_str(ui_raw).context("parse greentic-qa-lib UI payload")?;
4530        let question_id = ui
4531            .get("next_question_id")
4532            .and_then(Value::as_str)
4533            .ok_or_else(|| anyhow::anyhow!("greentic-qa-lib UI payload missing next_question_id"))?
4534            .to_string();
4535        let question = ui
4536            .get("questions")
4537            .and_then(Value::as_array)
4538            .and_then(|questions| {
4539                questions.iter().find(|question| {
4540                    question.get("id").and_then(Value::as_str) == Some(question_id.as_str())
4541                })
4542            })
4543            .ok_or_else(|| {
4544                anyhow::anyhow!("greentic-qa-lib UI payload missing question {question_id}")
4545            })?;
4546
4547        let answer = prompt_qa_question_answer(input, output, &question_id, question)?;
4548        driver
4549            .submit_patch_json(&json!({ question_id: answer }).to_string())
4550            .context("submit greentic-qa-lib setup answer")?;
4551    }
4552
4553    let result = driver
4554        .finish()
4555        .context("finish greentic-qa-lib setup wizard")?;
4556    let answers = result
4557        .answer_set
4558        .answers
4559        .as_object()
4560        .cloned()
4561        .unwrap_or_else(Map::new);
4562    Ok(answers.into_iter().collect())
4563}
4564
4565#[allow(dead_code)]
4566fn qa_form_spec_from_setup_form(form: &crate::setup::FormSpec) -> Result<Value> {
4567    let questions = form
4568        .questions
4569        .iter()
4570        .map(|question| {
4571            let mut value = json!({
4572                "id": question.id,
4573                "type": qa_question_type_name(question.kind),
4574                "title": question.title,
4575                "required": question.required,
4576                "secret": question.secret,
4577            });
4578            if let Some(description) = &question.description {
4579                value["description"] = Value::String(description.clone());
4580            }
4581            if !question.choices.is_empty() {
4582                value["choices"] = Value::Array(
4583                    question
4584                        .choices
4585                        .iter()
4586                        .cloned()
4587                        .map(Value::String)
4588                        .collect(),
4589                );
4590            }
4591            if let Some(default) = &question.default_value
4592                && let Some(default_value) = qa_default_value(default)
4593            {
4594                value["default_value"] = Value::String(default_value);
4595            }
4596            value
4597        })
4598        .collect::<Vec<_>>();
4599
4600    Ok(json!({
4601        "id": form.id,
4602        "title": form.title,
4603        "version": form.version,
4604        "description": form.description,
4605        "presentation": {
4606            "default_locale": crate::i18n::current_locale()
4607        },
4608        "progress_policy": {
4609            "skip_answered": true,
4610            "autofill_defaults": false,
4611            "treat_default_as_answered": false
4612        },
4613        "questions": questions
4614    }))
4615}
4616
4617#[allow(dead_code)]
4618fn qa_question_type_name(kind: crate::setup::QuestionKind) -> &'static str {
4619    match kind {
4620        crate::setup::QuestionKind::String => "string",
4621        crate::setup::QuestionKind::Number => "number",
4622        crate::setup::QuestionKind::Boolean => "boolean",
4623        crate::setup::QuestionKind::Enum => "enum",
4624    }
4625}
4626
4627#[allow(dead_code)]
4628fn qa_default_value(value: &Value) -> Option<String> {
4629    match value {
4630        Value::String(text) => Some(text.clone()),
4631        Value::Bool(flag) => Some(flag.to_string()),
4632        Value::Number(number) => Some(number.to_string()),
4633        _ => None,
4634    }
4635}
4636
4637#[allow(dead_code)]
4638fn render_qa_driver_text<W: Write>(
4639    output: &mut W,
4640    text: &str,
4641    last_compact_title: &mut Option<String>,
4642) -> Result<()> {
4643    if text.is_empty() {
4644        return Ok(());
4645    }
4646    if let Some(title) = compact_form_title(text) {
4647        if last_compact_title.as_deref() != Some(title) {
4648            writeln!(output, "{title}")?;
4649            output.flush()?;
4650            *last_compact_title = Some(title.to_string());
4651        }
4652        return Ok(());
4653    }
4654    *last_compact_title = None;
4655    for line in text.lines() {
4656        writeln!(output, "{line}")?;
4657    }
4658    if !text.ends_with('\n') {
4659        output.flush()?;
4660    }
4661    Ok(())
4662}
4663
4664#[allow(dead_code)]
4665fn compact_form_title(text: &str) -> Option<&str> {
4666    let first_line = text.lines().next()?;
4667    let form = first_line.strip_prefix("Form: ")?;
4668    let (title, form_id) = form.rsplit_once(" (")?;
4669    if form_id
4670        .strip_suffix(')')
4671        .is_some_and(|id| id.starts_with("greentic-bundle-root-wizard-"))
4672    {
4673        return Some(title);
4674    }
4675    None
4676}
4677
4678#[allow(dead_code)]
4679fn prompt_qa_question_answer<R: BufRead, W: Write>(
4680    input: &mut R,
4681    output: &mut W,
4682    question_id: &str,
4683    question: &Value,
4684) -> Result<Value> {
4685    let title = question
4686        .get("title")
4687        .and_then(Value::as_str)
4688        .unwrap_or(question_id);
4689    let required = question
4690        .get("required")
4691        .and_then(Value::as_bool)
4692        .unwrap_or(false);
4693    let kind = question
4694        .get("type")
4695        .and_then(Value::as_str)
4696        .unwrap_or("string");
4697    let secret = question
4698        .get("secret")
4699        .and_then(Value::as_bool)
4700        .unwrap_or(false);
4701    let default_value = question_default_value(question, kind);
4702
4703    match kind {
4704        "boolean" => prompt_qa_boolean(input, output, title, required, default_value),
4705        "enum" => prompt_qa_enum(input, output, title, required, question, default_value),
4706        _ => prompt_qa_string_like(input, output, title, required, secret, default_value),
4707    }
4708}
4709
4710fn prompt_qa_string_like<R: BufRead, W: Write>(
4711    input: &mut R,
4712    output: &mut W,
4713    title: &str,
4714    required: bool,
4715    secret: bool,
4716    default_value: Option<Value>,
4717) -> Result<Value> {
4718    loop {
4719        if secret && io::stdin().is_terminal() && io::stdout().is_terminal() {
4720            let prompt = format!("{title}{}: ", default_suffix(default_value.as_ref()));
4721            let secret_value =
4722                rpassword::prompt_password(prompt).context("read secret wizard input")?;
4723            if secret_value.trim().is_empty() {
4724                if let Some(default) = &default_value {
4725                    return Ok(default.clone());
4726                }
4727                if required {
4728                    writeln!(output, "{}", crate::i18n::tr("wizard.error.empty_answer"))?;
4729                    continue;
4730                }
4731                return Ok(Value::Null);
4732            }
4733            return Ok(Value::String(secret_value));
4734        }
4735
4736        write!(
4737            output,
4738            "{title}{}: ",
4739            default_suffix(default_value.as_ref())
4740        )?;
4741        output.flush()?;
4742        let mut line = String::new();
4743        if input.read_line(&mut line)? == 0 {
4744            bail!("{}", crate::i18n::tr("wizard.error.input_ended"));
4745        }
4746        let trimmed = line.trim();
4747        if trimmed.is_empty() {
4748            if let Some(default) = &default_value {
4749                return Ok(default.clone());
4750            }
4751            if required {
4752                writeln!(output, "{}", crate::i18n::tr("wizard.error.empty_answer"))?;
4753                continue;
4754            }
4755            return Ok(Value::Null);
4756        }
4757        return Ok(Value::String(trimmed.to_string()));
4758    }
4759}
4760
4761#[allow(dead_code)]
4762fn prompt_qa_boolean<R: BufRead, W: Write>(
4763    input: &mut R,
4764    output: &mut W,
4765    title: &str,
4766    required: bool,
4767    default_value: Option<Value>,
4768) -> Result<Value> {
4769    loop {
4770        write!(
4771            output,
4772            "{title}{}: ",
4773            default_suffix(default_value.as_ref())
4774        )?;
4775        output.flush()?;
4776        let mut line = String::new();
4777        if input.read_line(&mut line)? == 0 {
4778            bail!("{}", crate::i18n::tr("wizard.error.input_ended"));
4779        }
4780        let trimmed = line.trim().to_ascii_lowercase();
4781        if trimmed.is_empty() {
4782            if let Some(default) = &default_value {
4783                return Ok(default.clone());
4784            }
4785            if required {
4786                writeln!(output, "{}", crate::i18n::tr("wizard.error.empty_answer"))?;
4787                continue;
4788            }
4789            return Ok(Value::Null);
4790        }
4791        match parse_localized_boolean(&trimmed) {
4792            Some(value) => return Ok(Value::Bool(value)),
4793            None => writeln!(output, "{}", crate::i18n::tr("wizard.error.invalid_choice"))?,
4794        }
4795    }
4796}
4797
4798#[allow(dead_code)]
4799fn parse_localized_boolean(input: &str) -> Option<bool> {
4800    let trimmed = input.trim().to_ascii_lowercase();
4801    if trimmed.is_empty() {
4802        return None;
4803    }
4804
4805    let locale = crate::i18n::current_locale();
4806    let mut truthy = vec!["true", "t", "yes", "y", "1"];
4807    let mut falsy = vec!["false", "f", "no", "n", "0"];
4808
4809    match crate::i18n::base_language(&locale).as_deref() {
4810        Some("nl") => {
4811            truthy.extend(["ja", "j"]);
4812            falsy.extend(["nee"]);
4813        }
4814        Some("de") => {
4815            truthy.extend(["ja", "j"]);
4816            falsy.extend(["nein"]);
4817        }
4818        Some("fr") => {
4819            truthy.extend(["oui", "o"]);
4820            falsy.extend(["non"]);
4821        }
4822        Some("es") | Some("pt") | Some("it") => {
4823            truthy.extend(["si", "s"]);
4824            falsy.extend(["no"]);
4825        }
4826        _ => {}
4827    }
4828
4829    if truthy.iter().any(|value| *value == trimmed) {
4830        return Some(true);
4831    }
4832    if falsy.iter().any(|value| *value == trimmed) {
4833        return Some(false);
4834    }
4835    None
4836}
4837
4838#[allow(dead_code)]
4839fn prompt_qa_enum<R: BufRead, W: Write>(
4840    input: &mut R,
4841    output: &mut W,
4842    title: &str,
4843    required: bool,
4844    question: &Value,
4845    default_value: Option<Value>,
4846) -> Result<Value> {
4847    let choices = question
4848        .get("choices")
4849        .and_then(Value::as_array)
4850        .ok_or_else(|| anyhow::anyhow!("qa enum question missing choices"))?
4851        .iter()
4852        .filter_map(Value::as_str)
4853        .map(ToOwned::to_owned)
4854        .collect::<Vec<_>>();
4855
4856    loop {
4857        if !title.is_empty() {
4858            writeln!(output, "{title}:")?;
4859        }
4860        for (index, choice) in choices.iter().enumerate() {
4861            if title.is_empty() {
4862                writeln!(output, "{}. {}", index + 1, choice)?;
4863            } else {
4864                writeln!(output, "  {}. {}", index + 1, choice)?;
4865            }
4866        }
4867        write!(output, "{} ", crate::i18n::tr("wizard.setup.enum_prompt"))?;
4868        output.flush()?;
4869
4870        let mut line = String::new();
4871        if input.read_line(&mut line)? == 0 {
4872            bail!("{}", crate::i18n::tr("wizard.error.input_ended"));
4873        }
4874        let trimmed = line.trim();
4875        if trimmed.is_empty() {
4876            if let Some(default) = &default_value {
4877                return Ok(default.clone());
4878            }
4879            if required {
4880                writeln!(output, "{}", crate::i18n::tr("wizard.error.empty_answer"))?;
4881                continue;
4882            }
4883            return Ok(Value::Null);
4884        }
4885        if let Ok(number) = trimmed.parse::<usize>()
4886            && number > 0
4887            && number <= choices.len()
4888        {
4889            return Ok(Value::String(choices[number - 1].clone()));
4890        }
4891        if choices.iter().any(|choice| choice == trimmed) {
4892            return Ok(Value::String(trimmed.to_string()));
4893        }
4894        writeln!(output, "{}", crate::i18n::tr("wizard.error.invalid_choice"))?;
4895    }
4896}
4897
4898#[allow(dead_code)]
4899fn question_default_value(question: &Value, kind: &str) -> Option<Value> {
4900    let raw = question
4901        .get("current_value")
4902        .cloned()
4903        .or_else(|| question.get("default").cloned())?;
4904    match raw {
4905        Value::String(text) => match kind {
4906            "boolean" => match text.as_str() {
4907                "true" => Some(Value::Bool(true)),
4908                "false" => Some(Value::Bool(false)),
4909                _ => None,
4910            },
4911            "number" => serde_json::from_str::<serde_json::Number>(&text)
4912                .ok()
4913                .map(Value::Number),
4914            _ => Some(Value::String(text)),
4915        },
4916        Value::Bool(flag) if kind == "boolean" => Some(Value::Bool(flag)),
4917        Value::Number(number) if kind == "number" => Some(Value::Number(number)),
4918        Value::Null => None,
4919        other => Some(other),
4920    }
4921}
4922
4923fn default_suffix(value: Option<&Value>) -> String {
4924    match value {
4925        Some(Value::String(text)) if !text.is_empty() => format!(" [{}]", text),
4926        Some(Value::Bool(flag)) => format!(" [{}]", flag),
4927        Some(Value::Number(number)) => format!(" [{}]", number),
4928        _ => String::new(),
4929    }
4930}
4931
4932fn discover_setup_specs(
4933    mut request: NormalizedRequest,
4934    catalog_resolution: &crate::catalog::resolve::CatalogResolution,
4935) -> NormalizedRequest {
4936    if !request.setup_execution_intent {
4937        return request;
4938    }
4939
4940    for reference in request
4941        .extension_providers
4942        .iter()
4943        .chain(request.app_packs.iter())
4944    {
4945        if request.setup_specs.contains_key(reference) {
4946            continue;
4947        }
4948        if let Some(entry) = catalog_resolution
4949            .discovered_items
4950            .iter()
4951            .find(|entry| entry.id == *reference || entry.reference == *reference)
4952            && let Some(setup) = &entry.setup
4953        {
4954            request
4955                .setup_specs
4956                .entry(entry.id.clone())
4957                .or_insert_with(|| serde_json::to_value(setup).expect("serialize setup metadata"));
4958
4959            if let Some(answer_value) = request.setup_answers.remove(reference) {
4960                request
4961                    .setup_answers
4962                    .entry(entry.id.clone())
4963                    .or_insert(answer_value);
4964            }
4965        }
4966    }
4967
4968    request
4969}
4970
4971#[cfg(test)]
4972mod tests {
4973    use std::io::Cursor;
4974
4975    use crate::catalog::registry::CatalogEntry;
4976
4977    use super::{
4978        NormalizedRequest, RootMenuZeroAction, WizardMode, apply_cloud_deployer_target_to_entries,
4979        build_extension_provider_options, choose_interactive_menu, clean_extension_provider_label,
4980        detect_cloud_deployer_target, detected_reference_kind, ensure_remote_references_absolute,
4981    };
4982    use std::collections::BTreeMap;
4983    use std::path::PathBuf;
4984
4985    /// Minimal request carrying only the `app_packs` references under test.
4986    fn remote_request_with_app_packs(app_packs: &[&str]) -> NormalizedRequest {
4987        NormalizedRequest {
4988            mode: WizardMode::Create,
4989            locale: "en-GB".to_string(),
4990            bundle_name: "demo".to_string(),
4991            bundle_id: "demo".to_string(),
4992            output_dir: PathBuf::from("demo-bundle"),
4993            local_reference_base_dir: None,
4994            app_pack_entries: Vec::new(),
4995            access_rules: Vec::new(),
4996            extension_provider_entries: Vec::new(),
4997            advanced_setup: false,
4998            app_packs: app_packs.iter().map(|s| s.to_string()).collect(),
4999            extension_providers: Vec::new(),
5000            remote_catalogs: Vec::new(),
5001            setup_specs: BTreeMap::new(),
5002            setup_answers: BTreeMap::new(),
5003            setup_execution_intent: false,
5004            export_intent: false,
5005            capabilities: Vec::new(),
5006        }
5007    }
5008
5009    #[test]
5010    fn remote_answers_accept_absolute_references() {
5011        let request = remote_request_with_app_packs(&[
5012            "https://github.com/greenticai/greentic-demo/releases/latest/download/x.gtpack",
5013            // Non-`:stable` tag on purpose: a `:stable` literal here would be
5014            // collected by the stable-OCI-cache scan test and demand a cache entry.
5015            "oci://ghcr.io/greenticai/packs/state/state-memory:1.2.0",
5016            "store://greentic.hubspot@1.2.1-research",
5017        ]);
5018        assert!(ensure_remote_references_absolute(&request, "oci://ref").is_ok());
5019    }
5020
5021    #[test]
5022    fn remote_answers_reject_relative_reference() {
5023        let request =
5024            remote_request_with_app_packs(&["./tavily-research/dist/tavily-research.gtpack"]);
5025        let error = ensure_remote_references_absolute(&request, "oci://ref")
5026            .expect_err("relative reference must be rejected");
5027        let message = error.to_string();
5028        assert!(
5029            message.contains("must use absolute references"),
5030            "{message}"
5031        );
5032        assert!(message.contains("tavily-research.gtpack"), "{message}");
5033    }
5034
5035    #[test]
5036    fn root_menu_shows_back_and_returns_none_for_embedded_wizards() {
5037        crate::i18n::init(Some("en".to_string()));
5038        let mut input = Cursor::new(b"0\n");
5039        let mut output = Vec::new();
5040
5041        let choice = choose_interactive_menu(&mut input, &mut output, RootMenuZeroAction::Back)
5042            .expect("menu should render");
5043
5044        assert_eq!(choice, None);
5045        let rendered = String::from_utf8(output).expect("utf8");
5046        assert!(rendered.contains("0. Back"));
5047        assert!(!rendered.contains("0. Exit"));
5048    }
5049
5050    #[test]
5051    fn extension_provider_options_dedupe_by_display_name() {
5052        let pinned = CatalogEntry {
5053            id: "greentic.secrets.aws-sm.v0-4-25".to_string(),
5054            category: Some("secrets".to_string()),
5055            category_label: None,
5056            category_description: None,
5057            label: Some("Greentic Secrets AWS SM".to_string()),
5058            reference:
5059                "oci://ghcr.io/greenticai/packs/secret/greentic.secrets.aws-sm.gtpack:0.4.25"
5060                    .to_string(),
5061            setup: None,
5062        };
5063        let latest = CatalogEntry {
5064            id: "greentic.secrets.aws-sm.latest".to_string(),
5065            category: Some("secrets".to_string()),
5066            category_label: None,
5067            category_description: None,
5068            label: Some("Greentic Secrets AWS SM".to_string()),
5069            reference:
5070                "oci://ghcr.io/greenticai/packs/secret/greentic.secrets.aws-sm.gtpack:stable"
5071                    .to_string(),
5072            setup: None,
5073        };
5074        let entries = vec![&pinned, &latest];
5075        let options = build_extension_provider_options(&entries);
5076
5077        assert_eq!(options.len(), 1);
5078        assert_eq!(options[0].display_name, "Greentic Secrets AWS SM");
5079        assert_eq!(options[0].entry.id, "greentic.secrets.aws-sm.v0-4-25");
5080        assert_eq!(
5081            options[0].entry.reference,
5082            "oci://ghcr.io/greenticai/packs/secret/greentic.secrets.aws-sm.gtpack:0.4.25"
5083        );
5084    }
5085
5086    #[test]
5087    fn clean_extension_provider_label_removes_latest_suffix_only() {
5088        let latest = CatalogEntry {
5089            id: "x.latest".to_string(),
5090            category: None,
5091            category_label: None,
5092            category_description: None,
5093            label: Some("Greentic Secrets AWS SM (latest)".to_string()),
5094            reference: "oci://ghcr.io/example/secrets:stable".to_string(),
5095            setup: None,
5096        };
5097        let semver = CatalogEntry {
5098            id: "x.0.4.25".to_string(),
5099            category: None,
5100            category_label: None,
5101            category_description: None,
5102            label: Some("Greentic Secrets AWS SM (0.4.25)".to_string()),
5103            reference: "oci://ghcr.io/example/secrets:0.4.25".to_string(),
5104            setup: None,
5105        };
5106        let pr = CatalogEntry {
5107            id: "x.pr".to_string(),
5108            category: None,
5109            category_label: None,
5110            category_description: None,
5111            label: Some("Greentic Messaging Dummy (PR version)".to_string()),
5112            reference: "oci://ghcr.io/example/messaging:<pr-version>".to_string(),
5113            setup: None,
5114        };
5115
5116        assert_eq!(
5117            clean_extension_provider_label(&latest),
5118            "Greentic Secrets AWS SM"
5119        );
5120        assert_eq!(
5121            clean_extension_provider_label(&semver),
5122            "Greentic Secrets AWS SM (0.4.25)"
5123        );
5124        assert_eq!(
5125            clean_extension_provider_label(&pr),
5126            "Greentic Messaging Dummy (PR version)"
5127        );
5128    }
5129
5130    #[test]
5131    fn detected_reference_kind_classifies_https_refs() {
5132        let root = std::path::Path::new(".");
5133        assert_eq!(
5134            detected_reference_kind(root, "https://example.com/packs/cards-demo.gtpack"),
5135            "https"
5136        );
5137    }
5138
5139    #[test]
5140    fn cloud_deployer_target_adds_provider_entry() {
5141        let mut entries = Vec::new();
5142        apply_cloud_deployer_target_to_entries(&mut entries, "aws");
5143
5144        assert_eq!(entries.len(), 1);
5145        assert_eq!(entries[0].provider_id, "deployer-aws");
5146        assert_eq!(
5147            entries[0].reference,
5148            "oci://ghcr.io/greenticai/packs/deployer/greentic.deploy.aws:stable"
5149        );
5150    }
5151
5152    #[test]
5153    fn cloud_deployer_target_replaces_existing_cloud_deployer_entry() {
5154        let mut entries = Vec::new();
5155        apply_cloud_deployer_target_to_entries(&mut entries, "aws");
5156        apply_cloud_deployer_target_to_entries(&mut entries, "gcp");
5157
5158        assert_eq!(entries.len(), 1);
5159        assert_eq!(entries[0].provider_id, "deployer-gcp");
5160        assert_eq!(
5161            detect_cloud_deployer_target(&[entries[0].reference.clone()]),
5162            Some("gcp".to_string())
5163        );
5164    }
5165}