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