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