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