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