Skip to main content

greentic_bundle/wizard/
mod.rs

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