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