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