1mod confirm;
2mod executor;
3mod persistence;
4pub mod plan;
5mod provider;
6mod registry;
7
8use std::collections::BTreeMap;
9use std::fs;
10use std::io::{self, IsTerminal, Write};
11use std::path::{Path, PathBuf};
12use std::process::{Command, Stdio};
13
14use anyhow::{Context, Result, bail};
15use serde::{Deserialize, Serialize};
16
17use crate::cli::{WizardApplyArgs, WizardLaunchArgs, WizardValidateArgs};
18use crate::i18n;
19use crate::passthrough::resolve_binary;
20use crate::wizard::executor::ExecuteOptions;
21use crate::wizard::plan::{WizardAnswers, WizardFrontend, WizardPlan};
22use crate::wizard::provider::{ProviderRequest, ShellWizardProvider, WizardProvider};
23
24const DEFAULT_LOCALE: &str = "en-US";
25const DEFAULT_SCHEMA_VERSION: &str = "1.0.0";
26const WIZARD_ID: &str = "greentic-dev.wizard.launcher.main";
27const SCHEMA_ID: &str = "greentic-dev.launcher.main";
28const BUNDLE_WIZARD_ID_PREFIX: &str = "greentic-bundle.";
29const PACK_WIZARD_ID_PREFIX: &str = "greentic-pack.";
30const EMBEDDED_WIZARD_ROOT_ZERO_ACTION_ENV: &str = "GREENTIC_WIZARD_ROOT_ZERO_ACTION";
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33enum ExecutionMode {
34 DryRun,
35 Execute,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39enum LauncherMenuChoice {
40 Pack,
41 Bundle,
42 MainMenu,
43 Exit,
44}
45
46#[derive(Debug, Clone)]
47struct LoadedAnswers {
48 answers: serde_json::Value,
49 inferred_locale: Option<String>,
50 schema_version: Option<String>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
54struct AnswerDocument {
55 wizard_id: String,
56 schema_id: String,
57 schema_version: String,
58 locale: String,
59 answers: serde_json::Value,
60 #[serde(default)]
61 locks: serde_json::Map<String, serde_json::Value>,
62}
63
64pub fn launch(args: WizardLaunchArgs) -> Result<()> {
65 let mode = if args.dry_run {
66 ExecutionMode::DryRun
67 } else {
68 ExecutionMode::Execute
69 };
70
71 if let Some(answers_path) = args.answers.as_deref() {
72 let loaded =
73 load_answer_document(answers_path, args.schema_version.as_deref(), args.migrate)?;
74
75 return run_from_inputs(
76 args.frontend,
77 args.locale,
78 loaded,
79 args.out,
80 mode,
81 args.yes,
82 args.non_interactive,
83 args.unsafe_commands,
84 args.allow_destructive,
85 args.emit_answers,
86 args.schema_version,
87 );
88 }
89
90 let locale = i18n::select_locale(args.locale.as_deref());
91 if mode == ExecutionMode::DryRun {
92 let Some(answers) = prompt_launcher_answers(mode, &locale)? else {
93 return Ok(());
94 };
95 let loaded = LoadedAnswers {
96 answers,
97 inferred_locale: None,
98 schema_version: args.schema_version.clone(),
99 };
100
101 return run_from_inputs(
102 args.frontend,
103 Some(locale),
104 loaded,
105 args.out,
106 mode,
107 args.yes,
108 args.non_interactive,
109 args.unsafe_commands,
110 args.allow_destructive,
111 args.emit_answers,
112 args.schema_version,
113 );
114 }
115
116 loop {
117 let Some(answers) = prompt_launcher_answers(mode, &locale)? else {
118 return Ok(());
119 };
120
121 run_interactive_delegate(&answers, &locale)?;
122 }
123}
124
125fn run_interactive_delegate(answers: &serde_json::Value, locale: &str) -> Result<()> {
126 let selected_action = answers
127 .get("selected_action")
128 .and_then(|value| value.as_str())
129 .ok_or_else(|| anyhow::anyhow!("missing required answers.selected_action"))?;
130
131 let program = match selected_action {
132 "pack" => "greentic-pack",
133 "bundle" => "greentic-bundle",
134 other => bail!("unsupported selected_action `{other}`; expected `pack` or `bundle`"),
135 };
136
137 let bin = resolve_binary(program)?;
138 let mut command = Command::new(&bin);
139 command
140 .args(interactive_delegate_args(program, locale))
141 .env("LANG", locale)
142 .env("LC_ALL", locale)
143 .env("LC_MESSAGES", locale)
144 .stdin(Stdio::inherit())
145 .stdout(Stdio::inherit())
146 .stderr(Stdio::inherit());
147 if program == "greentic-bundle" {
148 command.env(EMBEDDED_WIZARD_ROOT_ZERO_ACTION_ENV, "back");
149 }
150 let status = command
151 .status()
152 .map_err(|e| anyhow::anyhow!("failed to execute {}: {e}", bin.display()))?;
153 if status.success() {
154 Ok(())
155 } else {
156 bail!(
157 "wizard step command failed: {} {:?} (exit code {:?})",
158 program,
159 ["wizard"],
160 status.code()
161 );
162 }
163}
164
165fn interactive_delegate_args(program: &str, locale: &str) -> Vec<String> {
166 if program == "greentic-bundle" {
167 vec![
168 "--locale".to_string(),
169 locale.to_string(),
170 "wizard".to_string(),
171 ]
172 } else {
173 vec!["wizard".to_string()]
174 }
175}
176
177pub fn validate(args: WizardValidateArgs) -> Result<()> {
178 let loaded = load_answer_document(
179 args.answers.as_path(),
180 args.schema_version.as_deref(),
181 args.migrate,
182 )?;
183
184 run_from_inputs(
185 args.frontend,
186 args.locale,
187 loaded,
188 args.out,
189 ExecutionMode::DryRun,
190 true,
191 true,
192 false,
193 false,
194 args.emit_answers,
195 args.schema_version,
196 )
197}
198
199pub fn apply(args: WizardApplyArgs) -> Result<()> {
200 let loaded = load_answer_document(
201 args.answers.as_path(),
202 args.schema_version.as_deref(),
203 args.migrate,
204 )?;
205
206 run_from_inputs(
207 args.frontend,
208 args.locale,
209 loaded,
210 args.out,
211 ExecutionMode::Execute,
212 args.yes,
213 args.non_interactive,
214 args.unsafe_commands,
215 args.allow_destructive,
216 args.emit_answers,
217 args.schema_version,
218 )
219}
220
221#[allow(clippy::too_many_arguments)]
222fn run_from_inputs(
223 frontend_raw: String,
224 cli_locale: Option<String>,
225 loaded: LoadedAnswers,
226 out: Option<PathBuf>,
227 mode: ExecutionMode,
228 yes: bool,
229 non_interactive: bool,
230 unsafe_commands: bool,
231 allow_destructive: bool,
232 emit_answers: Option<PathBuf>,
233 requested_schema_version: Option<String>,
234) -> Result<()> {
235 let locale = i18n::select_locale(
236 cli_locale
237 .as_deref()
238 .or(loaded.inferred_locale.as_deref())
239 .or(Some(DEFAULT_LOCALE)),
240 );
241 let frontend = WizardFrontend::parse(&frontend_raw).ok_or_else(|| {
242 anyhow::anyhow!(
243 "unsupported frontend `{}`; expected text|json|adaptive-card",
244 frontend_raw
245 )
246 })?;
247
248 if registry::resolve("launcher", "main").is_none() {
249 bail!("launcher mapping missing for `launcher.main`");
250 }
251
252 let merged_answers = merge_answers(None, None, Some(loaded.answers.clone()), None);
253 let delegated_answers_path = persist_delegated_answers_if_present(
254 &paths_for_provider(out.as_deref())?,
255 &merged_answers,
256 )?;
257 let provider = ShellWizardProvider;
258 let req = ProviderRequest {
259 frontend: frontend.clone(),
260 locale: locale.clone(),
261 dry_run: mode == ExecutionMode::DryRun,
262 answers: merged_answers.clone(),
263 delegated_answers_path,
264 };
265 let mut plan = provider.build_plan(&req)?;
266
267 let out_dir = persistence::resolve_out_dir(out.as_deref());
268 let paths = persistence::prepare_dir(&out_dir)?;
269 persistence::persist_plan_and_answers(&paths, &merged_answers, &plan)?;
270
271 render_plan(&plan)?;
272
273 if mode == ExecutionMode::Execute {
274 confirm::ensure_execute_allowed(
275 &crate::i18n::tf(
276 &locale,
277 "runtime.wizard.confirm.summary",
278 &[
279 ("target", plan.metadata.target.clone()),
280 ("mode", plan.metadata.mode.clone()),
281 ("step_count", plan.steps.len().to_string()),
282 ],
283 ),
284 yes,
285 non_interactive,
286 &locale,
287 )?;
288 let report = executor::execute(
289 &plan,
290 &paths.exec_log_path,
291 &ExecuteOptions {
292 unsafe_commands,
293 allow_destructive,
294 locale: locale.clone(),
295 },
296 )?;
297 annotate_execution_metadata(&mut plan, &report);
298 persistence::persist_plan_and_answers(&paths, &merged_answers, &plan)?;
299 }
300
301 if let Some(path) = emit_answers {
302 let schema_version = requested_schema_version
303 .or(loaded.schema_version)
304 .unwrap_or_else(|| DEFAULT_SCHEMA_VERSION.to_string());
305 let doc = build_answer_document(&locale, &schema_version, &merged_answers, &plan);
306 write_answer_document(&path, &doc)?;
307 }
308
309 Ok(())
310}
311
312fn paths_for_provider(out: Option<&Path>) -> Result<persistence::PersistedPaths> {
313 let out_dir = persistence::resolve_out_dir(out);
314 persistence::prepare_dir(&out_dir)
315}
316
317fn persist_delegated_answers_if_present(
318 paths: &persistence::PersistedPaths,
319 answers: &WizardAnswers,
320) -> Result<Option<PathBuf>> {
321 let Some(delegated_answers) = answers.data.get("delegate_answer_document") else {
322 return Ok(None);
323 };
324 if !delegated_answers.is_object() {
325 bail!("answers.delegate_answer_document must be a JSON object");
326 }
327 persistence::persist_delegated_answers(&paths.delegated_answers_path, delegated_answers)?;
328 Ok(Some(paths.delegated_answers_path.clone()))
329}
330
331fn render_plan(plan: &WizardPlan) -> Result<()> {
332 let rendered = match plan.metadata.frontend {
333 WizardFrontend::Json => {
334 serde_json::to_string_pretty(plan).context("failed to encode wizard plan")?
335 }
336 WizardFrontend::Text => render_text_plan(plan),
337 WizardFrontend::AdaptiveCard => {
338 let card = serde_json::json!({
339 "type": "AdaptiveCard",
340 "version": "1.5",
341 "body": [
342 {"type":"TextBlock","weight":"Bolder","text":"greentic-dev launcher wizard plan"},
343 {"type":"TextBlock","text": "target: launcher mode: main"},
344 ],
345 "data": { "plan": plan }
346 });
347 serde_json::to_string_pretty(&card).context("failed to encode adaptive card")?
348 }
349 };
350 println!("{rendered}");
351 Ok(())
352}
353
354fn render_text_plan(plan: &WizardPlan) -> String {
355 let mut out = String::new();
356 out.push_str(&format!(
357 "wizard plan v{}: {}.{}\n",
358 plan.plan_version, plan.metadata.target, plan.metadata.mode
359 ));
360 out.push_str(&format!("locale: {}\n", plan.metadata.locale));
361 out.push_str(&format!("steps: {}\n", plan.steps.len()));
362 for (idx, step) in plan.steps.iter().enumerate() {
363 match step {
364 crate::wizard::plan::WizardStep::RunCommand(cmd) => {
365 out.push_str(&format!(
366 "{}. RunCommand {} {}\n",
367 idx + 1,
368 cmd.program,
369 cmd.args.join(" ")
370 ));
371 }
372 other => out.push_str(&format!("{}. {:?}\n", idx + 1, other)),
373 }
374 }
375 out
376}
377
378fn prompt_launcher_answers(mode: ExecutionMode, locale: &str) -> Result<Option<serde_json::Value>> {
379 let interactive = io::stdin().is_terminal() && io::stdout().is_terminal();
380 if !interactive {
381 bail!(
382 "{}",
383 i18n::t(locale, "cli.wizard.error.interactive_required")
384 );
385 }
386
387 loop {
388 eprintln!("{}", i18n::t(locale, "cli.wizard.launcher.title"));
389 eprintln!();
390 eprintln!("{}", i18n::t(locale, "cli.wizard.launcher.option_pack"));
391 eprintln!("{}", i18n::t(locale, "cli.wizard.launcher.option_bundle"));
392 eprintln!("{}", i18n::t(locale, "cli.wizard.launcher.option_exit"));
393 eprintln!();
394 eprint!("{}", i18n::t(locale, "cli.wizard.launcher.select_option"));
395 io::stderr().flush()?;
396
397 let mut input = String::new();
398 io::stdin().read_line(&mut input)?;
399 match parse_launcher_menu_choice(input.trim(), true, locale)? {
400 LauncherMenuChoice::Pack => return Ok(Some(build_launcher_answers(mode, "pack"))),
401 LauncherMenuChoice::Bundle => return Ok(Some(build_launcher_answers(mode, "bundle"))),
402 LauncherMenuChoice::MainMenu => {
403 eprintln!();
404 continue;
405 }
406 LauncherMenuChoice::Exit => return Ok(None),
407 }
408 }
409}
410
411fn parse_launcher_menu_choice(
412 input: &str,
413 in_main_menu: bool,
414 locale: &str,
415) -> Result<LauncherMenuChoice> {
416 match input.trim() {
417 "1" if in_main_menu => Ok(LauncherMenuChoice::Pack),
418 "2" if in_main_menu => Ok(LauncherMenuChoice::Bundle),
419 "0" if in_main_menu => Ok(LauncherMenuChoice::Exit),
420 "0" => Ok(LauncherMenuChoice::MainMenu),
421 "m" | "M" => Ok(LauncherMenuChoice::MainMenu),
422 _ => bail!("{}", i18n::t(locale, "cli.wizard.error.invalid_selection")),
423 }
424}
425
426fn build_launcher_answers(mode: ExecutionMode, selected_action: &str) -> serde_json::Value {
427 let mut answers = serde_json::Map::new();
428 answers.insert(
429 "selected_action".to_string(),
430 serde_json::Value::String(selected_action.to_string()),
431 );
432 if mode == ExecutionMode::DryRun {
433 answers.insert(
434 "delegate_answer_document".to_string(),
435 serde_json::Value::Object(Default::default()),
436 );
437 }
438 serde_json::Value::Object(answers)
439}
440
441fn load_answer_document(
442 path: &Path,
443 requested_schema_version: Option<&str>,
444 migrate: bool,
445) -> Result<LoadedAnswers> {
446 let raw =
447 fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
448 let value: serde_json::Value = serde_json::from_str(&raw)
449 .with_context(|| format!("failed to parse {}", path.display()))?;
450
451 let mut doc: AnswerDocument = serde_json::from_value(value)
452 .with_context(|| format!("failed to parse AnswerDocument from {}", path.display()))?;
453 if is_launcher_answer_document(&doc) {
454 if let Some(schema_version) = requested_schema_version
455 && doc.schema_version != schema_version
456 {
457 if migrate {
458 doc = migrate_answer_document(doc, schema_version);
459 } else {
460 bail!(
461 "answers schema_version `{}` does not match requested `{}`; re-run with --migrate",
462 doc.schema_version,
463 schema_version
464 );
465 }
466 }
467
468 if !doc.answers.is_object() {
469 bail!(
470 "AnswerDocument `answers` must be a JSON object in {}",
471 path.display()
472 );
473 }
474
475 return Ok(LoadedAnswers {
476 answers: doc.answers.clone(),
477 inferred_locale: Some(doc.locale),
478 schema_version: Some(doc.schema_version),
479 });
480 }
481
482 if let Some(selected_action) = delegated_selected_action(&doc) {
483 return Ok(LoadedAnswers {
484 answers: wrap_delegated_answer_document(selected_action, &doc),
485 inferred_locale: Some(doc.locale),
486 schema_version: Some(
487 requested_schema_version
488 .unwrap_or(DEFAULT_SCHEMA_VERSION)
489 .to_string(),
490 ),
491 });
492 }
493
494 validate_answer_document_identity(&doc, path)?;
495 unreachable!("launcher identity validation must error for unsupported documents");
496}
497
498fn validate_answer_document_identity(doc: &AnswerDocument, path: &Path) -> Result<()> {
499 if !is_launcher_answer_document(doc) {
500 bail!(
501 "unsupported wizard_id `{}` in {}; expected `{}`",
502 doc.wizard_id,
503 path.display(),
504 WIZARD_ID
505 );
506 }
507 if doc.schema_id != SCHEMA_ID {
508 bail!(
509 "unsupported schema_id `{}` in {}; expected `{}`",
510 doc.schema_id,
511 path.display(),
512 SCHEMA_ID
513 );
514 }
515 Ok(())
516}
517
518fn is_launcher_answer_document(doc: &AnswerDocument) -> bool {
519 doc.wizard_id == WIZARD_ID && doc.schema_id == SCHEMA_ID
520}
521
522fn delegated_selected_action(doc: &AnswerDocument) -> Option<&'static str> {
523 if doc.wizard_id.starts_with(BUNDLE_WIZARD_ID_PREFIX) {
524 Some("bundle")
525 } else if doc.wizard_id.starts_with(PACK_WIZARD_ID_PREFIX) {
526 Some("pack")
527 } else {
528 None
529 }
530}
531
532fn wrap_delegated_answer_document(
533 selected_action: &str,
534 doc: &AnswerDocument,
535) -> serde_json::Value {
536 serde_json::json!({
537 "selected_action": selected_action,
538 "delegate_answer_document": doc,
539 })
540}
541
542fn merge_answers(
543 cli_overrides: Option<serde_json::Value>,
544 parent_prefill: Option<serde_json::Value>,
545 answers_file: Option<serde_json::Value>,
546 provider_defaults: Option<serde_json::Value>,
547) -> WizardAnswers {
548 let mut out = BTreeMap::<String, serde_json::Value>::new();
549 merge_obj(&mut out, provider_defaults);
550 merge_obj(&mut out, answers_file);
551 merge_obj(&mut out, parent_prefill);
552 merge_obj(&mut out, cli_overrides);
553 WizardAnswers {
554 data: serde_json::Value::Object(out.into_iter().collect()),
555 }
556}
557
558fn merge_obj(dst: &mut BTreeMap<String, serde_json::Value>, src: Option<serde_json::Value>) {
559 if let Some(serde_json::Value::Object(map)) = src {
560 for (k, v) in map {
561 dst.insert(k, v);
562 }
563 }
564}
565
566fn migrate_answer_document(mut doc: AnswerDocument, target_schema_version: &str) -> AnswerDocument {
567 doc.schema_version = target_schema_version.to_string();
568 doc
569}
570
571fn build_answer_document(
572 locale: &str,
573 schema_version: &str,
574 answers: &WizardAnswers,
575 plan: &WizardPlan,
576) -> AnswerDocument {
577 let locks = plan
578 .inputs
579 .iter()
580 .map(|(key, value)| (key.clone(), serde_json::Value::String(value.clone())))
581 .collect();
582 AnswerDocument {
583 wizard_id: WIZARD_ID.to_string(),
584 schema_id: SCHEMA_ID.to_string(),
585 schema_version: schema_version.to_string(),
586 locale: locale.to_string(),
587 answers: answers.data.clone(),
588 locks,
589 }
590}
591
592fn write_answer_document(path: &Path, doc: &AnswerDocument) -> Result<()> {
593 let rendered = serde_json::to_string_pretty(doc).context("render answers envelope JSON")?;
594 fs::write(path, rendered).with_context(|| format!("failed to write {}", path.display()))
595}
596
597fn annotate_execution_metadata(
598 plan: &mut WizardPlan,
599 report: &crate::wizard::executor::ExecutionReport,
600) {
601 for (program, version) in &report.resolved_versions {
602 plan.inputs
603 .insert(format!("resolved_versions.{program}"), version.clone());
604 }
605 plan.inputs.insert(
606 "executed_commands".to_string(),
607 report.commands_executed.to_string(),
608 );
609}
610
611#[cfg(test)]
612mod tests {
613 use std::collections::BTreeMap;
614 use std::path::Path;
615
616 use serde_json::json;
617
618 use super::{
619 AnswerDocument, LauncherMenuChoice, SCHEMA_ID, WIZARD_ID, build_answer_document,
620 build_launcher_answers, interactive_delegate_args, is_launcher_answer_document,
621 merge_answers, parse_launcher_menu_choice, validate_answer_document_identity,
622 wrap_delegated_answer_document,
623 };
624 use crate::wizard::plan::{WizardFrontend, WizardPlan, WizardPlanMetadata};
625
626 #[test]
627 fn answer_precedence_cli_over_file() {
628 let merged = merge_answers(
629 Some(json!({"foo":"cli"})),
630 None,
631 Some(json!({"foo":"file","bar":"file"})),
632 None,
633 );
634 assert_eq!(merged.data["foo"], "cli");
635 assert_eq!(merged.data["bar"], "file");
636 }
637
638 #[test]
639 fn build_answer_document_sets_launcher_identity_fields() {
640 let answers = merge_answers(None, None, Some(json!({"selected_action":"pack"})), None);
641 let plan = WizardPlan {
642 plan_version: 1,
643 created_at: None,
644 metadata: WizardPlanMetadata {
645 target: "launcher".to_string(),
646 mode: "main".to_string(),
647 locale: "en-US".to_string(),
648 frontend: WizardFrontend::Json,
649 },
650 inputs: BTreeMap::from([(
651 "resolved_versions.greentic-pack".to_string(),
652 "greentic-pack 0.1".to_string(),
653 )]),
654 steps: vec![],
655 };
656
657 let doc = build_answer_document("en-US", "1.0.0", &answers, &plan);
658
659 assert_eq!(doc.wizard_id, WIZARD_ID);
660 assert_eq!(doc.schema_id, SCHEMA_ID);
661 assert_eq!(doc.schema_version, "1.0.0");
662 assert_eq!(doc.locale, "en-US");
663 assert_eq!(doc.answers["selected_action"], "pack");
664 assert_eq!(
665 doc.locks.get("resolved_versions.greentic-pack"),
666 Some(&json!("greentic-pack 0.1"))
667 );
668 }
669
670 #[test]
671 fn reject_non_launcher_answer_document_id() {
672 let doc = AnswerDocument {
673 wizard_id: "greentic-dev.wizard.pack.build".to_string(),
674 schema_id: SCHEMA_ID.to_string(),
675 schema_version: "1.0.0".to_string(),
676 locale: "en-US".to_string(),
677 answers: json!({}),
678 locks: serde_json::Map::new(),
679 };
680 let err = validate_answer_document_identity(&doc, Path::new("answers.json")).unwrap_err();
681 assert!(err.to_string().contains("unsupported wizard_id"));
682 }
683
684 #[test]
685 fn launcher_identity_matches_expected_pair() {
686 let doc = AnswerDocument {
687 wizard_id: WIZARD_ID.to_string(),
688 schema_id: SCHEMA_ID.to_string(),
689 schema_version: "1.0.0".to_string(),
690 locale: "en-US".to_string(),
691 answers: json!({}),
692 locks: serde_json::Map::new(),
693 };
694 assert!(is_launcher_answer_document(&doc));
695 }
696
697 #[test]
698 fn wrap_delegated_bundle_document_builds_launcher_shape() {
699 let doc = AnswerDocument {
700 wizard_id: "greentic-bundle.wizard.main".to_string(),
701 schema_id: "greentic-bundle.main".to_string(),
702 schema_version: "1.0.0".to_string(),
703 locale: "en-US".to_string(),
704 answers: json!({"selected_action":"create"}),
705 locks: serde_json::Map::new(),
706 };
707 let wrapped = wrap_delegated_answer_document("bundle", &doc);
708 assert_eq!(wrapped["selected_action"], "bundle");
709 assert_eq!(
710 wrapped["delegate_answer_document"]["wizard_id"],
711 "greentic-bundle.wizard.main"
712 );
713 }
714
715 #[test]
716 fn parse_main_menu_navigation_keys() {
717 assert_eq!(
718 parse_launcher_menu_choice("1", true, "en-US").expect("parse"),
719 LauncherMenuChoice::Pack
720 );
721 assert_eq!(
722 parse_launcher_menu_choice("2", true, "en-US").expect("parse"),
723 LauncherMenuChoice::Bundle
724 );
725 assert_eq!(
726 parse_launcher_menu_choice("0", true, "en-US").expect("parse"),
727 LauncherMenuChoice::Exit
728 );
729 assert_eq!(
730 parse_launcher_menu_choice("M", true, "en-US").expect("parse"),
731 LauncherMenuChoice::MainMenu
732 );
733 }
734
735 #[test]
736 fn parse_nested_menu_zero_returns_to_main_menu() {
737 assert_eq!(
738 parse_launcher_menu_choice("0", false, "en-US").expect("parse"),
739 LauncherMenuChoice::MainMenu
740 );
741 }
742
743 #[test]
744 fn build_launcher_answers_includes_selected_action() {
745 let answers = build_launcher_answers(super::ExecutionMode::DryRun, "bundle");
746 assert_eq!(answers["selected_action"], "bundle");
747 assert!(answers.get("delegate_answer_document").is_some());
748 }
749
750 #[test]
751 fn bundle_delegate_receives_locale_flag() {
752 assert_eq!(
753 interactive_delegate_args("greentic-bundle", "en-GB"),
754 vec!["--locale", "en-GB", "wizard"]
755 );
756 }
757
758 #[test]
759 fn pack_delegate_keeps_plain_wizard_args() {
760 assert_eq!(
761 interactive_delegate_args("greentic-pack", "en-GB"),
762 vec!["wizard"]
763 );
764 }
765}