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