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(
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 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 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}