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