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