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