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