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