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