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}