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