Skip to main content

greentic_dev/wizard/
mod.rs

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}