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