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 if !request.remote_catalogs.is_empty() {
979 eprintln!(
980 "[resolve] Resolving {} remote catalog(s)...",
981 request.remote_catalogs.len()
982 );
983 }
984 let catalog_resolution = crate::catalog::resolve::resolve_catalogs(
985 &request.output_dir,
986 &request.remote_catalogs,
987 &crate::catalog::resolve::CatalogResolveOptions {
988 offline: crate::runtime::offline(),
989 write_cache: execution == ExecutionMode::Execute,
990 },
991 )?;
992 if !request.remote_catalogs.is_empty() {
993 eprintln!(
994 "[resolve] Catalog resolution complete ({} entries)",
995 catalog_resolution.entries.len()
996 );
997 }
998 let request = discover_setup_specs(request, &catalog_resolution);
999 let setup_writes = preview_setup_writes(&request, execution)?;
1000 let bundle_lock = build_bundle_lock(&request, execution, &catalog_resolution, &setup_writes);
1001 let plan = build_plan(
1002 &request,
1003 execution,
1004 build_bundle_now,
1005 &target_version,
1006 &catalog_resolution.cache_writes,
1007 &setup_writes,
1008 );
1009 let mut document = answer_document_from_request(&request, Some(&target_version.to_string()))?;
1010 let mut locks = source_locks.unwrap_or_default();
1011 locks.extend(bundle_lock_to_answer_locks(&bundle_lock));
1012 document.locks = locks;
1013 let applied_files = if execution == ExecutionMode::Execute {
1014 let mut applied_files = apply_plan(&request, &bundle_lock)?;
1015 if build_bundle_now {
1016 let build_result = crate::build::build_workspace(&request.output_dir, None, false)?;
1017 applied_files.push(PathBuf::from(build_result.artifact_path));
1018 }
1019 applied_files.sort();
1020 applied_files.dedup();
1021 applied_files
1022 } else {
1023 Vec::new()
1024 };
1025 if let Some(path) = emit_answers {
1026 write_answer_document(path, &document)?;
1027 }
1028 Ok(WizardRunResult {
1029 plan,
1030 document,
1031 applied_files,
1032 })
1033}
1034
1035#[allow(dead_code)]
1036fn collect_interactive_request<R: BufRead, W: Write>(
1037 input: &mut R,
1038 output: &mut W,
1039 initial_mode: Option<WizardMode>,
1040 last_compact_title: &mut Option<String>,
1041) -> Result<NormalizedRequest> {
1042 let mode = match initial_mode {
1043 Some(mode) => mode,
1044 None => choose_mode_via_qa(input, output, last_compact_title)?,
1045 };
1046 let request = match mode {
1047 WizardMode::Update => collect_update_request(input, output, last_compact_title)?,
1048 WizardMode::Create | WizardMode::Doctor => {
1049 let answers = run_qa_form(
1050 input,
1051 output,
1052 &wizard_request_form_spec_json(mode, None)?,
1053 None,
1054 "root wizard",
1055 last_compact_title,
1056 )?;
1057 normalized_request_from_qa_answers(answers, crate::i18n::current_locale(), mode)?
1058 }
1059 };
1060 collect_interactive_setup_answers(input, output, request, last_compact_title)
1061}
1062
1063#[allow(dead_code)]
1064fn parse_csv_answers(raw: &str) -> Vec<String> {
1065 raw.split(',')
1066 .map(str::trim)
1067 .filter(|entry| !entry.is_empty())
1068 .map(ToOwned::to_owned)
1069 .collect()
1070}
1071
1072#[allow(dead_code)]
1073fn choose_mode_via_qa<R: BufRead, W: Write>(
1074 input: &mut R,
1075 output: &mut W,
1076 last_compact_title: &mut Option<String>,
1077) -> Result<WizardMode> {
1078 let config = WizardRunConfig {
1079 spec_json: json!({
1080 "id": "greentic-bundle-wizard-mode",
1081 "title": crate::i18n::tr("wizard.menu.title"),
1082 "version": "1.0.0",
1083 "presentation": {
1084 "default_locale": crate::i18n::current_locale()
1085 },
1086 "progress_policy": {
1087 "skip_answered": true,
1088 "autofill_defaults": false,
1089 "treat_default_as_answered": false
1090 },
1091 "questions": [{
1092 "id": "mode",
1093 "type": "enum",
1094 "title": crate::i18n::tr("wizard.prompt.main_choice"),
1095 "required": true,
1096 "choices": ["create", "update", "doctor"]
1097 }]
1098 })
1099 .to_string(),
1100 initial_answers_json: None,
1101 frontend: WizardFrontend::JsonUi,
1102 i18n: I18nConfig {
1103 locale: Some(crate::i18n::current_locale()),
1104 resolved: None,
1105 debug: false,
1106 },
1107 verbose: false,
1108 };
1109 let mut driver =
1110 WizardDriver::new(config).context("initialize greentic-qa-lib wizard mode form")?;
1111
1112 loop {
1113 driver
1114 .next_payload_json()
1115 .context("render greentic-qa-lib wizard mode payload")?;
1116 if driver.is_complete() {
1117 break;
1118 }
1119
1120 let ui_raw = driver
1121 .last_ui_json()
1122 .ok_or_else(|| anyhow::anyhow!("greentic-qa-lib wizard mode missing UI state"))?;
1123 let ui: Value =
1124 serde_json::from_str(ui_raw).context("parse greentic-qa-lib wizard mode UI payload")?;
1125 let question = ui
1126 .get("questions")
1127 .and_then(Value::as_array)
1128 .and_then(|questions| questions.first())
1129 .ok_or_else(|| anyhow::anyhow!("greentic-qa-lib wizard mode missing question"))?;
1130
1131 let answer = prompt_wizard_mode_question(input, output, question)?;
1132 driver
1133 .submit_patch_json(&json!({ "mode": answer }).to_string())
1134 .context("submit greentic-qa-lib wizard mode answer")?;
1135 }
1136 *last_compact_title = Some(crate::i18n::tr("wizard.menu.title"));
1137
1138 let answers = driver
1139 .finish()
1140 .context("finish greentic-qa-lib wizard mode")?
1141 .answer_set
1142 .answers;
1143
1144 Ok(
1145 match answers
1146 .get("mode")
1147 .and_then(Value::as_str)
1148 .unwrap_or("create")
1149 {
1150 "update" => WizardMode::Update,
1151 "doctor" => WizardMode::Doctor,
1152 _ => WizardMode::Create,
1153 },
1154 )
1155}
1156
1157#[allow(dead_code)]
1158fn prompt_wizard_mode_question<R: BufRead, W: Write>(
1159 input: &mut R,
1160 output: &mut W,
1161 question: &Value,
1162) -> Result<Value> {
1163 writeln!(output, "{}", crate::i18n::tr("wizard.menu.title"))?;
1164 let choices = question
1165 .get("choices")
1166 .and_then(Value::as_array)
1167 .ok_or_else(|| anyhow::anyhow!("wizard mode question missing choices"))?;
1168 for (index, choice) in choices.iter().enumerate() {
1169 let choice = choice
1170 .as_str()
1171 .ok_or_else(|| anyhow::anyhow!("wizard mode choice must be a string"))?;
1172 writeln!(
1173 output,
1174 "{}. {}",
1175 index + 1,
1176 crate::i18n::tr(&format!("wizard.mode.{choice}"))
1177 )?;
1178 }
1179 prompt_compact_enum(
1180 input,
1181 output,
1182 question,
1183 true,
1184 question_default_value(question, "enum"),
1185 )
1186}
1187
1188#[allow(dead_code)]
1189fn prompt_compact_enum<R: BufRead, W: Write>(
1190 input: &mut R,
1191 output: &mut W,
1192 question: &Value,
1193 required: bool,
1194 default_value: Option<Value>,
1195) -> Result<Value> {
1196 let choices = question
1197 .get("choices")
1198 .and_then(Value::as_array)
1199 .ok_or_else(|| anyhow::anyhow!("qa enum question missing choices"))?
1200 .iter()
1201 .filter_map(Value::as_str)
1202 .map(ToOwned::to_owned)
1203 .collect::<Vec<_>>();
1204
1205 loop {
1206 write!(output, "{} ", crate::i18n::tr("wizard.setup.enum_prompt"))?;
1207 output.flush()?;
1208
1209 let mut line = String::new();
1210 input.read_line(&mut line)?;
1211 let trimmed = line.trim();
1212 if trimmed.is_empty() {
1213 if let Some(default) = &default_value {
1214 return Ok(default.clone());
1215 }
1216 if required {
1217 writeln!(output, "{}", crate::i18n::tr("wizard.error.empty_answer"))?;
1218 continue;
1219 }
1220 return Ok(Value::Null);
1221 }
1222 if let Ok(number) = trimmed.parse::<usize>()
1223 && number > 0
1224 && number <= choices.len()
1225 {
1226 return Ok(Value::String(choices[number - 1].clone()));
1227 }
1228 if choices.iter().any(|choice| choice == trimmed) {
1229 return Ok(Value::String(trimmed.to_string()));
1230 }
1231 writeln!(output, "{}", crate::i18n::tr("wizard.error.invalid_choice"))?;
1232 }
1233}
1234
1235#[allow(dead_code)]
1236fn collect_update_request<R: BufRead, W: Write>(
1237 input: &mut R,
1238 output: &mut W,
1239 last_compact_title: &mut Option<String>,
1240) -> Result<NormalizedRequest> {
1241 let root_answers = run_qa_form(
1242 input,
1243 output,
1244 &json!({
1245 "id": "greentic-bundle-update-root",
1246 "title": crate::i18n::tr("wizard.menu.update"),
1247 "version": "1.0.0",
1248 "presentation": {
1249 "default_locale": crate::i18n::current_locale()
1250 },
1251 "progress_policy": {
1252 "skip_answered": true,
1253 "autofill_defaults": false,
1254 "treat_default_as_answered": false
1255 },
1256 "questions": [{
1257 "id": "output_dir",
1258 "type": "string",
1259 "title": crate::i18n::tr("wizard.prompt.current_bundle_root"),
1260 "required": true
1261 }]
1262 })
1263 .to_string(),
1264 None,
1265 "update bundle root",
1266 last_compact_title,
1267 )?;
1268 let root = PathBuf::from(
1269 root_answers
1270 .get("output_dir")
1271 .and_then(Value::as_str)
1272 .ok_or_else(|| anyhow::anyhow!("update wizard missing current bundle root"))?,
1273 );
1274 let workspace = crate::project::read_bundle_workspace(&root)
1275 .with_context(|| format!("read current bundle workspace {}", root.display()))?;
1276 let defaults = request_defaults_from_workspace(&workspace, &root);
1277 let answers = run_qa_form(
1278 input,
1279 output,
1280 &wizard_request_form_spec_json(WizardMode::Update, Some(&defaults))?,
1281 None,
1282 "update wizard",
1283 last_compact_title,
1284 )?;
1285 normalized_request_from_qa_answers(answers, crate::i18n::current_locale(), WizardMode::Update)
1286}
1287
1288#[allow(dead_code)]
1289fn request_defaults_from_workspace(
1290 workspace: &crate::project::BundleWorkspaceDefinition,
1291 root: &Path,
1292) -> RequestDefaults {
1293 RequestDefaults {
1294 bundle_name: Some(workspace.bundle_name.clone()),
1295 bundle_id: Some(workspace.bundle_id.clone()),
1296 output_dir: Some(root.display().to_string()),
1297 advanced_setup: Some(workspace.advanced_setup.to_string()),
1298 app_packs: Some(workspace.app_packs.join(", ")),
1299 extension_providers: Some(workspace.extension_providers.join(", ")),
1300 remote_catalogs: Some(workspace.remote_catalogs.join(", ")),
1301 setup_execution_intent: Some(workspace.setup_execution_intent.to_string()),
1302 export_intent: Some(workspace.export_intent.to_string()),
1303 }
1304}
1305
1306#[allow(dead_code)]
1307fn run_qa_form<R: BufRead, W: Write>(
1308 input: &mut R,
1309 output: &mut W,
1310 spec_json: &str,
1311 initial_answers_json: Option<String>,
1312 context_label: &str,
1313 last_compact_title: &mut Option<String>,
1314) -> Result<Value> {
1315 let config = WizardRunConfig {
1316 spec_json: spec_json.to_string(),
1317 initial_answers_json,
1318 frontend: WizardFrontend::Text,
1319 i18n: I18nConfig {
1320 locale: Some(crate::i18n::current_locale()),
1321 resolved: None,
1322 debug: false,
1323 },
1324 verbose: false,
1325 };
1326 let mut driver = WizardDriver::new(config)
1327 .with_context(|| format!("initialize greentic-qa-lib {context_label}"))?;
1328 loop {
1329 let payload_raw = driver
1330 .next_payload_json()
1331 .with_context(|| format!("render greentic-qa-lib {context_label} payload"))?;
1332 let payload: Value = serde_json::from_str(&payload_raw)
1333 .with_context(|| format!("parse greentic-qa-lib {context_label} payload"))?;
1334 if let Some(text) = payload.get("text").and_then(Value::as_str) {
1335 render_qa_driver_text(output, text, last_compact_title)?;
1336 }
1337 if driver.is_complete() {
1338 break;
1339 }
1340
1341 let ui_raw = driver.last_ui_json().ok_or_else(|| {
1342 anyhow::anyhow!("greentic-qa-lib {context_label} payload missing UI state")
1343 })?;
1344 let ui: Value = serde_json::from_str(ui_raw)
1345 .with_context(|| format!("parse greentic-qa-lib {context_label} UI payload"))?;
1346 let question_id = ui
1347 .get("next_question_id")
1348 .and_then(Value::as_str)
1349 .ok_or_else(|| {
1350 anyhow::anyhow!("greentic-qa-lib {context_label} missing next_question_id")
1351 })?
1352 .to_string();
1353 let question = ui
1354 .get("questions")
1355 .and_then(Value::as_array)
1356 .and_then(|questions| {
1357 questions.iter().find(|question| {
1358 question.get("id").and_then(Value::as_str) == Some(question_id.as_str())
1359 })
1360 })
1361 .ok_or_else(|| {
1362 anyhow::anyhow!("greentic-qa-lib {context_label} missing question {question_id}")
1363 })?;
1364
1365 let answer = prompt_qa_question_answer(input, output, &question_id, question)?;
1366 driver
1367 .submit_patch_json(&json!({ question_id: answer }).to_string())
1368 .with_context(|| format!("submit greentic-qa-lib {context_label} answer"))?;
1369 }
1370
1371 let result = driver
1372 .finish()
1373 .with_context(|| format!("finish greentic-qa-lib {context_label}"))?;
1374 Ok(result.answer_set.answers)
1375}
1376
1377#[allow(dead_code)]
1378#[derive(Debug, Clone, Default)]
1379struct RequestDefaults {
1380 bundle_name: Option<String>,
1381 bundle_id: Option<String>,
1382 output_dir: Option<String>,
1383 advanced_setup: Option<String>,
1384 app_packs: Option<String>,
1385 extension_providers: Option<String>,
1386 remote_catalogs: Option<String>,
1387 setup_execution_intent: Option<String>,
1388 export_intent: Option<String>,
1389}
1390
1391#[allow(dead_code)]
1392fn wizard_request_form_spec_json(
1393 mode: WizardMode,
1394 defaults: Option<&RequestDefaults>,
1395) -> Result<String> {
1396 let defaults = defaults.cloned().unwrap_or_default();
1397 let output_dir_default = defaults.output_dir.clone().or_else(|| {
1398 if matches!(mode, WizardMode::Create) {
1399 defaults
1400 .bundle_id
1401 .as_deref()
1402 .map(default_bundle_output_dir)
1403 .map(|path| path.display().to_string())
1404 } else {
1405 None
1406 }
1407 });
1408 Ok(json!({
1409 "id": format!("greentic-bundle-root-wizard-{}", mode_name(mode)),
1410 "title": crate::i18n::tr("wizard.menu.title"),
1411 "version": "1.0.0",
1412 "presentation": {
1413 "default_locale": crate::i18n::current_locale()
1414 },
1415 "progress_policy": {
1416 "skip_answered": true,
1417 "autofill_defaults": false,
1418 "treat_default_as_answered": false
1419 },
1420 "questions": [
1421 {
1422 "id": "bundle_name",
1423 "type": "string",
1424 "title": crate::i18n::tr("wizard.prompt.bundle_name"),
1425 "required": true,
1426 "default_value": defaults.bundle_name
1427 },
1428 {
1429 "id": "bundle_id",
1430 "type": "string",
1431 "title": crate::i18n::tr("wizard.prompt.bundle_id"),
1432 "required": true,
1433 "default_value": defaults.bundle_id
1434 },
1435 {
1436 "id": "output_dir",
1437 "type": "string",
1438 "title": crate::i18n::tr("wizard.prompt.output_dir"),
1439 "required": !matches!(mode, WizardMode::Create),
1440 "default_value": output_dir_default
1441 },
1442 {
1443 "id": "advanced_setup",
1444 "type": "boolean",
1445 "title": crate::i18n::tr("wizard.prompt.advanced_setup"),
1446 "required": true,
1447 "default_value": defaults.advanced_setup.unwrap_or_else(|| "false".to_string())
1448 },
1449 {
1450 "id": "app_packs",
1451 "type": "string",
1452 "title": crate::i18n::tr("wizard.prompt.app_packs"),
1453 "required": false,
1454 "default_value": defaults.app_packs,
1455 "visible_if": { "op": "var", "path": "/advanced_setup" }
1456 },
1457 {
1458 "id": "extension_providers",
1459 "type": "string",
1460 "title": crate::i18n::tr("wizard.prompt.extension_providers"),
1461 "required": false,
1462 "default_value": defaults.extension_providers,
1463 "visible_if": { "op": "var", "path": "/advanced_setup" }
1464 },
1465 {
1466 "id": "remote_catalogs",
1467 "type": "string",
1468 "title": crate::i18n::tr("wizard.prompt.remote_catalogs"),
1469 "required": false,
1470 "default_value": defaults.remote_catalogs,
1471 "visible_if": { "op": "var", "path": "/advanced_setup" }
1472 },
1473 {
1474 "id": "setup_execution_intent",
1475 "type": "boolean",
1476 "title": crate::i18n::tr("wizard.prompt.setup_execution"),
1477 "required": true,
1478 "default_value": defaults
1479 .setup_execution_intent
1480 .unwrap_or_else(|| "false".to_string()),
1481 "visible_if": { "op": "var", "path": "/advanced_setup" }
1482 },
1483 {
1484 "id": "export_intent",
1485 "type": "boolean",
1486 "title": crate::i18n::tr("wizard.prompt.export_intent"),
1487 "required": true,
1488 "default_value": defaults.export_intent.unwrap_or_else(|| "false".to_string()),
1489 "visible_if": { "op": "var", "path": "/advanced_setup" }
1490 }
1491 ]
1492 })
1493 .to_string())
1494}
1495
1496#[derive(Debug)]
1497struct SeedRequest {
1498 mode: WizardMode,
1499 locale: String,
1500 bundle_name: String,
1501 bundle_id: String,
1502 output_dir: PathBuf,
1503 app_pack_entries: Vec<AppPackEntry>,
1504 access_rules: Vec<AccessRuleInput>,
1505 extension_provider_entries: Vec<ExtensionProviderEntry>,
1506 advanced_setup: bool,
1507 app_packs: Vec<String>,
1508 extension_providers: Vec<String>,
1509 remote_catalogs: Vec<String>,
1510 setup_specs: BTreeMap<String, Value>,
1511 setup_answers: BTreeMap<String, Value>,
1512 setup_execution_intent: bool,
1513 export_intent: bool,
1514 capabilities: Vec<String>,
1515}
1516
1517fn normalize_request(seed: SeedRequest) -> NormalizedRequest {
1518 let bundle_id = normalize_bundle_id(&seed.bundle_id);
1519 let mut app_pack_entries = seed.app_pack_entries;
1520 if app_pack_entries.is_empty() {
1521 app_pack_entries = seed
1522 .app_packs
1523 .iter()
1524 .map(|reference| AppPackEntry {
1525 reference: reference.clone(),
1526 detected_kind: "legacy".to_string(),
1527 pack_id: inferred_reference_id(reference),
1528 display_name: inferred_display_name(reference),
1529 version: inferred_reference_version(reference),
1530 mapping: AppPackMappingInput {
1531 scope: "global".to_string(),
1532 tenant: None,
1533 team: None,
1534 },
1535 })
1536 .collect();
1537 }
1538 app_pack_entries.sort_by(|left, right| left.reference.cmp(&right.reference));
1539 app_pack_entries.dedup_by(|left, right| {
1540 left.reference == right.reference
1541 && left.mapping.scope == right.mapping.scope
1542 && left.mapping.tenant == right.mapping.tenant
1543 && left.mapping.team == right.mapping.team
1544 });
1545 let mut app_packs = seed.app_packs;
1546 app_packs.extend(app_pack_entries.iter().map(|entry| entry.reference.clone()));
1547
1548 let mut extension_provider_entries = seed.extension_provider_entries;
1549 if extension_provider_entries.is_empty() {
1550 extension_provider_entries = seed
1551 .extension_providers
1552 .iter()
1553 .map(|reference| ExtensionProviderEntry {
1554 reference: reference.clone(),
1555 detected_kind: "legacy".to_string(),
1556 provider_id: inferred_reference_id(reference),
1557 display_name: inferred_display_name(reference),
1558 version: inferred_reference_version(reference),
1559 source_catalog: None,
1560 group: None,
1561 })
1562 .collect();
1563 }
1564 extension_provider_entries.sort_by(|left, right| left.reference.cmp(&right.reference));
1565 extension_provider_entries.dedup_by(|left, right| left.reference == right.reference);
1566 let mut extension_providers = seed.extension_providers;
1567 extension_providers.extend(
1568 extension_provider_entries
1569 .iter()
1570 .map(|entry| entry.reference.clone()),
1571 );
1572
1573 let mut remote_catalogs = seed.remote_catalogs;
1574 remote_catalogs.extend(
1575 extension_provider_entries
1576 .iter()
1577 .filter_map(|entry| entry.source_catalog.clone()),
1578 );
1579
1580 let access_rules = if seed.access_rules.is_empty() {
1581 derive_access_rules_from_entries(&app_pack_entries)
1582 } else {
1583 normalize_access_rules(seed.access_rules)
1584 };
1585
1586 NormalizedRequest {
1587 mode: seed.mode,
1588 locale: crate::i18n::normalize_locale(&seed.locale).unwrap_or_else(|| "en".to_string()),
1589 bundle_name: seed.bundle_name.trim().to_string(),
1590 bundle_id,
1591 output_dir: normalize_output_dir(seed.output_dir),
1592 app_pack_entries,
1593 access_rules,
1594 extension_provider_entries,
1595 advanced_setup: seed.advanced_setup,
1596 app_packs: sorted_unique(app_packs),
1597 extension_providers: sorted_unique(extension_providers),
1598 remote_catalogs: sorted_unique(remote_catalogs),
1599 setup_specs: seed.setup_specs,
1600 setup_answers: seed.setup_answers,
1601 setup_execution_intent: seed.setup_execution_intent,
1602 export_intent: seed.export_intent,
1603 capabilities: sorted_unique(seed.capabilities),
1604 }
1605}
1606
1607fn normalize_access_rules(mut rules: Vec<AccessRuleInput>) -> Vec<AccessRuleInput> {
1608 rules.retain(|rule| !rule.rule_path.trim().is_empty() && !rule.tenant.trim().is_empty());
1609 rules.sort_by(|left, right| {
1610 left.tenant
1611 .cmp(&right.tenant)
1612 .then(left.team.cmp(&right.team))
1613 .then(left.rule_path.cmp(&right.rule_path))
1614 .then(left.policy.cmp(&right.policy))
1615 });
1616 rules.dedup_by(|left, right| {
1617 left.tenant == right.tenant
1618 && left.team == right.team
1619 && left.rule_path == right.rule_path
1620 && left.policy == right.policy
1621 });
1622 rules
1623}
1624
1625fn request_from_workspace(
1626 workspace: &crate::project::BundleWorkspaceDefinition,
1627 root: &Path,
1628 mode: WizardMode,
1629) -> NormalizedRequest {
1630 let app_pack_entries = if workspace.app_pack_mappings.is_empty() {
1631 workspace
1632 .app_packs
1633 .iter()
1634 .map(|reference| AppPackEntry {
1635 pack_id: inferred_reference_id(reference),
1636 display_name: inferred_display_name(reference),
1637 version: inferred_reference_version(reference),
1638 detected_kind: detected_reference_kind(root, reference).to_string(),
1639 reference: reference.clone(),
1640 mapping: AppPackMappingInput {
1641 scope: "global".to_string(),
1642 tenant: None,
1643 team: None,
1644 },
1645 })
1646 .collect::<Vec<_>>()
1647 } else {
1648 workspace
1649 .app_pack_mappings
1650 .iter()
1651 .map(|mapping| AppPackEntry {
1652 pack_id: inferred_reference_id(&mapping.reference),
1653 display_name: inferred_display_name(&mapping.reference),
1654 version: inferred_reference_version(&mapping.reference),
1655 detected_kind: detected_reference_kind(root, &mapping.reference).to_string(),
1656 reference: mapping.reference.clone(),
1657 mapping: AppPackMappingInput {
1658 scope: match mapping.scope {
1659 crate::project::MappingScope::Global => "global".to_string(),
1660 crate::project::MappingScope::Tenant => "tenant".to_string(),
1661 crate::project::MappingScope::Team => "tenant_team".to_string(),
1662 },
1663 tenant: mapping.tenant.clone(),
1664 team: mapping.team.clone(),
1665 },
1666 })
1667 .collect::<Vec<_>>()
1668 };
1669
1670 let access_rules = derive_access_rules_from_entries(&app_pack_entries);
1671 let extension_provider_entries = workspace
1672 .extension_providers
1673 .iter()
1674 .map(|reference| ExtensionProviderEntry {
1675 provider_id: inferred_reference_id(reference),
1676 display_name: inferred_display_name(reference),
1677 version: inferred_reference_version(reference),
1678 detected_kind: detected_reference_kind(root, reference).to_string(),
1679 reference: reference.clone(),
1680 source_catalog: workspace.remote_catalogs.first().cloned(),
1681 group: None,
1682 })
1683 .collect();
1684
1685 normalize_request(SeedRequest {
1686 mode,
1687 locale: workspace.locale.clone(),
1688 bundle_name: workspace.bundle_name.clone(),
1689 bundle_id: workspace.bundle_id.clone(),
1690 output_dir: root.to_path_buf(),
1691 app_pack_entries,
1692 access_rules,
1693 extension_provider_entries,
1694 advanced_setup: false,
1695 app_packs: workspace.app_packs.clone(),
1696 extension_providers: workspace.extension_providers.clone(),
1697 remote_catalogs: workspace.remote_catalogs.clone(),
1698 setup_specs: BTreeMap::new(),
1699 setup_answers: BTreeMap::new(),
1700 setup_execution_intent: false,
1701 export_intent: false,
1702 capabilities: workspace.capabilities.clone(),
1703 })
1704}
1705
1706fn prompt_required_string<R: BufRead, W: Write>(
1707 input: &mut R,
1708 output: &mut W,
1709 title: &str,
1710 default: Option<&str>,
1711) -> Result<String> {
1712 loop {
1713 let value = prompt_optional_string(input, output, title, default)?;
1714 if !value.trim().is_empty() {
1715 return Ok(value);
1716 }
1717 writeln!(output, "{}", crate::i18n::tr("wizard.error.empty_answer"))?;
1718 }
1719}
1720
1721fn prompt_optional_string<R: BufRead, W: Write>(
1722 input: &mut R,
1723 output: &mut W,
1724 title: &str,
1725 default: Option<&str>,
1726) -> Result<String> {
1727 let default_value = default.map(|value| Value::String(value.to_string()));
1728 let value = prompt_qa_string_like(input, output, title, false, false, default_value)?;
1729 Ok(value.as_str().unwrap_or_default().to_string())
1730}
1731
1732fn edit_app_packs<R: BufRead, W: Write>(
1733 input: &mut R,
1734 output: &mut W,
1735 mut state: NormalizedRequest,
1736 allow_back: bool,
1737) -> Result<NormalizedRequest> {
1738 loop {
1739 writeln!(output, "{}", crate::i18n::tr("wizard.stage.app_packs"))?;
1740 render_pack_entries(output, &state.app_pack_entries)?;
1741 writeln!(
1742 output,
1743 "1. {}",
1744 crate::i18n::tr("wizard.action.add_app_pack")
1745 )?;
1746 writeln!(
1747 output,
1748 "2. {}",
1749 crate::i18n::tr("wizard.action.edit_app_pack_mapping")
1750 )?;
1751 writeln!(
1752 output,
1753 "3. {}",
1754 crate::i18n::tr("wizard.action.remove_app_pack")
1755 )?;
1756 writeln!(output, "4. {}", crate::i18n::tr("wizard.action.continue"))?;
1757 if allow_back {
1758 writeln!(output, "0. {}", crate::i18n::tr("wizard.action.back"))?;
1759 }
1760
1761 let answer = prompt_menu_value(input, output)?;
1762 match answer.as_str() {
1763 "1" => {
1764 if let Some(entry) = add_app_pack(input, output, &state)? {
1765 state.app_pack_entries.push(entry);
1766 state.access_rules = derive_access_rules_from_entries(&state.app_pack_entries);
1767 state = rebuild_request(state);
1768 }
1769 }
1770 "2" => {
1771 if !state.app_pack_entries.is_empty() {
1772 state = edit_pack_access(input, output, state, true)?;
1773 }
1774 }
1775 "3" => {
1776 remove_app_pack(input, output, &mut state)?;
1777 state.access_rules = derive_access_rules_from_entries(&state.app_pack_entries);
1778 state = rebuild_request(state);
1779 }
1780 "4" => {
1781 if state.app_pack_entries.is_empty() {
1782 writeln!(
1783 output,
1784 "{}",
1785 crate::i18n::tr("wizard.error.app_pack_required")
1786 )?;
1787 continue;
1788 }
1789 return Ok(state);
1790 }
1791 "0" if allow_back => return Ok(state),
1792 _ => writeln!(output, "{}", crate::i18n::tr("wizard.error.invalid_choice"))?,
1793 }
1794 }
1795}
1796
1797fn edit_pack_access<R: BufRead, W: Write>(
1798 input: &mut R,
1799 output: &mut W,
1800 mut state: NormalizedRequest,
1801 allow_back: bool,
1802) -> Result<NormalizedRequest> {
1803 loop {
1804 writeln!(output, "{}", crate::i18n::tr("wizard.stage.pack_access"))?;
1805 render_pack_entries(output, &state.app_pack_entries)?;
1806 writeln!(
1807 output,
1808 "1. {}",
1809 crate::i18n::tr("wizard.action.change_scope")
1810 )?;
1811 writeln!(
1812 output,
1813 "2. {}",
1814 crate::i18n::tr("wizard.action.add_tenant_access")
1815 )?;
1816 writeln!(
1817 output,
1818 "3. {}",
1819 crate::i18n::tr("wizard.action.add_tenant_team_access")
1820 )?;
1821 writeln!(
1822 output,
1823 "4. {}",
1824 crate::i18n::tr("wizard.action.remove_scope")
1825 )?;
1826 writeln!(output, "5. {}", crate::i18n::tr("wizard.action.continue"))?;
1827 writeln!(
1828 output,
1829 "6. {}",
1830 crate::i18n::tr("wizard.action.advanced_access_rules")
1831 )?;
1832 if allow_back {
1833 writeln!(output, "0. {}", crate::i18n::tr("wizard.action.back"))?;
1834 }
1835 let answer = prompt_menu_value(input, output)?;
1836 match answer.as_str() {
1837 "1" => change_pack_scope(input, output, &mut state)?,
1838 "2" => add_pack_scope(input, output, &mut state, false)?,
1839 "3" => add_pack_scope(input, output, &mut state, true)?,
1840 "4" => remove_pack_scope(input, output, &mut state)?,
1841 "5" => return Ok(rebuild_request(state)),
1842 "6" => edit_advanced_access_rules(input, output, &mut state)?,
1843 "0" if allow_back => return Ok(rebuild_request(state)),
1844 _ => writeln!(output, "{}", crate::i18n::tr("wizard.error.invalid_choice"))?,
1845 }
1846 state.access_rules = derive_access_rules_from_entries(&state.app_pack_entries);
1847 }
1848}
1849
1850fn edit_extension_providers<R: BufRead, W: Write>(
1851 input: &mut R,
1852 output: &mut W,
1853 mut state: NormalizedRequest,
1854 allow_back: bool,
1855) -> Result<NormalizedRequest> {
1856 loop {
1857 writeln!(
1858 output,
1859 "{}",
1860 crate::i18n::tr("wizard.stage.extension_providers")
1861 )?;
1862 render_named_entries(
1863 output,
1864 &crate::i18n::tr("wizard.stage.current_extension_providers"),
1865 &state
1866 .extension_provider_entries
1867 .iter()
1868 .map(|entry| format!("{} [{}]", entry.display_name, entry.reference))
1869 .collect::<Vec<_>>(),
1870 )?;
1871 writeln!(
1872 output,
1873 "1. {}",
1874 crate::i18n::tr("wizard.action.add_common_extension_provider")
1875 )?;
1876 writeln!(
1877 output,
1878 "2. {}",
1879 crate::i18n::tr("wizard.action.add_custom_extension_provider")
1880 )?;
1881 writeln!(
1882 output,
1883 "3. {}",
1884 crate::i18n::tr("wizard.action.remove_extension_provider")
1885 )?;
1886 writeln!(output, "4. {}", crate::i18n::tr("wizard.action.continue"))?;
1887 if allow_back {
1888 writeln!(output, "0. {}", crate::i18n::tr("wizard.action.back"))?;
1889 }
1890 let answer = prompt_menu_value(input, output)?;
1891 match answer.as_str() {
1892 "1" => {
1893 if let Some(entry) = add_common_extension_provider(input, output, &state)? {
1894 state.extension_provider_entries.push(entry);
1895 state = rebuild_request(state);
1896 }
1897 }
1898 "2" => {
1899 if let Some(entry) = add_custom_extension_provider(input, output, &state)? {
1900 state.extension_provider_entries.push(entry);
1901 state = rebuild_request(state);
1902 }
1903 }
1904 "3" => {
1905 remove_extension_provider(input, output, &mut state)?;
1906 state = rebuild_request(state);
1907 }
1908 "4" => return Ok(state),
1909 "0" if allow_back => return Ok(state),
1910 _ => writeln!(output, "{}", crate::i18n::tr("wizard.error.invalid_choice"))?,
1911 }
1912 }
1913}
1914
1915fn review_summary<R: BufRead, W: Write>(
1916 input: &mut R,
1917 output: &mut W,
1918 state: &NormalizedRequest,
1919 include_edit_paths: bool,
1920) -> Result<ReviewAction> {
1921 loop {
1922 writeln!(output, "{}", crate::i18n::tr("wizard.stage.review"))?;
1923 writeln!(
1924 output,
1925 "{}: {}",
1926 crate::i18n::tr("wizard.prompt.bundle_name"),
1927 state.bundle_name
1928 )?;
1929 writeln!(
1930 output,
1931 "{}: {}",
1932 crate::i18n::tr("wizard.prompt.bundle_id"),
1933 state.bundle_id
1934 )?;
1935 writeln!(
1936 output,
1937 "{}: {}",
1938 crate::i18n::tr("wizard.prompt.output_dir"),
1939 state.output_dir.display()
1940 )?;
1941 render_named_entries(
1942 output,
1943 &crate::i18n::tr("wizard.stage.current_app_packs"),
1944 &state
1945 .app_pack_entries
1946 .iter()
1947 .map(|entry| {
1948 format!(
1949 "{} [{} -> {}]",
1950 entry.display_name,
1951 entry.reference,
1952 format_mapping(&entry.mapping)
1953 )
1954 })
1955 .collect::<Vec<_>>(),
1956 )?;
1957 render_named_entries(
1958 output,
1959 &crate::i18n::tr("wizard.stage.current_access_rules"),
1960 &state
1961 .access_rules
1962 .iter()
1963 .map(format_access_rule)
1964 .collect::<Vec<_>>(),
1965 )?;
1966 render_named_entries(
1967 output,
1968 &crate::i18n::tr("wizard.stage.current_extension_providers"),
1969 &state
1970 .extension_provider_entries
1971 .iter()
1972 .map(|entry| format!("{} [{}]", entry.display_name, entry.reference))
1973 .collect::<Vec<_>>(),
1974 )?;
1975 if !state.capabilities.is_empty() {
1976 render_named_entries(
1977 output,
1978 &crate::i18n::tr("wizard.stage.capabilities"),
1979 &state.capabilities,
1980 )?;
1981 }
1982 writeln!(
1983 output,
1984 "1. {}",
1985 crate::i18n::tr("wizard.action.build_bundle")
1986 )?;
1987 writeln!(
1988 output,
1989 "2. {}",
1990 crate::i18n::tr("wizard.action.dry_run_only")
1991 )?;
1992 writeln!(
1993 output,
1994 "3. {}",
1995 crate::i18n::tr("wizard.action.save_answers_only")
1996 )?;
1997 if include_edit_paths {
1998 writeln!(output, "4. {}", crate::i18n::tr("wizard.action.finish"))?;
1999 }
2000 writeln!(output, "0. {}", crate::i18n::tr("wizard.action.back"))?;
2001 let answer = prompt_menu_value(input, output)?;
2002 match answer.as_str() {
2003 "1" => return Ok(ReviewAction::BuildNow),
2004 "2" => return Ok(ReviewAction::DryRunOnly),
2005 "3" => return Ok(ReviewAction::SaveAnswersOnly),
2006 "4" if include_edit_paths => return Ok(ReviewAction::BuildNow),
2007 "0" => return Ok(ReviewAction::DryRunOnly),
2008 _ => writeln!(output, "{}", crate::i18n::tr("wizard.error.invalid_choice"))?,
2009 }
2010 }
2011}
2012
2013fn prompt_menu_value<R: BufRead, W: Write>(input: &mut R, output: &mut W) -> Result<String> {
2014 write!(output, "{} ", crate::i18n::tr("wizard.setup.enum_prompt"))?;
2015 output.flush()?;
2016 let mut line = String::new();
2017 input.read_line(&mut line)?;
2018 Ok(line.trim().to_string())
2019}
2020
2021fn render_named_entries<W: Write>(output: &mut W, title: &str, entries: &[String]) -> Result<()> {
2022 writeln!(output, "{title}:")?;
2023 if entries.is_empty() {
2024 writeln!(output, "- {}", crate::i18n::tr("wizard.list.none"))?;
2025 } else {
2026 for entry in entries {
2027 writeln!(output, "- {entry}")?;
2028 }
2029 }
2030 Ok(())
2031}
2032
2033#[derive(Debug, Clone)]
2034struct PackGroup {
2035 reference: String,
2036 display_name: String,
2037 scopes: Vec<AppPackMappingInput>,
2038}
2039
2040fn render_pack_entries<W: Write>(output: &mut W, entries: &[AppPackEntry]) -> Result<()> {
2041 writeln!(
2042 output,
2043 "{}",
2044 crate::i18n::tr("wizard.stage.current_app_packs")
2045 )?;
2046 let groups = group_pack_entries(entries);
2047 if groups.is_empty() {
2048 writeln!(output, "- {}", crate::i18n::tr("wizard.list.none"))?;
2049 return Ok(());
2050 }
2051 for (index, group) in groups.iter().enumerate() {
2052 writeln!(output, "{}) {}", index + 1, group.display_name)?;
2053 writeln!(
2054 output,
2055 " {}: {}",
2056 crate::i18n::tr("wizard.label.source"),
2057 group.reference
2058 )?;
2059 writeln!(
2060 output,
2061 " {}: {}",
2062 crate::i18n::tr("wizard.label.scope"),
2063 group
2064 .scopes
2065 .iter()
2066 .map(format_mapping)
2067 .collect::<Vec<_>>()
2068 .join(", ")
2069 )?;
2070 }
2071 Ok(())
2072}
2073
2074fn group_pack_entries(entries: &[AppPackEntry]) -> Vec<PackGroup> {
2075 let mut groups = Vec::<PackGroup>::new();
2076 for entry in entries {
2077 if let Some(group) = groups
2078 .iter_mut()
2079 .find(|group| group.reference == entry.reference)
2080 {
2081 group.scopes.push(entry.mapping.clone());
2082 } else {
2083 groups.push(PackGroup {
2084 reference: entry.reference.clone(),
2085 display_name: entry.display_name.clone(),
2086 scopes: vec![entry.mapping.clone()],
2087 });
2088 }
2089 }
2090 groups
2091}
2092
2093fn edit_bundle_capabilities<R: BufRead, W: Write>(
2094 input: &mut R,
2095 output: &mut W,
2096 mut state: NormalizedRequest,
2097) -> Result<NormalizedRequest> {
2098 let cap = crate::project::CAP_BUNDLE_ASSETS_READ_V1.to_string();
2099 let already_enabled = state.capabilities.contains(&cap);
2100 let default_value = if already_enabled {
2101 Some(Value::Bool(true))
2102 } else {
2103 Some(Value::Bool(false))
2104 };
2105 let answer = prompt_qa_boolean(
2106 input,
2107 output,
2108 &crate::i18n::tr("wizard.prompt.enable_bundle_assets"),
2109 false,
2110 default_value,
2111 )?;
2112 let enable = answer.as_bool().unwrap_or(false);
2113 if enable && !state.capabilities.contains(&cap) {
2114 state.capabilities.push(cap);
2115 } else if !enable {
2116 state.capabilities.retain(|c| c != &cap);
2117 }
2118 Ok(state)
2119}
2120
2121fn rebuild_request(request: NormalizedRequest) -> NormalizedRequest {
2122 normalize_request(SeedRequest {
2123 mode: request.mode,
2124 locale: request.locale,
2125 bundle_name: request.bundle_name,
2126 bundle_id: request.bundle_id,
2127 output_dir: request.output_dir,
2128 app_pack_entries: request.app_pack_entries,
2129 access_rules: request.access_rules,
2130 extension_provider_entries: request.extension_provider_entries,
2131 advanced_setup: false,
2132 app_packs: Vec::new(),
2133 extension_providers: Vec::new(),
2134 remote_catalogs: request.remote_catalogs,
2135 setup_specs: BTreeMap::new(),
2136 setup_answers: BTreeMap::new(),
2137 setup_execution_intent: false,
2138 export_intent: false,
2139 capabilities: request.capabilities,
2140 })
2141}
2142
2143fn format_mapping(mapping: &AppPackMappingInput) -> String {
2144 match mapping.scope.as_str() {
2145 "tenant" => format!("tenant:{}", mapping.tenant.clone().unwrap_or_default()),
2146 "tenant_team" => format!(
2147 "tenant/team:{}/{}",
2148 mapping.tenant.clone().unwrap_or_default(),
2149 mapping.team.clone().unwrap_or_default()
2150 ),
2151 _ => "global".to_string(),
2152 }
2153}
2154
2155fn format_access_rule(rule: &AccessRuleInput) -> String {
2156 match &rule.team {
2157 Some(team) => format!(
2158 "{}/{team}: {} = {}",
2159 rule.tenant, rule.rule_path, rule.policy
2160 ),
2161 None => format!("{}: {} = {}", rule.tenant, rule.rule_path, rule.policy),
2162 }
2163}
2164
2165fn derive_access_rules_from_entries(entries: &[AppPackEntry]) -> Vec<AccessRuleInput> {
2166 normalize_access_rules(
2167 entries
2168 .iter()
2169 .map(|entry| match entry.mapping.scope.as_str() {
2170 "tenant" => AccessRuleInput {
2171 rule_path: entry.pack_id.clone(),
2172 policy: "public".to_string(),
2173 tenant: entry
2174 .mapping
2175 .tenant
2176 .clone()
2177 .unwrap_or_else(|| "default".to_string()),
2178 team: None,
2179 },
2180 "tenant_team" => AccessRuleInput {
2181 rule_path: entry.pack_id.clone(),
2182 policy: "public".to_string(),
2183 tenant: entry
2184 .mapping
2185 .tenant
2186 .clone()
2187 .unwrap_or_else(|| "default".to_string()),
2188 team: entry.mapping.team.clone(),
2189 },
2190 _ => AccessRuleInput {
2191 rule_path: entry.pack_id.clone(),
2192 policy: "public".to_string(),
2193 tenant: "default".to_string(),
2194 team: None,
2195 },
2196 })
2197 .collect(),
2198 )
2199}
2200
2201fn choose_pack_group_index<R: BufRead, W: Write>(
2202 input: &mut R,
2203 output: &mut W,
2204 entries: &[AppPackEntry],
2205) -> Result<Option<usize>> {
2206 let groups = group_pack_entries(entries);
2207 choose_named_index(
2208 input,
2209 output,
2210 &crate::i18n::tr("wizard.prompt.choose_app_pack"),
2211 &groups
2212 .iter()
2213 .map(|group| format!("{} [{}]", group.display_name, group.reference))
2214 .collect::<Vec<_>>(),
2215 )
2216}
2217
2218fn change_pack_scope<R: BufRead, W: Write>(
2219 input: &mut R,
2220 output: &mut W,
2221 state: &mut NormalizedRequest,
2222) -> Result<()> {
2223 let Some(group_index) = choose_pack_group_index(input, output, &state.app_pack_entries)? else {
2224 return Ok(());
2225 };
2226 let groups = group_pack_entries(&state.app_pack_entries);
2227 let group = &groups[group_index];
2228 let template = state
2229 .app_pack_entries
2230 .iter()
2231 .find(|entry| entry.reference == group.reference)
2232 .cloned()
2233 .ok_or_else(|| anyhow::anyhow!("missing pack entry template"))?;
2234 let mapping = prompt_app_pack_mapping(input, output, &template.pack_id)?;
2235 state
2236 .app_pack_entries
2237 .retain(|entry| entry.reference != group.reference);
2238 let mut replacement = template;
2239 replacement.mapping = mapping;
2240 state.app_pack_entries.push(replacement);
2241 Ok(())
2242}
2243
2244fn add_pack_scope<R: BufRead, W: Write>(
2245 input: &mut R,
2246 output: &mut W,
2247 state: &mut NormalizedRequest,
2248 include_team: bool,
2249) -> Result<()> {
2250 let Some(group_index) = choose_pack_group_index(input, output, &state.app_pack_entries)? else {
2251 return Ok(());
2252 };
2253 let groups = group_pack_entries(&state.app_pack_entries);
2254 let group = &groups[group_index];
2255 let template = state
2256 .app_pack_entries
2257 .iter()
2258 .find(|entry| entry.reference == group.reference)
2259 .cloned()
2260 .ok_or_else(|| anyhow::anyhow!("missing pack entry template"))?;
2261 let mapping = if include_team {
2262 let tenant = prompt_required_string(
2263 input,
2264 output,
2265 &crate::i18n::tr("wizard.prompt.tenant_id"),
2266 Some("default"),
2267 )?;
2268 let team = prompt_required_string(
2269 input,
2270 output,
2271 &crate::i18n::tr("wizard.prompt.team_id"),
2272 None,
2273 )?;
2274 AppPackMappingInput {
2275 scope: "tenant_team".to_string(),
2276 tenant: Some(tenant),
2277 team: Some(team),
2278 }
2279 } else {
2280 let tenant = prompt_required_string(
2281 input,
2282 output,
2283 &crate::i18n::tr("wizard.prompt.tenant_id"),
2284 Some("default"),
2285 )?;
2286 AppPackMappingInput {
2287 scope: "tenant".to_string(),
2288 tenant: Some(tenant),
2289 team: None,
2290 }
2291 };
2292 if state
2293 .app_pack_entries
2294 .iter()
2295 .any(|entry| entry.reference == group.reference && entry.mapping == mapping)
2296 {
2297 return Ok(());
2298 }
2299 let mut addition = template;
2300 addition.mapping = mapping;
2301 state.app_pack_entries.push(addition);
2302 Ok(())
2303}
2304
2305fn remove_pack_scope<R: BufRead, W: Write>(
2306 input: &mut R,
2307 output: &mut W,
2308 state: &mut NormalizedRequest,
2309) -> Result<()> {
2310 let groups = group_pack_entries(&state.app_pack_entries);
2311 let Some(group_index) = choose_pack_group_index(input, output, &state.app_pack_entries)? else {
2312 return Ok(());
2313 };
2314 let group = &groups[group_index];
2315 let Some(scope_index) = choose_named_index(
2316 input,
2317 output,
2318 &crate::i18n::tr("wizard.prompt.choose_scope"),
2319 &group.scopes.iter().map(format_mapping).collect::<Vec<_>>(),
2320 )?
2321 else {
2322 return Ok(());
2323 };
2324 let target_scope = &group.scopes[scope_index];
2325 state
2326 .app_pack_entries
2327 .retain(|entry| !(entry.reference == group.reference && &entry.mapping == target_scope));
2328 Ok(())
2329}
2330
2331fn edit_advanced_access_rules<R: BufRead, W: Write>(
2332 input: &mut R,
2333 output: &mut W,
2334 state: &mut NormalizedRequest,
2335) -> Result<()> {
2336 writeln!(
2337 output,
2338 "{}",
2339 crate::i18n::tr("wizard.stage.advanced_access_rules")
2340 )?;
2341 render_named_entries(
2342 output,
2343 &crate::i18n::tr("wizard.stage.current_access_rules"),
2344 &state
2345 .access_rules
2346 .iter()
2347 .map(format_access_rule)
2348 .collect::<Vec<_>>(),
2349 )?;
2350 writeln!(
2351 output,
2352 "1. {}",
2353 crate::i18n::tr("wizard.action.add_allow_rule")
2354 )?;
2355 writeln!(
2356 output,
2357 "2. {}",
2358 crate::i18n::tr("wizard.action.remove_rule")
2359 )?;
2360 writeln!(
2361 output,
2362 "3. {}",
2363 crate::i18n::tr("wizard.action.return_simple_mode")
2364 )?;
2365 loop {
2366 match prompt_menu_value(input, output)?.as_str() {
2367 "1" => add_manual_access_rule(input, output, state, "public")?,
2368 "2" => remove_access_rule(input, output, state)?,
2369 "3" => return Ok(()),
2370 _ => writeln!(output, "{}", crate::i18n::tr("wizard.error.invalid_choice"))?,
2371 }
2372 state.access_rules = normalize_access_rules(state.access_rules.clone());
2373 }
2374}
2375
2376fn add_app_pack<R: BufRead, W: Write>(
2377 input: &mut R,
2378 output: &mut W,
2379 state: &NormalizedRequest,
2380) -> Result<Option<AppPackEntry>> {
2381 loop {
2382 let raw = prompt_required_string(
2383 input,
2384 output,
2385 &crate::i18n::tr("wizard.prompt.app_pack_reference"),
2386 None,
2387 )?;
2388 let resolved = match resolve_reference_metadata(&state.output_dir, &raw) {
2389 Ok(resolved) => resolved,
2390 Err(error) => {
2391 writeln!(output, "{error}")?;
2392 continue;
2393 }
2394 };
2395 writeln!(output, "{}", crate::i18n::tr("wizard.confirm.app_pack"))?;
2396 writeln!(
2397 output,
2398 "{}: {}",
2399 crate::i18n::tr("wizard.label.pack_id"),
2400 resolved.id
2401 )?;
2402 writeln!(
2403 output,
2404 "{}: {}",
2405 crate::i18n::tr("wizard.label.name"),
2406 resolved.display_name
2407 )?;
2408 if let Some(version) = &resolved.version {
2409 writeln!(
2410 output,
2411 "{}: {}",
2412 crate::i18n::tr("wizard.label.version"),
2413 version
2414 )?;
2415 }
2416 writeln!(
2417 output,
2418 "{}: {}",
2419 crate::i18n::tr("wizard.label.source"),
2420 resolved.reference
2421 )?;
2422 writeln!(
2423 output,
2424 "1. {}",
2425 crate::i18n::tr("wizard.action.add_this_app_pack")
2426 )?;
2427 writeln!(
2428 output,
2429 "2. {}",
2430 crate::i18n::tr("wizard.action.reenter_reference")
2431 )?;
2432 writeln!(output, "0. {}", crate::i18n::tr("wizard.action.back"))?;
2433 match prompt_menu_value(input, output)?.as_str() {
2434 "1" => {
2435 let mapping = prompt_app_pack_mapping(input, output, &resolved.id)?;
2436 return Ok(Some(AppPackEntry {
2437 reference: resolved.reference,
2438 detected_kind: resolved.detected_kind,
2439 pack_id: resolved.id,
2440 display_name: resolved.display_name,
2441 version: resolved.version,
2442 mapping,
2443 }));
2444 }
2445 "2" => continue,
2446 "0" => return Ok(None),
2447 _ => writeln!(output, "{}", crate::i18n::tr("wizard.error.invalid_choice"))?,
2448 }
2449 }
2450}
2451
2452fn remove_app_pack<R: BufRead, W: Write>(
2453 input: &mut R,
2454 output: &mut W,
2455 state: &mut NormalizedRequest,
2456) -> Result<()> {
2457 let Some(index) = choose_named_index(
2458 input,
2459 output,
2460 &crate::i18n::tr("wizard.prompt.choose_app_pack"),
2461 &state
2462 .app_pack_entries
2463 .iter()
2464 .map(|entry| format!("{} [{}]", entry.display_name, entry.reference))
2465 .collect::<Vec<_>>(),
2466 )?
2467 else {
2468 return Ok(());
2469 };
2470 state.app_pack_entries.remove(index);
2471 Ok(())
2472}
2473
2474fn prompt_app_pack_mapping<R: BufRead, W: Write>(
2475 input: &mut R,
2476 output: &mut W,
2477 pack_id: &str,
2478) -> Result<AppPackMappingInput> {
2479 writeln!(output, "{}", crate::i18n::tr("wizard.stage.map_app_pack"))?;
2480 writeln!(output, "{}", pack_id)?;
2481 writeln!(output, "1. {}", crate::i18n::tr("wizard.mapping.global"))?;
2482 writeln!(output, "2. {}", crate::i18n::tr("wizard.mapping.tenant"))?;
2483 writeln!(
2484 output,
2485 "3. {}",
2486 crate::i18n::tr("wizard.mapping.tenant_team")
2487 )?;
2488 writeln!(output, "0. {}", crate::i18n::tr("wizard.action.back"))?;
2489 loop {
2490 match prompt_menu_value(input, output)?.as_str() {
2491 "1" => {
2492 return Ok(AppPackMappingInput {
2493 scope: "global".to_string(),
2494 tenant: None,
2495 team: None,
2496 });
2497 }
2498 "2" => {
2499 let tenant = prompt_required_string(
2500 input,
2501 output,
2502 &crate::i18n::tr("wizard.prompt.tenant_id"),
2503 Some("default"),
2504 )?;
2505 return Ok(AppPackMappingInput {
2506 scope: "tenant".to_string(),
2507 tenant: Some(tenant),
2508 team: None,
2509 });
2510 }
2511 "3" => {
2512 let tenant = prompt_required_string(
2513 input,
2514 output,
2515 &crate::i18n::tr("wizard.prompt.tenant_id"),
2516 Some("default"),
2517 )?;
2518 let team = prompt_required_string(
2519 input,
2520 output,
2521 &crate::i18n::tr("wizard.prompt.team_id"),
2522 None,
2523 )?;
2524 return Ok(AppPackMappingInput {
2525 scope: "tenant_team".to_string(),
2526 tenant: Some(tenant),
2527 team: Some(team),
2528 });
2529 }
2530 "0" => {
2531 return Ok(AppPackMappingInput {
2532 scope: "global".to_string(),
2533 tenant: None,
2534 team: None,
2535 });
2536 }
2537 _ => writeln!(output, "{}", crate::i18n::tr("wizard.error.invalid_choice"))?,
2538 }
2539 }
2540}
2541
2542fn add_manual_access_rule<R: BufRead, W: Write>(
2543 input: &mut R,
2544 output: &mut W,
2545 state: &mut NormalizedRequest,
2546 policy: &str,
2547) -> Result<()> {
2548 let target = prompt_access_target(input, output)?;
2549 let rule_path = prompt_required_string(
2550 input,
2551 output,
2552 &crate::i18n::tr("wizard.prompt.rule_path"),
2553 None,
2554 )?;
2555 state.access_rules.push(AccessRuleInput {
2556 rule_path,
2557 policy: policy.to_string(),
2558 tenant: target.0,
2559 team: target.1,
2560 });
2561 Ok(())
2562}
2563
2564fn remove_access_rule<R: BufRead, W: Write>(
2565 input: &mut R,
2566 output: &mut W,
2567 state: &mut NormalizedRequest,
2568) -> Result<()> {
2569 let Some(index) = choose_named_index(
2570 input,
2571 output,
2572 &crate::i18n::tr("wizard.prompt.choose_access_rule"),
2573 &state
2574 .access_rules
2575 .iter()
2576 .map(format_access_rule)
2577 .collect::<Vec<_>>(),
2578 )?
2579 else {
2580 return Ok(());
2581 };
2582 state.access_rules.remove(index);
2583 Ok(())
2584}
2585
2586fn prompt_access_target<R: BufRead, W: Write>(
2587 input: &mut R,
2588 output: &mut W,
2589) -> Result<(String, Option<String>)> {
2590 writeln!(output, "1. {}", crate::i18n::tr("wizard.mapping.global"))?;
2591 writeln!(output, "2. {}", crate::i18n::tr("wizard.mapping.tenant"))?;
2592 writeln!(
2593 output,
2594 "3. {}",
2595 crate::i18n::tr("wizard.mapping.tenant_team")
2596 )?;
2597 loop {
2598 match prompt_menu_value(input, output)?.as_str() {
2599 "1" => return Ok(("default".to_string(), None)),
2600 "2" => {
2601 let tenant = prompt_required_string(
2602 input,
2603 output,
2604 &crate::i18n::tr("wizard.prompt.tenant_id"),
2605 Some("default"),
2606 )?;
2607 return Ok((tenant, None));
2608 }
2609 "3" => {
2610 let tenant = prompt_required_string(
2611 input,
2612 output,
2613 &crate::i18n::tr("wizard.prompt.tenant_id"),
2614 Some("default"),
2615 )?;
2616 let team = prompt_required_string(
2617 input,
2618 output,
2619 &crate::i18n::tr("wizard.prompt.team_id"),
2620 None,
2621 )?;
2622 return Ok((tenant, Some(team)));
2623 }
2624 _ => writeln!(output, "{}", crate::i18n::tr("wizard.error.invalid_choice"))?,
2625 }
2626 }
2627}
2628
2629fn resolve_extension_provider_catalog(
2637 output_dir: &Path,
2638 remote_catalogs: &[String],
2639) -> Result<(
2640 bool,
2641 Option<String>,
2642 Vec<crate::catalog::registry::CatalogEntry>,
2643)> {
2644 if let Some(catalog_ref) = remote_catalogs.first() {
2646 let resolution = crate::catalog::resolve::resolve_catalogs(
2647 output_dir,
2648 std::slice::from_ref(catalog_ref),
2649 &crate::catalog::resolve::CatalogResolveOptions {
2650 offline: crate::runtime::offline(),
2651 write_cache: false,
2652 },
2653 )?;
2654 return Ok((true, Some(catalog_ref.clone()), resolution.discovered_items));
2655 }
2656
2657 let use_bundled_only = std::env::var("GREENTIC_BUNDLE_USE_BUNDLED_CATALOG")
2659 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
2660 .unwrap_or(false);
2661
2662 if !crate::runtime::offline() && !use_bundled_only {
2664 let catalog_ref = DEFAULT_PROVIDER_REGISTRY.to_string();
2665 match crate::catalog::resolve::resolve_catalogs(
2666 output_dir,
2667 std::slice::from_ref(&catalog_ref),
2668 &crate::catalog::resolve::CatalogResolveOptions {
2669 offline: false,
2670 write_cache: false,
2671 },
2672 ) {
2673 Ok(resolution) if !resolution.discovered_items.is_empty() => {
2674 return Ok((true, Some(catalog_ref), resolution.discovered_items));
2675 }
2676 _ => {
2677 }
2679 }
2680 }
2681
2682 let entries = crate::catalog::registry::bundled_provider_registry_entries()?;
2684 Ok((false, None, entries))
2685}
2686
2687fn add_common_extension_provider<R: BufRead, W: Write>(
2688 input: &mut R,
2689 output: &mut W,
2690 state: &NormalizedRequest,
2691) -> Result<Option<ExtensionProviderEntry>> {
2692 let (persist_catalog_ref, catalog_ref, entries) =
2693 resolve_extension_provider_catalog(&state.output_dir, &state.remote_catalogs)?;
2694 if entries.is_empty() {
2695 writeln!(output, "{}", crate::i18n::tr("wizard.error.empty_catalog"))?;
2696 return Ok(None);
2697 }
2698 let grouped_entries = group_catalog_entries_by_category(&entries);
2699 let category_key = if grouped_entries.len() > 1 {
2700 let labels = grouped_entries
2701 .iter()
2702 .map(|(category_id, category_label, description, _)| {
2703 let display_name = category_label.as_deref().unwrap_or(category_id);
2705 format_extension_category_label(display_name, description.as_deref())
2706 })
2707 .collect::<Vec<_>>();
2708 let Some(index) = choose_named_index(input, output, "Choose extension category", &labels)?
2709 else {
2710 return Ok(None);
2711 };
2712 Some(grouped_entries[index].0.clone())
2713 } else {
2714 None
2715 };
2716 let selected_entries = category_key
2717 .as_deref()
2718 .map(|category| {
2719 entries
2720 .iter()
2721 .filter(|entry| entry.category.as_deref().unwrap_or("other") == category)
2722 .collect::<Vec<_>>()
2723 })
2724 .unwrap_or_else(|| entries.iter().collect::<Vec<_>>());
2725 let options = build_extension_provider_options(&selected_entries);
2726 let labels = options
2727 .iter()
2728 .map(|option| option.display_name.clone())
2729 .collect::<Vec<_>>();
2730 let Some(index) = choose_named_index(
2731 input,
2732 output,
2733 &crate::i18n::tr("wizard.prompt.choose_extension_provider"),
2734 &labels,
2735 )?
2736 else {
2737 return Ok(None);
2738 };
2739 let selected = &options[index];
2740 let entry = selected.entry;
2741 let reference = resolve_catalog_entry_reference(input, output, &entry.reference)?;
2742 Ok(Some(ExtensionProviderEntry {
2743 detected_kind: detected_reference_kind(&state.output_dir, &reference).to_string(),
2744 reference,
2745 provider_id: entry.id.clone(),
2746 display_name: selected.display_name.clone(),
2747 version: inferred_reference_version(&entry.reference),
2748 source_catalog: if persist_catalog_ref {
2749 catalog_ref
2750 } else {
2751 None
2752 },
2753 group: None,
2754 }))
2755}
2756
2757fn build_extension_provider_options<'a>(
2758 entries: &'a [&'a crate::catalog::registry::CatalogEntry],
2759) -> Vec<ResolvedExtensionProviderOption<'a>> {
2760 let mut options = Vec::<ResolvedExtensionProviderOption<'a>>::new();
2761 for entry in entries {
2762 let display_name = clean_extension_provider_label(entry);
2763 if options
2764 .iter()
2765 .any(|existing| existing.display_name == display_name)
2766 {
2767 continue;
2768 }
2769 options.push(ResolvedExtensionProviderOption {
2770 entry,
2771 display_name,
2772 });
2773 }
2774 options
2775}
2776
2777#[derive(Clone)]
2778struct ResolvedExtensionProviderOption<'a> {
2779 entry: &'a crate::catalog::registry::CatalogEntry,
2780 display_name: String,
2781}
2782
2783fn clean_extension_provider_label(entry: &crate::catalog::registry::CatalogEntry) -> String {
2784 let raw = entry
2785 .label
2786 .clone()
2787 .unwrap_or_else(|| inferred_display_name(&entry.reference));
2788 let trimmed = raw.trim();
2789 for suffix in [" (latest)", " (Latest)", " (LATEST)"] {
2790 if let Some(base) = trimmed.strip_suffix(suffix) {
2791 return base.trim().to_string();
2792 }
2793 }
2794 trimmed.to_string()
2795}
2796
2797type CategoryGroup = (String, Option<String>, Option<String>, Vec<usize>);
2799
2800fn group_catalog_entries_by_category(
2801 entries: &[crate::catalog::registry::CatalogEntry],
2802) -> Vec<CategoryGroup> {
2803 let mut grouped = Vec::<CategoryGroup>::new();
2804 for (index, entry) in entries.iter().enumerate() {
2805 let category = entry
2806 .category
2807 .clone()
2808 .unwrap_or_else(|| "other".to_string());
2809 let label = entry.category_label.clone();
2810 let description = entry.category_description.clone();
2811 if let Some((_, existing_label, existing_description, indices)) =
2812 grouped.iter_mut().find(|(name, _, _, _)| name == &category)
2813 {
2814 if existing_label.is_none() {
2815 *existing_label = label.clone();
2816 }
2817 if existing_description.is_none() {
2818 *existing_description = description.clone();
2819 }
2820 indices.push(index);
2821 } else {
2822 grouped.push((category, label, description, vec![index]));
2823 }
2824 }
2825 grouped
2826}
2827
2828fn format_extension_category_label(category: &str, description: Option<&str>) -> String {
2829 match description
2830 .map(str::trim)
2831 .filter(|description| !description.is_empty())
2832 {
2833 Some(description) => format!("{category} -> {description}"),
2834 None => category.to_string(),
2835 }
2836}
2837
2838fn add_custom_extension_provider<R: BufRead, W: Write>(
2839 input: &mut R,
2840 output: &mut W,
2841 state: &NormalizedRequest,
2842) -> Result<Option<ExtensionProviderEntry>> {
2843 loop {
2844 let raw = prompt_required_string(
2845 input,
2846 output,
2847 &crate::i18n::tr("wizard.prompt.extension_provider_reference"),
2848 None,
2849 )?;
2850 let resolved = match resolve_reference_metadata(&state.output_dir, &raw) {
2851 Ok(resolved) => resolved,
2852 Err(error) => {
2853 writeln!(output, "{error}")?;
2854 continue;
2855 }
2856 };
2857 return Ok(Some(ExtensionProviderEntry {
2858 reference: resolved.reference,
2859 detected_kind: resolved.detected_kind,
2860 provider_id: resolved.id.clone(),
2861 display_name: resolved.display_name,
2862 version: resolved.version,
2863 source_catalog: None,
2864 group: None,
2865 }));
2866 }
2867}
2868
2869fn remove_extension_provider<R: BufRead, W: Write>(
2870 input: &mut R,
2871 output: &mut W,
2872 state: &mut NormalizedRequest,
2873) -> Result<()> {
2874 let Some(index) = choose_named_index(
2875 input,
2876 output,
2877 &crate::i18n::tr("wizard.prompt.choose_extension_provider"),
2878 &state
2879 .extension_provider_entries
2880 .iter()
2881 .map(|entry| format!("{} [{}]", entry.display_name, entry.reference))
2882 .collect::<Vec<_>>(),
2883 )?
2884 else {
2885 return Ok(());
2886 };
2887 state.extension_provider_entries.remove(index);
2888 Ok(())
2889}
2890
2891fn choose_named_index<R: BufRead, W: Write>(
2892 input: &mut R,
2893 output: &mut W,
2894 title: &str,
2895 entries: &[String],
2896) -> Result<Option<usize>> {
2897 if entries.is_empty() {
2898 return Ok(None);
2899 }
2900 writeln!(output, "{title}:")?;
2901 for (index, entry) in entries.iter().enumerate() {
2902 writeln!(output, "{}. {}", index + 1, entry)?;
2903 }
2904 writeln!(output, "0. {}", crate::i18n::tr("wizard.action.back"))?;
2905 loop {
2906 let answer = prompt_menu_value(input, output)?;
2907 if answer == "0" {
2908 return Ok(None);
2909 }
2910 if let Ok(index) = answer.parse::<usize>()
2911 && index > 0
2912 && index <= entries.len()
2913 {
2914 return Ok(Some(index - 1));
2915 }
2916 writeln!(output, "{}", crate::i18n::tr("wizard.error.invalid_choice"))?;
2917 }
2918}
2919
2920struct ResolvedReferenceMetadata {
2921 reference: String,
2922 detected_kind: String,
2923 id: String,
2924 display_name: String,
2925 version: Option<String>,
2926}
2927
2928fn resolve_reference_metadata(root: &Path, raw: &str) -> Result<ResolvedReferenceMetadata> {
2929 let raw = raw.trim();
2930 if raw.is_empty() {
2931 bail!("{}", crate::i18n::tr("wizard.error.empty_answer"));
2932 }
2933 validate_reference_input(root, raw)?;
2934 let detected_kind = detected_reference_kind(root, raw).to_string();
2935 Ok(ResolvedReferenceMetadata {
2936 id: inferred_reference_id(raw),
2937 display_name: inferred_display_name(raw),
2938 version: inferred_reference_version(raw),
2939 reference: raw.to_string(),
2940 detected_kind,
2941 })
2942}
2943
2944fn resolve_catalog_entry_reference<R: BufRead, W: Write>(
2945 input: &mut R,
2946 output: &mut W,
2947 raw: &str,
2948) -> Result<String> {
2949 if !raw.contains("<pr-version>") {
2950 return Ok(raw.to_string());
2951 }
2952 let version = prompt_required_string(input, output, "PR version or tag", None)?;
2953 Ok(raw.replace("<pr-version>", version.trim()))
2954}
2955
2956fn validate_reference_input(root: &Path, raw: &str) -> Result<()> {
2957 if raw.contains("<pr-version>") {
2958 bail!("Reference contains an unresolved <pr-version> placeholder.");
2959 }
2960 if let Some(path) = parse_local_gtpack_reference(root, raw) {
2961 let metadata = fs::metadata(&path)
2962 .with_context(|| format!("read local .gtpack {}", path.display()))?;
2963 if !metadata.is_file() {
2964 bail!(
2965 "Local .gtpack reference must point to a file: {}",
2966 path.display()
2967 );
2968 }
2969 }
2970 Ok(())
2971}
2972
2973fn parse_local_gtpack_reference(root: &Path, raw: &str) -> Option<PathBuf> {
2974 if let Some(path) = raw.strip_prefix("file://") {
2975 let path = PathBuf::from(path.trim());
2976 return Some(path);
2977 }
2978 if raw.contains("://") || !raw.ends_with(".gtpack") {
2979 return None;
2980 }
2981 let candidate = PathBuf::from(raw);
2982 Some(if candidate.is_absolute() {
2983 candidate
2984 } else {
2985 root.join(candidate)
2986 })
2987}
2988
2989fn detected_reference_kind(root: &Path, raw: &str) -> &'static str {
2990 if raw.starts_with("file://") {
2991 return "file_uri";
2992 }
2993 if raw.starts_with("https://") {
2994 return "https";
2995 }
2996 if raw.starts_with("oci://") {
2997 return "oci";
2998 }
2999 if raw.starts_with("repo://") {
3000 return "repo";
3001 }
3002 if raw.starts_with("store://") {
3003 return "store";
3004 }
3005 if raw.contains("://") {
3006 return "unknown";
3007 }
3008 let path = PathBuf::from(raw);
3009 let resolved = if path.is_absolute() {
3010 path
3011 } else {
3012 root.join(&path)
3013 };
3014 if resolved.is_dir() {
3015 "local_dir"
3016 } else {
3017 "local_file"
3018 }
3019}
3020
3021fn inferred_reference_id(raw: &str) -> String {
3022 let cleaned = raw
3023 .trim_end_matches('/')
3024 .rsplit('/')
3025 .next()
3026 .unwrap_or(raw)
3027 .split('@')
3028 .next()
3029 .unwrap_or(raw)
3030 .split(':')
3031 .next()
3032 .unwrap_or(raw)
3033 .trim_end_matches(".json")
3034 .trim_end_matches(".gtpack")
3035 .trim_end_matches(".yaml")
3036 .trim_end_matches(".yml");
3037 normalize_bundle_id(cleaned)
3038}
3039
3040fn inferred_display_name(raw: &str) -> String {
3041 inferred_reference_id(raw)
3042 .split('-')
3043 .filter(|part| !part.is_empty())
3044 .map(|part| {
3045 let mut chars = part.chars();
3046 match chars.next() {
3047 Some(first) => format!("{}{}", first.to_ascii_uppercase(), chars.as_str()),
3048 None => String::new(),
3049 }
3050 })
3051 .collect::<Vec<_>>()
3052 .join(" ")
3053}
3054
3055fn inferred_reference_version(raw: &str) -> Option<String> {
3056 raw.split('@').nth(1).map(ToOwned::to_owned).or_else(|| {
3057 raw.rsplit_once(':')
3058 .and_then(|(_, version)| (!version.contains('/')).then(|| version.to_string()))
3059 })
3060}
3061
3062fn load_and_normalize_answers(
3063 path: &Path,
3064 mode_override: Option<WizardMode>,
3065 schema_version: Option<&str>,
3066 migrate: bool,
3067 locale: &str,
3068) -> Result<LoadedRequest> {
3069 let raw = fs::read_to_string(path)
3070 .with_context(|| format!("failed to read answers file {}", path.display()))?;
3071 let value: Value = serde_json::from_str(&raw).map_err(|_| {
3072 anyhow::anyhow!(crate::i18n::trf(
3073 "errors.answer_document.invalid_json",
3074 &[("path", &path.display().to_string())],
3075 ))
3076 })?;
3077 let document = parse_answer_document(value, schema_version, migrate, locale)?;
3078 let locks = document.locks.clone();
3079 let build_bundle_now = answer_document_requests_bundle_build(&document);
3080 let request = normalized_request_from_document(document, mode_override)?;
3081 Ok(LoadedRequest {
3082 request,
3083 locks,
3084 build_bundle_now,
3085 })
3086}
3087
3088fn answer_document_requests_bundle_build(document: &AnswerDocument) -> bool {
3089 matches!(
3090 document.locks.get("execution").and_then(Value::as_str),
3091 Some("execute")
3092 )
3093}
3094
3095fn parse_answer_document(
3096 value: Value,
3097 schema_version: Option<&str>,
3098 migrate: bool,
3099 locale: &str,
3100) -> Result<AnswerDocument> {
3101 let object = value
3102 .as_object()
3103 .cloned()
3104 .ok_or_else(|| anyhow::anyhow!(crate::i18n::tr("errors.answer_document.invalid_root")))?;
3105
3106 let has_metadata = object.contains_key("wizard_id")
3107 || object.contains_key("schema_id")
3108 || object.contains_key("schema_version")
3109 || object.contains_key("locale");
3110
3111 let document = if has_metadata {
3112 let document: AnswerDocument =
3113 serde_json::from_value(Value::Object(object)).map_err(|_| {
3114 anyhow::anyhow!(crate::i18n::tr("errors.answer_document.invalid_document"))
3115 })?;
3116 document.validate()?;
3117 document
3118 } else if migrate {
3119 let mut document = AnswerDocument::new(locale);
3120 if let Some(Value::Object(answers)) = object.get("answers") {
3121 document.answers = answers
3122 .iter()
3123 .map(|(key, value)| (key.clone(), value.clone()))
3124 .collect();
3125 } else {
3126 document.answers = object
3127 .iter()
3128 .filter(|(key, _)| key.as_str() != "locks")
3129 .map(|(key, value)| (key.clone(), value.clone()))
3130 .collect();
3131 }
3132 if let Some(Value::Object(locks)) = object.get("locks") {
3133 document.locks = locks
3134 .iter()
3135 .map(|(key, value)| (key.clone(), value.clone()))
3136 .collect();
3137 }
3138 document
3139 } else {
3140 bail!(
3141 "{}",
3142 crate::i18n::tr("errors.answer_document.metadata_missing")
3143 );
3144 };
3145
3146 if document.schema_id != ANSWER_SCHEMA_ID {
3147 bail!(
3148 "{}",
3149 crate::i18n::tr("errors.answer_document.schema_id_mismatch")
3150 );
3151 }
3152
3153 let target_version = requested_schema_version(schema_version)?;
3154 let migrated = migrate_document(document, &target_version)?;
3155 if migrated.migrated && !migrate {
3156 bail!(
3157 "{}",
3158 crate::i18n::tr("errors.answer_document.migrate_required")
3159 );
3160 }
3161 Ok(migrated.document)
3162}
3163
3164fn normalized_request_from_document(
3165 document: AnswerDocument,
3166 mode_override: Option<WizardMode>,
3167) -> Result<NormalizedRequest> {
3168 let answers = normalized_answers_from_document(&document.answers)?;
3169 let mode = match mode_override {
3170 Some(mode) => mode,
3171 None => mode_from_answers(&answers)?,
3172 };
3173 let bundle_name = required_string(&answers, "bundle_name")?;
3174 let bundle_id = required_string(&answers, "bundle_id")?;
3175 let normalized_bundle_id = normalize_bundle_id(&bundle_id);
3176 let output_dir = answers
3177 .get("output_dir")
3178 .and_then(Value::as_str)
3179 .map(str::trim)
3180 .filter(|value| !value.is_empty())
3181 .map(PathBuf::from)
3182 .unwrap_or_else(|| default_bundle_output_dir(&normalized_bundle_id));
3183 let request = normalize_request(SeedRequest {
3184 mode,
3185 locale: document.locale,
3186 bundle_name,
3187 bundle_id,
3188 output_dir,
3189 app_pack_entries: optional_app_pack_entries(&answers, "app_pack_entries")?,
3190 access_rules: optional_access_rules(&answers, "access_rules")?,
3191 extension_provider_entries: optional_extension_provider_entries(
3192 &answers,
3193 "extension_provider_entries",
3194 )?,
3195 advanced_setup: optional_bool(&answers, "advanced_setup")?,
3196 app_packs: optional_string_list(&answers, "app_packs")?,
3197 extension_providers: optional_string_list(&answers, "extension_providers")?,
3198 remote_catalogs: optional_string_list(&answers, "remote_catalogs")?,
3199 setup_specs: optional_object_map(&answers, "setup_specs")?,
3200 setup_answers: optional_object_map(&answers, "setup_answers")?,
3201 setup_execution_intent: optional_bool(&answers, "setup_execution_intent")?,
3202 export_intent: optional_bool(&answers, "export_intent")?,
3203 capabilities: optional_string_list(&answers, "capabilities")?,
3204 });
3205 validate_normalized_answer_request(&request)?;
3206 Ok(request)
3207}
3208
3209fn normalized_answers_from_document(
3210 answers: &BTreeMap<String, Value>,
3211) -> Result<BTreeMap<String, Value>> {
3212 let mut normalized = answers.clone();
3213
3214 if let Some(Value::Object(bundle)) = answers.get("bundle") {
3215 copy_nested_string(bundle, "name", &mut normalized, "bundle_name")?;
3216 copy_nested_string(bundle, "id", &mut normalized, "bundle_id")?;
3217 copy_nested_string(bundle, "output_dir", &mut normalized, "output_dir")?;
3218 }
3219
3220 if !normalized.contains_key("mode")
3221 && let Some(Value::String(action)) = answers.get("selected_action")
3222 {
3223 if action == "bundle" {
3224 normalized.insert("mode".to_string(), Value::String("create".to_string()));
3225 } else {
3226 return Err(invalid_answer_field("selected_action"));
3227 }
3228 }
3229
3230 if !normalized.contains_key("app_packs")
3231 && !normalized.contains_key("app_pack_entries")
3232 && let Some(Value::Array(apps)) = answers.get("apps")
3233 {
3234 normalized.insert(
3235 "app_packs".to_string(),
3236 Value::Array(
3237 apps.iter()
3238 .map(app_reference_from_launcher_entry)
3239 .collect::<Result<Vec<_>>>()?
3240 .into_iter()
3241 .map(Value::String)
3242 .collect(),
3243 ),
3244 );
3245 }
3246
3247 if !normalized.contains_key("extension_providers")
3248 && !normalized.contains_key("extension_provider_entries")
3249 && let Some(Value::Object(providers)) = answers.get("providers")
3250 {
3251 let mut refs = Vec::new();
3252 for key in ["messaging", "events"] {
3253 if let Some(value) = providers.get(key) {
3254 refs.extend(string_list_from_launcher_value(value, "providers")?);
3255 }
3256 }
3257 normalized.insert(
3258 "extension_providers".to_string(),
3259 Value::Array(refs.into_iter().map(Value::String).collect()),
3260 );
3261 }
3262
3263 Ok(normalized)
3264}
3265
3266fn copy_nested_string(
3267 object: &Map<String, Value>,
3268 source_key: &str,
3269 normalized: &mut BTreeMap<String, Value>,
3270 target_key: &str,
3271) -> Result<()> {
3272 if normalized.contains_key(target_key) {
3273 return Ok(());
3274 }
3275 match object.get(source_key) {
3276 None => Ok(()),
3277 Some(Value::String(value)) => {
3278 normalized.insert(target_key.to_string(), Value::String(value.clone()));
3279 Ok(())
3280 }
3281 Some(_) => Err(invalid_answer_field(target_key)),
3282 }
3283}
3284
3285fn app_reference_from_launcher_entry(value: &Value) -> Result<String> {
3286 let object = value
3287 .as_object()
3288 .ok_or_else(|| invalid_answer_field("apps"))?;
3289 object
3290 .get("source")
3291 .and_then(Value::as_str)
3292 .filter(|value| !value.trim().is_empty())
3293 .map(ToOwned::to_owned)
3294 .ok_or_else(|| invalid_answer_field("apps"))
3295}
3296
3297fn string_list_from_launcher_value(value: &Value, key: &str) -> Result<Vec<String>> {
3298 match value {
3299 Value::Array(entries) => entries
3300 .iter()
3301 .map(|entry| {
3302 entry
3303 .as_str()
3304 .filter(|value| !value.trim().is_empty())
3305 .map(ToOwned::to_owned)
3306 .ok_or_else(|| invalid_answer_field(key))
3307 })
3308 .collect(),
3309 _ => Err(invalid_answer_field(key)),
3310 }
3311}
3312
3313#[allow(dead_code)]
3314fn normalized_request_from_qa_answers(
3315 answers: Value,
3316 locale: String,
3317 mode: WizardMode,
3318) -> Result<NormalizedRequest> {
3319 let object = answers
3320 .as_object()
3321 .ok_or_else(|| anyhow::anyhow!("wizard answers must be a JSON object"))?;
3322 let bundle_name = object
3323 .get("bundle_name")
3324 .and_then(Value::as_str)
3325 .ok_or_else(|| anyhow::anyhow!("wizard answer missing bundle_name"))?
3326 .to_string();
3327 let bundle_id = normalize_bundle_id(
3328 object
3329 .get("bundle_id")
3330 .and_then(Value::as_str)
3331 .ok_or_else(|| anyhow::anyhow!("wizard answer missing bundle_id"))?,
3332 );
3333 let output_dir = object
3334 .get("output_dir")
3335 .and_then(Value::as_str)
3336 .map(str::trim)
3337 .filter(|value| !value.is_empty())
3338 .map(PathBuf::from)
3339 .unwrap_or_else(|| default_bundle_output_dir(&bundle_id));
3340
3341 Ok(normalize_request(SeedRequest {
3342 mode,
3343 locale,
3344 bundle_name,
3345 bundle_id,
3346 output_dir,
3347 app_pack_entries: Vec::new(),
3348 access_rules: Vec::new(),
3349 extension_provider_entries: Vec::new(),
3350 advanced_setup: object
3351 .get("advanced_setup")
3352 .and_then(Value::as_bool)
3353 .unwrap_or(false),
3354 app_packs: parse_csv_answers(
3355 object
3356 .get("app_packs")
3357 .and_then(Value::as_str)
3358 .unwrap_or_default(),
3359 ),
3360 extension_providers: parse_csv_answers(
3361 object
3362 .get("extension_providers")
3363 .and_then(Value::as_str)
3364 .unwrap_or_default(),
3365 ),
3366 remote_catalogs: parse_csv_answers(
3367 object
3368 .get("remote_catalogs")
3369 .and_then(Value::as_str)
3370 .unwrap_or_default(),
3371 ),
3372 setup_specs: BTreeMap::new(),
3373 setup_answers: BTreeMap::new(),
3374 setup_execution_intent: object
3375 .get("setup_execution_intent")
3376 .and_then(Value::as_bool)
3377 .unwrap_or(false),
3378 export_intent: object
3379 .get("export_intent")
3380 .and_then(Value::as_bool)
3381 .unwrap_or(false),
3382 capabilities: object
3383 .get("capabilities")
3384 .and_then(Value::as_array)
3385 .map(|arr| {
3386 arr.iter()
3387 .filter_map(Value::as_str)
3388 .map(ToOwned::to_owned)
3389 .collect()
3390 })
3391 .unwrap_or_default(),
3392 }))
3393}
3394
3395fn mode_from_answers(answers: &BTreeMap<String, Value>) -> Result<WizardMode> {
3396 match answers.get("mode") {
3397 None => Ok(WizardMode::Create),
3398 Some(Value::String(value)) => match value.to_ascii_lowercase().as_str() {
3399 "create" => Ok(WizardMode::Create),
3400 "update" => Ok(WizardMode::Update),
3401 "doctor" => Ok(WizardMode::Doctor),
3402 _ => Err(invalid_answer_field("mode")),
3403 },
3404 Some(_) => Err(invalid_answer_field("mode")),
3405 }
3406}
3407
3408fn required_string(answers: &BTreeMap<String, Value>, key: &str) -> Result<String> {
3409 let value = answers.get(key).ok_or_else(|| missing_answer_field(key))?;
3410 let text = value.as_str().ok_or_else(|| invalid_answer_field(key))?;
3411 if text.trim().is_empty() {
3412 return Err(invalid_answer_field(key));
3413 }
3414 Ok(text.to_string())
3415}
3416
3417fn optional_bool(answers: &BTreeMap<String, Value>, key: &str) -> Result<bool> {
3418 match answers.get(key) {
3419 None => Ok(false),
3420 Some(Value::Bool(value)) => Ok(*value),
3421 Some(_) => Err(invalid_answer_field(key)),
3422 }
3423}
3424
3425fn optional_string_list(answers: &BTreeMap<String, Value>, key: &str) -> Result<Vec<String>> {
3426 match answers.get(key) {
3427 None => Ok(Vec::new()),
3428 Some(Value::Array(entries)) => entries
3429 .iter()
3430 .map(|entry| {
3431 entry
3432 .as_str()
3433 .map(ToOwned::to_owned)
3434 .ok_or_else(|| invalid_answer_field(key))
3435 })
3436 .collect(),
3437 Some(_) => Err(invalid_answer_field(key)),
3438 }
3439}
3440
3441fn optional_object_map(
3442 answers: &BTreeMap<String, Value>,
3443 key: &str,
3444) -> Result<BTreeMap<String, Value>> {
3445 match answers.get(key) {
3446 None => Ok(BTreeMap::new()),
3447 Some(Value::Object(entries)) => Ok(entries
3448 .iter()
3449 .map(|(entry_key, entry_value)| (entry_key.clone(), entry_value.clone()))
3450 .collect()),
3451 Some(_) => Err(invalid_answer_field(key)),
3452 }
3453}
3454
3455fn optional_app_pack_entries(
3456 answers: &BTreeMap<String, Value>,
3457 key: &str,
3458) -> Result<Vec<AppPackEntry>> {
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_access_rules(
3468 answers: &BTreeMap<String, Value>,
3469 key: &str,
3470) -> Result<Vec<AccessRuleInput>> {
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 optional_extension_provider_entries(
3480 answers: &BTreeMap<String, Value>,
3481 key: &str,
3482) -> Result<Vec<ExtensionProviderEntry>> {
3483 answers
3484 .get(key)
3485 .cloned()
3486 .map(|value| serde_json::from_value(value).map_err(|_| invalid_answer_field(key)))
3487 .transpose()
3488 .map(|value| value.unwrap_or_default())
3489}
3490
3491fn missing_answer_field(key: &str) -> anyhow::Error {
3492 anyhow::anyhow!(crate::i18n::trf(
3493 "errors.answer_document.answer_missing",
3494 &[("field", key)],
3495 ))
3496}
3497
3498fn invalid_answer_field(key: &str) -> anyhow::Error {
3499 anyhow::anyhow!(crate::i18n::trf(
3500 "errors.answer_document.answer_invalid",
3501 &[("field", key)],
3502 ))
3503}
3504
3505fn validate_normalized_answer_request(request: &NormalizedRequest) -> Result<()> {
3506 if request.bundle_name.trim().is_empty() {
3507 bail!(
3508 "{}",
3509 crate::i18n::trf(
3510 "errors.answer_document.answer_invalid",
3511 &[("field", "bundle_name")]
3512 )
3513 );
3514 }
3515 if request.bundle_id.trim().is_empty() {
3516 bail!(
3517 "{}",
3518 crate::i18n::trf(
3519 "errors.answer_document.answer_invalid",
3520 &[("field", "bundle_id")]
3521 )
3522 );
3523 }
3524 Ok(())
3525}
3526
3527fn requested_schema_version(schema_version: Option<&str>) -> Result<Version> {
3528 let raw = schema_version.unwrap_or("1.0.0");
3529 Version::parse(raw).with_context(|| format!("invalid schema version {raw}"))
3530}
3531
3532fn answer_document_from_request(
3533 request: &NormalizedRequest,
3534 schema_version: Option<&str>,
3535) -> Result<AnswerDocument> {
3536 let mut document = AnswerDocument::new(&request.locale);
3537 document.schema_version = requested_schema_version(schema_version)?;
3538 document.answers = BTreeMap::from([
3539 (
3540 "mode".to_string(),
3541 Value::String(mode_name(request.mode).to_string()),
3542 ),
3543 (
3544 "bundle_name".to_string(),
3545 Value::String(request.bundle_name.clone()),
3546 ),
3547 (
3548 "bundle_id".to_string(),
3549 Value::String(request.bundle_id.clone()),
3550 ),
3551 (
3552 "output_dir".to_string(),
3553 Value::String(request.output_dir.display().to_string()),
3554 ),
3555 (
3556 "advanced_setup".to_string(),
3557 Value::Bool(request.advanced_setup),
3558 ),
3559 (
3560 "app_pack_entries".to_string(),
3561 serde_json::to_value(&request.app_pack_entries)?,
3562 ),
3563 (
3564 "app_packs".to_string(),
3565 Value::Array(
3566 request
3567 .app_packs
3568 .iter()
3569 .cloned()
3570 .map(Value::String)
3571 .collect(),
3572 ),
3573 ),
3574 (
3575 "extension_providers".to_string(),
3576 Value::Array(
3577 request
3578 .extension_providers
3579 .iter()
3580 .cloned()
3581 .map(Value::String)
3582 .collect(),
3583 ),
3584 ),
3585 (
3586 "extension_provider_entries".to_string(),
3587 serde_json::to_value(&request.extension_provider_entries)?,
3588 ),
3589 (
3590 "remote_catalogs".to_string(),
3591 Value::Array(
3592 request
3593 .remote_catalogs
3594 .iter()
3595 .cloned()
3596 .map(Value::String)
3597 .collect(),
3598 ),
3599 ),
3600 (
3601 "setup_execution_intent".to_string(),
3602 Value::Bool(request.setup_execution_intent),
3603 ),
3604 (
3605 "setup_specs".to_string(),
3606 Value::Object(request.setup_specs.clone().into_iter().collect()),
3607 ),
3608 (
3609 "access_rules".to_string(),
3610 serde_json::to_value(&request.access_rules)?,
3611 ),
3612 (
3613 "setup_answers".to_string(),
3614 Value::Object(request.setup_answers.clone().into_iter().collect()),
3615 ),
3616 (
3617 "export_intent".to_string(),
3618 Value::Bool(request.export_intent),
3619 ),
3620 (
3621 "capabilities".to_string(),
3622 Value::Array(
3623 request
3624 .capabilities
3625 .iter()
3626 .cloned()
3627 .map(Value::String)
3628 .collect(),
3629 ),
3630 ),
3631 ]);
3632 Ok(document)
3633}
3634
3635pub fn build_plan(
3636 request: &NormalizedRequest,
3637 execution: ExecutionMode,
3638 build_bundle_now: bool,
3639 schema_version: &Version,
3640 cache_writes: &[String],
3641 setup_writes: &[String],
3642) -> WizardPlanEnvelope {
3643 let mut expected_file_writes = vec![
3644 request
3645 .output_dir
3646 .join(crate::project::WORKSPACE_ROOT_FILE)
3647 .display()
3648 .to_string(),
3649 request
3650 .output_dir
3651 .join("tenants/default/tenant.gmap")
3652 .display()
3653 .to_string(),
3654 request
3655 .output_dir
3656 .join(crate::project::LOCK_FILE)
3657 .display()
3658 .to_string(),
3659 ];
3660 expected_file_writes.extend(
3661 cache_writes
3662 .iter()
3663 .map(|path| request.output_dir.join(path).display().to_string()),
3664 );
3665 expected_file_writes.extend(
3666 setup_writes
3667 .iter()
3668 .map(|path| request.output_dir.join(path).display().to_string()),
3669 );
3670 if build_bundle_now && execution == ExecutionMode::Execute {
3671 expected_file_writes.push(
3672 crate::build::default_artifact_path(&request.output_dir, &request.bundle_id)
3673 .display()
3674 .to_string(),
3675 );
3676 }
3677 expected_file_writes.sort();
3678 expected_file_writes.dedup();
3679 let mut warnings = Vec::new();
3680 if request.advanced_setup
3681 && request.app_packs.is_empty()
3682 && request.extension_providers.is_empty()
3683 {
3684 warnings.push(crate::i18n::tr("wizard.warning.advanced_without_refs"));
3685 }
3686
3687 WizardPlanEnvelope {
3688 metadata: PlanMetadata {
3689 wizard_id: WIZARD_ID.to_string(),
3690 schema_id: ANSWER_SCHEMA_ID.to_string(),
3691 schema_version: schema_version.to_string(),
3692 locale: request.locale.clone(),
3693 execution,
3694 },
3695 target_root: request.output_dir.display().to_string(),
3696 requested_action: mode_name(request.mode).to_string(),
3697 normalized_input_summary: normalized_summary(request),
3698 ordered_step_list: plan_steps(request, build_bundle_now),
3699 expected_file_writes,
3700 warnings,
3701 }
3702}
3703
3704fn normalized_summary(request: &NormalizedRequest) -> BTreeMap<String, Value> {
3705 BTreeMap::from([
3706 (
3707 "mode".to_string(),
3708 Value::String(mode_name(request.mode).to_string()),
3709 ),
3710 (
3711 "bundle_name".to_string(),
3712 Value::String(request.bundle_name.clone()),
3713 ),
3714 (
3715 "bundle_id".to_string(),
3716 Value::String(request.bundle_id.clone()),
3717 ),
3718 (
3719 "output_dir".to_string(),
3720 Value::String(request.output_dir.display().to_string()),
3721 ),
3722 (
3723 "advanced_setup".to_string(),
3724 Value::Bool(request.advanced_setup),
3725 ),
3726 (
3727 "app_pack_entries".to_string(),
3728 serde_json::to_value(&request.app_pack_entries).unwrap_or(Value::Null),
3729 ),
3730 (
3731 "app_packs".to_string(),
3732 Value::Array(
3733 request
3734 .app_packs
3735 .iter()
3736 .cloned()
3737 .map(Value::String)
3738 .collect(),
3739 ),
3740 ),
3741 (
3742 "extension_providers".to_string(),
3743 Value::Array(
3744 request
3745 .extension_providers
3746 .iter()
3747 .cloned()
3748 .map(Value::String)
3749 .collect(),
3750 ),
3751 ),
3752 (
3753 "extension_provider_entries".to_string(),
3754 serde_json::to_value(&request.extension_provider_entries).unwrap_or(Value::Null),
3755 ),
3756 (
3757 "remote_catalogs".to_string(),
3758 Value::Array(
3759 request
3760 .remote_catalogs
3761 .iter()
3762 .cloned()
3763 .map(Value::String)
3764 .collect(),
3765 ),
3766 ),
3767 (
3768 "setup_execution_intent".to_string(),
3769 Value::Bool(request.setup_execution_intent),
3770 ),
3771 (
3772 "access_rules".to_string(),
3773 serde_json::to_value(&request.access_rules).unwrap_or(Value::Null),
3774 ),
3775 (
3776 "setup_spec_providers".to_string(),
3777 Value::Array(
3778 request
3779 .setup_specs
3780 .keys()
3781 .cloned()
3782 .map(Value::String)
3783 .collect(),
3784 ),
3785 ),
3786 (
3787 "export_intent".to_string(),
3788 Value::Bool(request.export_intent),
3789 ),
3790 ])
3791}
3792
3793fn plan_steps(request: &NormalizedRequest, build_bundle_now: bool) -> Vec<WizardPlanStep> {
3794 let mut steps = vec![
3795 WizardPlanStep {
3796 kind: StepKind::EnsureWorkspace,
3797 description: crate::i18n::tr("wizard.plan.ensure_workspace"),
3798 },
3799 WizardPlanStep {
3800 kind: StepKind::WriteBundleFile,
3801 description: crate::i18n::tr("wizard.plan.write_bundle_file"),
3802 },
3803 WizardPlanStep {
3804 kind: StepKind::UpdateAccessRules,
3805 description: crate::i18n::tr("wizard.plan.update_access_rules"),
3806 },
3807 WizardPlanStep {
3808 kind: StepKind::ResolveRefs,
3809 description: crate::i18n::tr("wizard.plan.resolve_refs"),
3810 },
3811 WizardPlanStep {
3812 kind: StepKind::WriteLock,
3813 description: crate::i18n::tr("wizard.plan.write_lock"),
3814 },
3815 ];
3816 if build_bundle_now || matches!(request.mode, WizardMode::Doctor) {
3817 steps.push(WizardPlanStep {
3818 kind: StepKind::BuildBundle,
3819 description: crate::i18n::tr("wizard.plan.build_bundle"),
3820 });
3821 }
3822 if request.export_intent {
3823 steps.push(WizardPlanStep {
3824 kind: StepKind::ExportBundle,
3825 description: crate::i18n::tr("wizard.plan.export_bundle"),
3826 });
3827 }
3828 steps
3829}
3830
3831fn apply_plan(
3832 request: &NormalizedRequest,
3833 bundle_lock: &crate::project::BundleLock,
3834) -> Result<Vec<PathBuf>> {
3835 fs::create_dir_all(&request.output_dir)
3836 .with_context(|| format!("create output dir {}", request.output_dir.display()))?;
3837 let bundle_yaml = request.output_dir.join(crate::project::WORKSPACE_ROOT_FILE);
3838 let tenant_gmap = request.output_dir.join("tenants/default/tenant.gmap");
3839 let lock_file = request.output_dir.join(crate::project::LOCK_FILE);
3840
3841 let workspace = workspace_definition_from_request(request);
3842 let mut writes = crate::project::init_bundle_workspace(&request.output_dir, &workspace)?;
3843
3844 for entry in &request.app_pack_entries {
3845 if let Some(tenant) = &entry.mapping.tenant {
3846 if let Some(team) = &entry.mapping.team {
3847 crate::project::ensure_team(&request.output_dir, tenant, team)?;
3848 } else {
3849 crate::project::ensure_tenant(&request.output_dir, tenant)?;
3850 }
3851 }
3852 }
3853
3854 for rule in &request.access_rules {
3855 let preview = crate::access::mutate_access(
3856 &request.output_dir,
3857 &crate::access::GmapTarget {
3858 tenant: rule.tenant.clone(),
3859 team: rule.team.clone(),
3860 },
3861 &crate::access::GmapMutation {
3862 rule_path: rule.rule_path.clone(),
3863 policy: match rule.policy.as_str() {
3864 "forbidden" => crate::access::Policy::Forbidden,
3865 _ => crate::access::Policy::Public,
3866 },
3867 },
3868 false,
3869 )?;
3870 writes.extend(
3871 preview
3872 .writes
3873 .into_iter()
3874 .map(|path| request.output_dir.join(path)),
3875 );
3876 }
3877
3878 let setup_result = persist_setup_state(request, ExecutionMode::Execute)?;
3879 crate::project::write_bundle_lock(&request.output_dir, bundle_lock)
3880 .with_context(|| format!("write {}", lock_file.display()))?;
3881 crate::project::sync_project(&request.output_dir)?;
3882
3883 if request
3884 .capabilities
3885 .iter()
3886 .any(|c| c == crate::project::CAP_BUNDLE_ASSETS_READ_V1)
3887 {
3888 let scaffolded = crate::project::scaffold_assets_from_packs(&request.output_dir)?;
3889 writes.extend(scaffolded);
3890 }
3891
3892 writes.push(bundle_yaml);
3893 writes.push(tenant_gmap);
3894 writes.push(lock_file);
3895 writes.extend(
3896 setup_result
3897 .writes
3898 .into_iter()
3899 .map(|path| request.output_dir.join(path)),
3900 );
3901 writes.sort();
3902 writes.dedup();
3903 Ok(writes)
3904}
3905
3906fn workspace_definition_from_request(
3907 request: &NormalizedRequest,
3908) -> crate::project::BundleWorkspaceDefinition {
3909 let mut workspace = crate::project::BundleWorkspaceDefinition::new(
3910 request.bundle_name.clone(),
3911 request.bundle_id.clone(),
3912 request.locale.clone(),
3913 mode_name(request.mode).to_string(),
3914 );
3915 workspace.advanced_setup = request.advanced_setup;
3916 workspace.app_pack_mappings = request
3917 .app_pack_entries
3918 .iter()
3919 .map(|entry| crate::project::AppPackMapping {
3920 reference: entry.reference.clone(),
3921 scope: match entry.mapping.scope.as_str() {
3922 "tenant" => crate::project::MappingScope::Tenant,
3923 "tenant_team" => crate::project::MappingScope::Team,
3924 _ => crate::project::MappingScope::Global,
3925 },
3926 tenant: entry.mapping.tenant.clone(),
3927 team: entry.mapping.team.clone(),
3928 })
3929 .collect();
3930 workspace.app_packs = request.app_packs.clone();
3931 workspace.extension_providers = request.extension_providers.clone();
3932 workspace.remote_catalogs = request.remote_catalogs.clone();
3933 workspace.capabilities = request.capabilities.clone();
3934 workspace.setup_execution_intent = false;
3935 workspace.export_intent = false;
3936 workspace.canonicalize();
3937 workspace
3938}
3939
3940fn write_answer_document(path: &Path, document: &AnswerDocument) -> Result<()> {
3941 if let Some(parent) = path.parent()
3942 && !parent.as_os_str().is_empty()
3943 {
3944 fs::create_dir_all(parent)
3945 .with_context(|| format!("create answers parent {}", parent.display()))?;
3946 }
3947 fs::write(path, document.to_pretty_json_string()?)
3948 .with_context(|| format!("write answers file {}", path.display()))
3949}
3950
3951fn normalize_bundle_id(raw: &str) -> String {
3952 let normalized = raw
3953 .trim()
3954 .to_ascii_lowercase()
3955 .chars()
3956 .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
3957 .collect::<String>();
3958 normalized.trim_matches('-').to_string()
3959}
3960
3961fn normalize_output_dir(path: PathBuf) -> PathBuf {
3962 if path.as_os_str().is_empty() {
3963 PathBuf::from(".")
3964 } else {
3965 path
3966 }
3967}
3968
3969fn default_bundle_output_dir(bundle_id: &str) -> PathBuf {
3970 let normalized = normalize_bundle_id(bundle_id);
3971 if normalized.is_empty() {
3972 PathBuf::from("./bundle")
3973 } else {
3974 PathBuf::from(format!("./{normalized}-bundle"))
3975 }
3976}
3977
3978fn sorted_unique(entries: Vec<String>) -> Vec<String> {
3979 let mut entries = entries
3980 .into_iter()
3981 .filter(|entry| !entry.trim().is_empty())
3982 .collect::<Vec<_>>();
3983 entries.sort();
3984 entries.dedup();
3985 entries
3986}
3987
3988fn mode_name(mode: WizardMode) -> &'static str {
3989 match mode {
3990 WizardMode::Create => "create",
3991 WizardMode::Update => "update",
3992 WizardMode::Doctor => "doctor",
3993 }
3994}
3995
3996pub fn print_plan(plan: &WizardPlanEnvelope) -> Result<()> {
3997 println!("{}", serde_json::to_string_pretty(plan)?);
3998 Ok(())
3999}
4000
4001fn build_bundle_lock(
4002 request: &NormalizedRequest,
4003 execution: ExecutionMode,
4004 catalog_resolution: &crate::catalog::resolve::CatalogResolution,
4005 setup_writes: &[String],
4006) -> crate::project::BundleLock {
4007 crate::project::BundleLock {
4008 schema_version: crate::project::LOCK_SCHEMA_VERSION,
4009 bundle_id: request.bundle_id.clone(),
4010 requested_mode: mode_name(request.mode).to_string(),
4011 execution: match execution {
4012 ExecutionMode::DryRun => "dry_run",
4013 ExecutionMode::Execute => "execute",
4014 }
4015 .to_string(),
4016 cache_policy: crate::catalog::DEFAULT_CACHE_POLICY.to_string(),
4017 tool_version: env!("CARGO_PKG_VERSION").to_string(),
4018 build_format_version: "bundle-lock-v1".to_string(),
4019 workspace_root: crate::project::WORKSPACE_ROOT_FILE.to_string(),
4020 lock_file: crate::project::LOCK_FILE.to_string(),
4021 catalogs: catalog_resolution.entries.clone(),
4022 app_packs: request
4023 .app_packs
4024 .iter()
4025 .cloned()
4026 .map(|reference| crate::project::DependencyLock {
4027 reference,
4028 digest: None,
4029 })
4030 .collect(),
4031 extension_providers: request
4032 .extension_providers
4033 .iter()
4034 .cloned()
4035 .map(|reference| crate::project::DependencyLock {
4036 reference,
4037 digest: None,
4038 })
4039 .collect(),
4040 setup_state_files: setup_writes.to_vec(),
4041 }
4042}
4043
4044fn bundle_lock_to_answer_locks(lock: &crate::project::BundleLock) -> BTreeMap<String, Value> {
4045 let catalogs = lock
4046 .catalogs
4047 .iter()
4048 .map(|entry| {
4049 serde_json::json!({
4050 "requested_ref": entry.requested_ref,
4051 "resolved_ref": entry.resolved_ref,
4052 "digest": entry.digest,
4053 "source": entry.source,
4054 "item_count": entry.item_count,
4055 "item_ids": entry.item_ids,
4056 "cache_path": entry.cache_path,
4057 })
4058 })
4059 .collect::<Vec<_>>();
4060
4061 BTreeMap::from([
4062 (
4063 "cache_policy".to_string(),
4064 Value::String(lock.cache_policy.clone()),
4065 ),
4066 (
4067 "workspace_root".to_string(),
4068 Value::String(lock.workspace_root.clone()),
4069 ),
4070 (
4071 "lock_file".to_string(),
4072 Value::String(lock.lock_file.clone()),
4073 ),
4074 (
4075 "requested_mode".to_string(),
4076 Value::String(lock.requested_mode.clone()),
4077 ),
4078 (
4079 "execution".to_string(),
4080 Value::String(lock.execution.clone()),
4081 ),
4082 ("catalogs".to_string(), Value::Array(catalogs)),
4083 (
4084 "setup_state_files".to_string(),
4085 Value::Array(
4086 lock.setup_state_files
4087 .iter()
4088 .cloned()
4089 .map(Value::String)
4090 .collect(),
4091 ),
4092 ),
4093 ])
4094}
4095
4096fn preview_setup_writes(
4097 request: &NormalizedRequest,
4098 execution: ExecutionMode,
4099) -> Result<Vec<String>> {
4100 let _ = execution;
4101 let instructions = collect_setup_instructions(request)?;
4102 if instructions.is_empty() {
4103 return Ok(Vec::new());
4104 }
4105 Ok(crate::setup::persist::persist_setup(
4106 &request.output_dir,
4107 &instructions,
4108 &crate::setup::backend::NoopSetupBackend,
4109 )?
4110 .writes)
4111}
4112
4113fn persist_setup_state(
4114 request: &NormalizedRequest,
4115 execution: ExecutionMode,
4116) -> Result<crate::setup::persist::SetupPersistenceResult> {
4117 let instructions = collect_setup_instructions(request)?;
4118 if instructions.is_empty() {
4119 return Ok(crate::setup::persist::SetupPersistenceResult {
4120 states: Vec::new(),
4121 writes: Vec::new(),
4122 });
4123 }
4124
4125 let backend: Box<dyn crate::setup::backend::SetupBackend> = match execution {
4126 ExecutionMode::Execute => Box::new(crate::setup::backend::FileSetupBackend::new(
4127 &request.output_dir,
4128 )),
4129 ExecutionMode::DryRun => Box::new(crate::setup::backend::NoopSetupBackend),
4130 };
4131 crate::setup::persist::persist_setup(&request.output_dir, &instructions, backend.as_ref())
4132}
4133
4134fn collect_setup_instructions(
4135 request: &NormalizedRequest,
4136) -> Result<Vec<crate::setup::persist::SetupInstruction>> {
4137 if !request.setup_execution_intent {
4138 return Ok(Vec::new());
4139 }
4140 crate::setup::persist::collect_setup_instructions(&request.setup_specs, &request.setup_answers)
4141}
4142
4143#[allow(dead_code)]
4144fn collect_interactive_setup_answers<R: BufRead, W: Write>(
4145 input: &mut R,
4146 output: &mut W,
4147 request: NormalizedRequest,
4148 last_compact_title: &mut Option<String>,
4149) -> Result<NormalizedRequest> {
4150 if !request.setup_execution_intent {
4151 return Ok(request);
4152 }
4153
4154 let catalog_resolution = crate::catalog::resolve::resolve_catalogs(
4155 &request.output_dir,
4156 &request.remote_catalogs,
4157 &crate::catalog::resolve::CatalogResolveOptions {
4158 offline: crate::runtime::offline(),
4159 write_cache: false,
4160 },
4161 )?;
4162 let mut request = discover_setup_specs(request, &catalog_resolution);
4163 let provider_ids = request.setup_specs.keys().cloned().collect::<Vec<_>>();
4164 for provider_id in provider_ids {
4165 let needs_answers = request
4166 .setup_answers
4167 .get(&provider_id)
4168 .and_then(Value::as_object)
4169 .map(|answers| answers.is_empty())
4170 .unwrap_or(true);
4171 if !needs_answers {
4172 continue;
4173 }
4174
4175 let spec_input = request
4176 .setup_specs
4177 .get(&provider_id)
4178 .cloned()
4179 .ok_or_else(|| anyhow::anyhow!("missing setup spec for {provider_id}"))?;
4180 let parsed = serde_json::from_value::<crate::setup::SetupSpecInput>(spec_input)?;
4181 let (_, form) = crate::setup::form_spec_from_input(&parsed, &provider_id)?;
4182 let answers =
4183 prompt_setup_form_answers(input, output, &provider_id, &form, last_compact_title)?;
4184 request
4185 .setup_answers
4186 .insert(provider_id, Value::Object(answers.into_iter().collect()));
4187 }
4188
4189 Ok(request)
4190}
4191
4192#[allow(dead_code)]
4193fn prompt_setup_form_answers<R: BufRead, W: Write>(
4194 input: &mut R,
4195 output: &mut W,
4196 provider_id: &str,
4197 form: &crate::setup::FormSpec,
4198 last_compact_title: &mut Option<String>,
4199) -> Result<BTreeMap<String, Value>> {
4200 writeln!(
4201 output,
4202 "{} {} ({provider_id})",
4203 crate::i18n::tr("wizard.setup.form_prefix"),
4204 form.title
4205 )?;
4206 let spec_json = serde_json::to_string(&qa_form_spec_from_setup_form(form)?)?;
4207 let config = WizardRunConfig {
4208 spec_json,
4209 initial_answers_json: None,
4210 frontend: WizardFrontend::Text,
4211 i18n: I18nConfig {
4212 locale: Some(crate::i18n::current_locale()),
4213 resolved: None,
4214 debug: false,
4215 },
4216 verbose: false,
4217 };
4218 let mut driver =
4219 WizardDriver::new(config).context("initialize greentic-qa-lib setup wizard")?;
4220 loop {
4221 let payload_raw = driver
4222 .next_payload_json()
4223 .context("render greentic-qa-lib setup payload")?;
4224 let payload: Value =
4225 serde_json::from_str(&payload_raw).context("parse greentic-qa-lib setup payload")?;
4226
4227 if let Some(text) = payload.get("text").and_then(Value::as_str) {
4228 render_qa_driver_text(output, text, last_compact_title)?;
4229 }
4230
4231 if driver.is_complete() {
4232 break;
4233 }
4234
4235 let ui_raw = driver
4236 .last_ui_json()
4237 .ok_or_else(|| anyhow::anyhow!("greentic-qa-lib setup payload missing UI state"))?;
4238 let ui: Value = serde_json::from_str(ui_raw).context("parse greentic-qa-lib UI payload")?;
4239 let question_id = ui
4240 .get("next_question_id")
4241 .and_then(Value::as_str)
4242 .ok_or_else(|| anyhow::anyhow!("greentic-qa-lib UI payload missing next_question_id"))?
4243 .to_string();
4244 let question = ui
4245 .get("questions")
4246 .and_then(Value::as_array)
4247 .and_then(|questions| {
4248 questions.iter().find(|question| {
4249 question.get("id").and_then(Value::as_str) == Some(question_id.as_str())
4250 })
4251 })
4252 .ok_or_else(|| {
4253 anyhow::anyhow!("greentic-qa-lib UI payload missing question {question_id}")
4254 })?;
4255
4256 let answer = prompt_qa_question_answer(input, output, &question_id, question)?;
4257 driver
4258 .submit_patch_json(&json!({ question_id: answer }).to_string())
4259 .context("submit greentic-qa-lib setup answer")?;
4260 }
4261
4262 let result = driver
4263 .finish()
4264 .context("finish greentic-qa-lib setup wizard")?;
4265 let answers = result
4266 .answer_set
4267 .answers
4268 .as_object()
4269 .cloned()
4270 .unwrap_or_else(Map::new);
4271 Ok(answers.into_iter().collect())
4272}
4273
4274#[allow(dead_code)]
4275fn qa_form_spec_from_setup_form(form: &crate::setup::FormSpec) -> Result<Value> {
4276 let questions = form
4277 .questions
4278 .iter()
4279 .map(|question| {
4280 let mut value = json!({
4281 "id": question.id,
4282 "type": qa_question_type_name(question.kind),
4283 "title": question.title,
4284 "required": question.required,
4285 "secret": question.secret,
4286 });
4287 if let Some(description) = &question.description {
4288 value["description"] = Value::String(description.clone());
4289 }
4290 if !question.choices.is_empty() {
4291 value["choices"] = Value::Array(
4292 question
4293 .choices
4294 .iter()
4295 .cloned()
4296 .map(Value::String)
4297 .collect(),
4298 );
4299 }
4300 if let Some(default) = &question.default_value
4301 && let Some(default_value) = qa_default_value(default)
4302 {
4303 value["default_value"] = Value::String(default_value);
4304 }
4305 value
4306 })
4307 .collect::<Vec<_>>();
4308
4309 Ok(json!({
4310 "id": form.id,
4311 "title": form.title,
4312 "version": form.version,
4313 "description": form.description,
4314 "presentation": {
4315 "default_locale": crate::i18n::current_locale()
4316 },
4317 "progress_policy": {
4318 "skip_answered": true,
4319 "autofill_defaults": false,
4320 "treat_default_as_answered": false
4321 },
4322 "questions": questions
4323 }))
4324}
4325
4326#[allow(dead_code)]
4327fn qa_question_type_name(kind: crate::setup::QuestionKind) -> &'static str {
4328 match kind {
4329 crate::setup::QuestionKind::String => "string",
4330 crate::setup::QuestionKind::Number => "number",
4331 crate::setup::QuestionKind::Boolean => "boolean",
4332 crate::setup::QuestionKind::Enum => "enum",
4333 }
4334}
4335
4336#[allow(dead_code)]
4337fn qa_default_value(value: &Value) -> Option<String> {
4338 match value {
4339 Value::String(text) => Some(text.clone()),
4340 Value::Bool(flag) => Some(flag.to_string()),
4341 Value::Number(number) => Some(number.to_string()),
4342 _ => None,
4343 }
4344}
4345
4346#[allow(dead_code)]
4347fn render_qa_driver_text<W: Write>(
4348 output: &mut W,
4349 text: &str,
4350 last_compact_title: &mut Option<String>,
4351) -> Result<()> {
4352 if text.is_empty() {
4353 return Ok(());
4354 }
4355 if let Some(title) = compact_form_title(text) {
4356 if last_compact_title.as_deref() != Some(title) {
4357 writeln!(output, "{title}")?;
4358 output.flush()?;
4359 *last_compact_title = Some(title.to_string());
4360 }
4361 return Ok(());
4362 }
4363 *last_compact_title = None;
4364 for line in text.lines() {
4365 writeln!(output, "{line}")?;
4366 }
4367 if !text.ends_with('\n') {
4368 output.flush()?;
4369 }
4370 Ok(())
4371}
4372
4373#[allow(dead_code)]
4374fn compact_form_title(text: &str) -> Option<&str> {
4375 let first_line = text.lines().next()?;
4376 let form = first_line.strip_prefix("Form: ")?;
4377 let (title, form_id) = form.rsplit_once(" (")?;
4378 if form_id
4379 .strip_suffix(')')
4380 .is_some_and(|id| id.starts_with("greentic-bundle-root-wizard-"))
4381 {
4382 return Some(title);
4383 }
4384 None
4385}
4386
4387#[allow(dead_code)]
4388fn prompt_qa_question_answer<R: BufRead, W: Write>(
4389 input: &mut R,
4390 output: &mut W,
4391 question_id: &str,
4392 question: &Value,
4393) -> Result<Value> {
4394 let title = question
4395 .get("title")
4396 .and_then(Value::as_str)
4397 .unwrap_or(question_id);
4398 let required = question
4399 .get("required")
4400 .and_then(Value::as_bool)
4401 .unwrap_or(false);
4402 let kind = question
4403 .get("type")
4404 .and_then(Value::as_str)
4405 .unwrap_or("string");
4406 let secret = question
4407 .get("secret")
4408 .and_then(Value::as_bool)
4409 .unwrap_or(false);
4410 let default_value = question_default_value(question, kind);
4411
4412 match kind {
4413 "boolean" => prompt_qa_boolean(input, output, title, required, default_value),
4414 "enum" => prompt_qa_enum(input, output, title, required, question, default_value),
4415 _ => prompt_qa_string_like(input, output, title, required, secret, default_value),
4416 }
4417}
4418
4419fn prompt_qa_string_like<R: BufRead, W: Write>(
4420 input: &mut R,
4421 output: &mut W,
4422 title: &str,
4423 required: bool,
4424 secret: bool,
4425 default_value: Option<Value>,
4426) -> Result<Value> {
4427 loop {
4428 if secret && io::stdin().is_terminal() && io::stdout().is_terminal() {
4429 let prompt = format!("{title}{}: ", default_suffix(default_value.as_ref()));
4430 let secret_value =
4431 rpassword::prompt_password(prompt).context("read secret wizard input")?;
4432 if secret_value.trim().is_empty() {
4433 if let Some(default) = &default_value {
4434 return Ok(default.clone());
4435 }
4436 if required {
4437 writeln!(output, "{}", crate::i18n::tr("wizard.error.empty_answer"))?;
4438 continue;
4439 }
4440 return Ok(Value::Null);
4441 }
4442 return Ok(Value::String(secret_value));
4443 }
4444
4445 write!(
4446 output,
4447 "{title}{}: ",
4448 default_suffix(default_value.as_ref())
4449 )?;
4450 output.flush()?;
4451 let mut line = String::new();
4452 input.read_line(&mut line)?;
4453 let trimmed = line.trim();
4454 if trimmed.is_empty() {
4455 if let Some(default) = &default_value {
4456 return Ok(default.clone());
4457 }
4458 if required {
4459 writeln!(output, "{}", crate::i18n::tr("wizard.error.empty_answer"))?;
4460 continue;
4461 }
4462 return Ok(Value::Null);
4463 }
4464 return Ok(Value::String(trimmed.to_string()));
4465 }
4466}
4467
4468#[allow(dead_code)]
4469fn prompt_qa_boolean<R: BufRead, W: Write>(
4470 input: &mut R,
4471 output: &mut W,
4472 title: &str,
4473 required: bool,
4474 default_value: Option<Value>,
4475) -> Result<Value> {
4476 loop {
4477 write!(
4478 output,
4479 "{title}{}: ",
4480 default_suffix(default_value.as_ref())
4481 )?;
4482 output.flush()?;
4483 let mut line = String::new();
4484 input.read_line(&mut line)?;
4485 let trimmed = line.trim().to_ascii_lowercase();
4486 if trimmed.is_empty() {
4487 if let Some(default) = &default_value {
4488 return Ok(default.clone());
4489 }
4490 if required {
4491 writeln!(output, "{}", crate::i18n::tr("wizard.error.empty_answer"))?;
4492 continue;
4493 }
4494 return Ok(Value::Null);
4495 }
4496 match parse_localized_boolean(&trimmed) {
4497 Some(value) => return Ok(Value::Bool(value)),
4498 None => writeln!(output, "{}", crate::i18n::tr("wizard.error.invalid_choice"))?,
4499 }
4500 }
4501}
4502
4503#[allow(dead_code)]
4504fn parse_localized_boolean(input: &str) -> Option<bool> {
4505 let trimmed = input.trim().to_ascii_lowercase();
4506 if trimmed.is_empty() {
4507 return None;
4508 }
4509
4510 let locale = crate::i18n::current_locale();
4511 let mut truthy = vec!["true", "t", "yes", "y", "1"];
4512 let mut falsy = vec!["false", "f", "no", "n", "0"];
4513
4514 match crate::i18n::base_language(&locale).as_deref() {
4515 Some("nl") => {
4516 truthy.extend(["ja", "j"]);
4517 falsy.extend(["nee"]);
4518 }
4519 Some("de") => {
4520 truthy.extend(["ja", "j"]);
4521 falsy.extend(["nein"]);
4522 }
4523 Some("fr") => {
4524 truthy.extend(["oui", "o"]);
4525 falsy.extend(["non"]);
4526 }
4527 Some("es") | Some("pt") | Some("it") => {
4528 truthy.extend(["si", "s"]);
4529 falsy.extend(["no"]);
4530 }
4531 _ => {}
4532 }
4533
4534 if truthy.iter().any(|value| *value == trimmed) {
4535 return Some(true);
4536 }
4537 if falsy.iter().any(|value| *value == trimmed) {
4538 return Some(false);
4539 }
4540 None
4541}
4542
4543#[allow(dead_code)]
4544fn prompt_qa_enum<R: BufRead, W: Write>(
4545 input: &mut R,
4546 output: &mut W,
4547 title: &str,
4548 required: bool,
4549 question: &Value,
4550 default_value: Option<Value>,
4551) -> Result<Value> {
4552 let choices = question
4553 .get("choices")
4554 .and_then(Value::as_array)
4555 .ok_or_else(|| anyhow::anyhow!("qa enum question missing choices"))?
4556 .iter()
4557 .filter_map(Value::as_str)
4558 .map(ToOwned::to_owned)
4559 .collect::<Vec<_>>();
4560
4561 loop {
4562 if !title.is_empty() {
4563 writeln!(output, "{title}:")?;
4564 }
4565 for (index, choice) in choices.iter().enumerate() {
4566 if title.is_empty() {
4567 writeln!(output, "{}. {}", index + 1, choice)?;
4568 } else {
4569 writeln!(output, " {}. {}", index + 1, choice)?;
4570 }
4571 }
4572 write!(output, "{} ", crate::i18n::tr("wizard.setup.enum_prompt"))?;
4573 output.flush()?;
4574
4575 let mut line = String::new();
4576 input.read_line(&mut line)?;
4577 let trimmed = line.trim();
4578 if trimmed.is_empty() {
4579 if let Some(default) = &default_value {
4580 return Ok(default.clone());
4581 }
4582 if required {
4583 writeln!(output, "{}", crate::i18n::tr("wizard.error.empty_answer"))?;
4584 continue;
4585 }
4586 return Ok(Value::Null);
4587 }
4588 if let Ok(number) = trimmed.parse::<usize>()
4589 && number > 0
4590 && number <= choices.len()
4591 {
4592 return Ok(Value::String(choices[number - 1].clone()));
4593 }
4594 if choices.iter().any(|choice| choice == trimmed) {
4595 return Ok(Value::String(trimmed.to_string()));
4596 }
4597 writeln!(output, "{}", crate::i18n::tr("wizard.error.invalid_choice"))?;
4598 }
4599}
4600
4601#[allow(dead_code)]
4602fn question_default_value(question: &Value, kind: &str) -> Option<Value> {
4603 let raw = question
4604 .get("current_value")
4605 .cloned()
4606 .or_else(|| question.get("default").cloned())?;
4607 match raw {
4608 Value::String(text) => match kind {
4609 "boolean" => match text.as_str() {
4610 "true" => Some(Value::Bool(true)),
4611 "false" => Some(Value::Bool(false)),
4612 _ => None,
4613 },
4614 "number" => serde_json::from_str::<serde_json::Number>(&text)
4615 .ok()
4616 .map(Value::Number),
4617 _ => Some(Value::String(text)),
4618 },
4619 Value::Bool(flag) if kind == "boolean" => Some(Value::Bool(flag)),
4620 Value::Number(number) if kind == "number" => Some(Value::Number(number)),
4621 Value::Null => None,
4622 other => Some(other),
4623 }
4624}
4625
4626fn default_suffix(value: Option<&Value>) -> String {
4627 match value {
4628 Some(Value::String(text)) if !text.is_empty() => format!(" [{}]", text),
4629 Some(Value::Bool(flag)) => format!(" [{}]", flag),
4630 Some(Value::Number(number)) => format!(" [{}]", number),
4631 _ => String::new(),
4632 }
4633}
4634
4635fn discover_setup_specs(
4636 mut request: NormalizedRequest,
4637 catalog_resolution: &crate::catalog::resolve::CatalogResolution,
4638) -> NormalizedRequest {
4639 if !request.setup_execution_intent {
4640 return request;
4641 }
4642
4643 for reference in request
4644 .extension_providers
4645 .iter()
4646 .chain(request.app_packs.iter())
4647 {
4648 if request.setup_specs.contains_key(reference) {
4649 continue;
4650 }
4651 if let Some(entry) = catalog_resolution
4652 .discovered_items
4653 .iter()
4654 .find(|entry| entry.id == *reference || entry.reference == *reference)
4655 && let Some(setup) = &entry.setup
4656 {
4657 request
4658 .setup_specs
4659 .entry(entry.id.clone())
4660 .or_insert_with(|| serde_json::to_value(setup).expect("serialize setup metadata"));
4661
4662 if let Some(answer_value) = request.setup_answers.remove(reference) {
4663 request
4664 .setup_answers
4665 .entry(entry.id.clone())
4666 .or_insert(answer_value);
4667 }
4668 }
4669 }
4670
4671 request
4672}
4673
4674#[cfg(test)]
4675mod tests {
4676 use std::io::Cursor;
4677
4678 use crate::catalog::registry::CatalogEntry;
4679
4680 use super::{
4681 RootMenuZeroAction, build_extension_provider_options, choose_interactive_menu,
4682 clean_extension_provider_label, detected_reference_kind,
4683 };
4684
4685 #[test]
4686 fn root_menu_shows_back_and_returns_none_for_embedded_wizards() {
4687 crate::i18n::init(Some("en".to_string()));
4688 let mut input = Cursor::new(b"0\n");
4689 let mut output = Vec::new();
4690
4691 let choice = choose_interactive_menu(&mut input, &mut output, RootMenuZeroAction::Back)
4692 .expect("menu should render");
4693
4694 assert_eq!(choice, None);
4695 let rendered = String::from_utf8(output).expect("utf8");
4696 assert!(rendered.contains("0. Back"));
4697 assert!(!rendered.contains("0. Exit"));
4698 }
4699
4700 #[test]
4701 fn extension_provider_options_dedupe_by_display_name() {
4702 let pinned = CatalogEntry {
4703 id: "greentic.secrets.aws-sm.v0-4-25".to_string(),
4704 category: Some("secrets".to_string()),
4705 category_label: None,
4706 category_description: None,
4707 label: Some("Greentic Secrets AWS SM".to_string()),
4708 reference:
4709 "oci://ghcr.io/greenticai/packs/secret/greentic.secrets.aws-sm.gtpack:0.4.25"
4710 .to_string(),
4711 setup: None,
4712 };
4713 let latest = CatalogEntry {
4714 id: "greentic.secrets.aws-sm.latest".to_string(),
4715 category: Some("secrets".to_string()),
4716 category_label: None,
4717 category_description: None,
4718 label: Some("Greentic Secrets AWS SM".to_string()),
4719 reference:
4720 "oci://ghcr.io/greenticai/packs/secret/greentic.secrets.aws-sm.gtpack:latest"
4721 .to_string(),
4722 setup: None,
4723 };
4724 let entries = vec![&pinned, &latest];
4725 let options = build_extension_provider_options(&entries);
4726
4727 assert_eq!(options.len(), 1);
4728 assert_eq!(options[0].display_name, "Greentic Secrets AWS SM");
4729 assert_eq!(options[0].entry.id, "greentic.secrets.aws-sm.v0-4-25");
4730 assert_eq!(
4731 options[0].entry.reference,
4732 "oci://ghcr.io/greenticai/packs/secret/greentic.secrets.aws-sm.gtpack:0.4.25"
4733 );
4734 }
4735
4736 #[test]
4737 fn clean_extension_provider_label_removes_latest_suffix_only() {
4738 let latest = CatalogEntry {
4739 id: "x.latest".to_string(),
4740 category: None,
4741 category_label: None,
4742 category_description: None,
4743 label: Some("Greentic Secrets AWS SM (latest)".to_string()),
4744 reference: "oci://ghcr.io/example/secrets:latest".to_string(),
4745 setup: None,
4746 };
4747 let semver = CatalogEntry {
4748 id: "x.0.4.25".to_string(),
4749 category: None,
4750 category_label: None,
4751 category_description: None,
4752 label: Some("Greentic Secrets AWS SM (0.4.25)".to_string()),
4753 reference: "oci://ghcr.io/example/secrets:0.4.25".to_string(),
4754 setup: None,
4755 };
4756 let pr = CatalogEntry {
4757 id: "x.pr".to_string(),
4758 category: None,
4759 category_label: None,
4760 category_description: None,
4761 label: Some("Greentic Messaging Dummy (PR version)".to_string()),
4762 reference: "oci://ghcr.io/example/messaging:<pr-version>".to_string(),
4763 setup: None,
4764 };
4765
4766 assert_eq!(
4767 clean_extension_provider_label(&latest),
4768 "Greentic Secrets AWS SM"
4769 );
4770 assert_eq!(
4771 clean_extension_provider_label(&semver),
4772 "Greentic Secrets AWS SM (0.4.25)"
4773 );
4774 assert_eq!(
4775 clean_extension_provider_label(&pr),
4776 "Greentic Messaging Dummy (PR version)"
4777 );
4778 }
4779
4780 #[test]
4781 fn detected_reference_kind_classifies_https_refs() {
4782 let root = std::path::Path::new(".");
4783 assert_eq!(
4784 detected_reference_kind(root, "https://example.com/packs/cards-demo.gtpack"),
4785 "https"
4786 );
4787 }
4788}