Skip to main content

greentic_bundle/wizard/
mod.rs

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