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};
16use tempfile::TempDir;
17
18use crate::cli::{WizardApplyArgs, WizardLaunchArgs, WizardValidateArgs};
19use crate::i18n;
20use crate::passthrough::resolve_binary;
21use crate::wizard::executor::ExecuteOptions;
22use crate::wizard::plan::{WizardAnswers, WizardFrontend, WizardPlan};
23use crate::wizard::provider::{ProviderRequest, ShellWizardProvider, WizardProvider};
24
25const DEFAULT_LOCALE: &str = "en-US";
26const DEFAULT_SCHEMA_VERSION: &str = "1.0.0";
27const WIZARD_ID: &str = "greentic-dev.wizard.launcher.main";
28const SCHEMA_ID: &str = "greentic-dev.launcher.main";
29const BUNDLE_WIZARD_ID_PREFIX: &str = "greentic-bundle.";
30const PACK_WIZARD_ID_PREFIX: &str = "greentic-pack.";
31const EMBEDDED_WIZARD_ROOT_ZERO_ACTION_ENV: &str = "GREENTIC_WIZARD_ROOT_ZERO_ACTION";
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34enum ExecutionMode {
35 DryRun,
36 Execute,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40enum LauncherMenuChoice {
41 Pack,
42 Bundle,
43 MainMenu,
44 Exit,
45}
46
47#[derive(Debug, Clone)]
48struct LoadedAnswers {
49 answers: serde_json::Value,
50 inferred_locale: Option<String>,
51 schema_version: Option<String>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
55struct AnswerDocument {
56 wizard_id: String,
57 schema_id: String,
58 schema_version: String,
59 locale: String,
60 answers: serde_json::Value,
61 #[serde(default)]
62 locks: serde_json::Map<String, serde_json::Value>,
63}
64
65pub fn launch(args: WizardLaunchArgs) -> Result<()> {
66 let mode = if args.dry_run {
67 ExecutionMode::DryRun
68 } else {
69 ExecutionMode::Execute
70 };
71
72 if let Some(answers_path) = args.answers.as_deref() {
73 let loaded =
74 load_answer_document(answers_path, args.schema_version.as_deref(), args.migrate)?;
75
76 return run_from_inputs(
78 args.frontend,
79 args.locale,
80 loaded,
81 args.out,
82 mode,
83 true,
84 true,
85 args.unsafe_commands,
86 args.allow_destructive,
87 args.emit_answers,
88 args.schema_version,
89 );
90 }
91
92 let locale = i18n::select_locale(args.locale.as_deref());
93 if mode == ExecutionMode::DryRun {
94 let Some(answers) = prompt_launcher_answers(mode, &locale)? else {
95 return Ok(());
96 };
97 let loaded = LoadedAnswers {
98 answers,
99 inferred_locale: None,
100 schema_version: args.schema_version.clone(),
101 };
102
103 return run_from_inputs(
104 args.frontend,
105 Some(locale),
106 loaded,
107 args.out,
108 mode,
109 args.yes,
110 args.non_interactive,
111 args.unsafe_commands,
112 args.allow_destructive,
113 args.emit_answers,
114 args.schema_version,
115 );
116 }
117
118 loop {
119 let Some(answers) = prompt_launcher_answers(mode, &locale)? else {
120 return Ok(());
121 };
122
123 run_interactive_delegate(
124 &answers,
125 &locale,
126 args.emit_answers.as_deref(),
127 args.schema_version.as_deref(),
128 )?;
129 if args.emit_answers.is_some() {
130 return Ok(());
131 }
132 }
133}
134
135fn run_interactive_delegate(
136 answers: &serde_json::Value,
137 locale: &str,
138 emit_answers: Option<&Path>,
139 requested_schema_version: Option<&str>,
140) -> Result<()> {
141 let selected_action = answers
142 .get("selected_action")
143 .and_then(|value| value.as_str())
144 .ok_or_else(|| anyhow::anyhow!("missing required answers.selected_action"))?;
145
146 let program = match selected_action {
147 "pack" => "greentic-pack",
148 "bundle" => "greentic-bundle",
149 other => bail!("unsupported selected_action `{other}`; expected `pack` or `bundle`"),
150 };
151
152 let bin = resolve_binary(program)?;
153 let delegated_emit = delegated_emit_capture(emit_answers)?;
154 let mut command = Command::new(&bin);
155 command
156 .args(interactive_delegate_args(
157 program,
158 locale,
159 delegated_emit.path.as_deref(),
160 ))
161 .env("LANG", locale)
162 .env("LC_ALL", locale)
163 .env("LC_MESSAGES", locale)
164 .stdin(Stdio::inherit())
165 .stdout(Stdio::inherit())
166 .stderr(Stdio::inherit());
167 if program == "greentic-bundle" {
168 command.env(EMBEDDED_WIZARD_ROOT_ZERO_ACTION_ENV, "back");
169 }
170 let status = command
171 .status()
172 .map_err(|e| anyhow::anyhow!("failed to execute {}: {e}", bin.display()))?;
173 if !status.success() {
174 bail!(
175 "wizard step command failed: {} {:?} (exit code {:?})",
176 program,
177 ["wizard"],
178 status.code()
179 );
180 }
181
182 if let (Some(output_path), Some(delegated_emit_path)) =
183 (emit_answers, delegated_emit.path.as_deref())
184 {
185 let delegated_doc = read_answer_document(delegated_emit_path)?;
186 let Some(delegated_action) = delegated_selected_action(&delegated_doc) else {
187 bail!(
188 "unsupported delegated wizard_id `{}` in {}; expected `greentic-pack.*` or `greentic-bundle.*`",
189 delegated_doc.wizard_id,
190 delegated_emit_path.display()
191 );
192 };
193 if delegated_action != selected_action {
194 bail!(
195 "delegated answers wizard_id `{}` did not match selected_action `{selected_action}`",
196 delegated_doc.wizard_id
197 );
198 }
199 let schema_version = requested_schema_version.unwrap_or(DEFAULT_SCHEMA_VERSION);
200 let launcher_doc = build_interactive_answer_document(
201 locale,
202 schema_version,
203 selected_action,
204 &delegated_doc,
205 );
206 write_answer_document(output_path, &launcher_doc)?;
207 }
208
209 Ok(())
210}
211
212fn interactive_delegate_args(
213 program: &str,
214 locale: &str,
215 emit_answers: Option<&Path>,
216) -> Vec<String> {
217 let mut args = if program == "greentic-bundle" {
218 vec![
219 "--locale".to_string(),
220 locale.to_string(),
221 "wizard".to_string(),
222 ]
223 } else {
224 vec!["wizard".to_string()]
225 };
226 if let Some(path) = emit_answers {
227 args.push("run".to_string());
228 args.push("--emit-answers".to_string());
229 args.push(path.display().to_string());
230 }
231 args
232}
233
234pub fn validate(args: WizardValidateArgs) -> Result<()> {
235 let loaded = load_answer_document(&args.answers, args.schema_version.as_deref(), args.migrate)?;
236
237 run_from_inputs(
238 args.frontend,
239 args.locale,
240 loaded,
241 args.out,
242 ExecutionMode::DryRun,
243 true,
244 true,
245 false,
246 false,
247 args.emit_answers,
248 args.schema_version,
249 )
250}
251
252pub fn apply(args: WizardApplyArgs) -> Result<()> {
253 let loaded = load_answer_document(&args.answers, args.schema_version.as_deref(), args.migrate)?;
254
255 run_from_inputs(
256 args.frontend,
257 args.locale,
258 loaded,
259 args.out,
260 ExecutionMode::Execute,
261 args.yes,
262 args.non_interactive,
263 args.unsafe_commands,
264 args.allow_destructive,
265 args.emit_answers,
266 args.schema_version,
267 )
268}
269
270#[allow(clippy::too_many_arguments)]
271fn run_from_inputs(
272 frontend_raw: String,
273 cli_locale: Option<String>,
274 loaded: LoadedAnswers,
275 out: Option<PathBuf>,
276 mode: ExecutionMode,
277 yes: bool,
278 non_interactive: bool,
279 unsafe_commands: bool,
280 allow_destructive: bool,
281 emit_answers: Option<PathBuf>,
282 requested_schema_version: Option<String>,
283) -> Result<()> {
284 let locale = i18n::select_locale(
285 cli_locale
286 .as_deref()
287 .or(loaded.inferred_locale.as_deref())
288 .or(Some(DEFAULT_LOCALE)),
289 );
290 let frontend = WizardFrontend::parse(&frontend_raw).ok_or_else(|| {
291 anyhow::anyhow!(
292 "unsupported frontend `{}`; expected text|json|adaptive-card",
293 frontend_raw
294 )
295 })?;
296
297 if registry::resolve("launcher", "main").is_none() {
298 bail!("launcher mapping missing for `launcher.main`");
299 }
300
301 let merged_answers = merge_answers(None, None, Some(loaded.answers.clone()), None);
302 let delegated_answers_path = persist_delegated_answers_if_present(
303 &paths_for_provider(out.as_deref())?,
304 &merged_answers,
305 )?;
306 let provider = ShellWizardProvider;
307 let req = ProviderRequest {
308 frontend: frontend.clone(),
309 locale: locale.clone(),
310 dry_run: mode == ExecutionMode::DryRun,
311 answers: merged_answers.clone(),
312 delegated_answers_path,
313 };
314 let mut plan = provider.build_plan(&req)?;
315
316 let out_dir = persistence::resolve_out_dir(out.as_deref());
317 let paths = persistence::prepare_dir(&out_dir)?;
318 persistence::persist_plan_and_answers(&paths, &merged_answers, &plan)?;
319
320 render_plan(&plan)?;
321
322 if mode == ExecutionMode::Execute {
323 confirm::ensure_execute_allowed(
324 &crate::i18n::tf(
325 &locale,
326 "runtime.wizard.confirm.summary",
327 &[
328 ("target", plan.metadata.target.clone()),
329 ("mode", plan.metadata.mode.clone()),
330 ("step_count", plan.steps.len().to_string()),
331 ],
332 ),
333 yes,
334 non_interactive,
335 &locale,
336 )?;
337 let report = executor::execute(
338 &plan,
339 &paths.exec_log_path,
340 &ExecuteOptions {
341 unsafe_commands,
342 allow_destructive,
343 locale: locale.clone(),
344 },
345 )?;
346 annotate_execution_metadata(&mut plan, &report);
347 persistence::persist_plan_and_answers(&paths, &merged_answers, &plan)?;
348 }
349
350 if let Some(path) = emit_answers {
351 let schema_version = requested_schema_version
352 .or(loaded.schema_version)
353 .unwrap_or_else(|| DEFAULT_SCHEMA_VERSION.to_string());
354 let doc = build_answer_document(&locale, &schema_version, &merged_answers, &plan);
355 write_answer_document(&path, &doc)?;
356 }
357
358 Ok(())
359}
360
361fn paths_for_provider(out: Option<&Path>) -> Result<persistence::PersistedPaths> {
362 let out_dir = persistence::resolve_out_dir(out);
363 persistence::prepare_dir(&out_dir)
364}
365
366fn persist_delegated_answers_if_present(
367 paths: &persistence::PersistedPaths,
368 answers: &WizardAnswers,
369) -> Result<Option<PathBuf>> {
370 let Some(delegated_answers) = answers.data.get("delegate_answer_document") else {
371 return Ok(None);
372 };
373 if !delegated_answers.is_object() {
374 bail!("answers.delegate_answer_document must be a JSON object");
375 }
376 persistence::persist_delegated_answers(&paths.delegated_answers_path, delegated_answers)?;
377 Ok(Some(paths.delegated_answers_path.clone()))
378}
379
380fn render_plan(plan: &WizardPlan) -> Result<()> {
381 let rendered = match plan.metadata.frontend {
382 WizardFrontend::Json => {
383 serde_json::to_string_pretty(plan).context("failed to encode wizard plan")?
384 }
385 WizardFrontend::Text => render_text_plan(plan),
386 WizardFrontend::AdaptiveCard => {
387 let card = serde_json::json!({
388 "type": "AdaptiveCard",
389 "version": "1.5",
390 "body": [
391 {"type":"TextBlock","weight":"Bolder","text":"greentic-dev launcher wizard plan"},
392 {"type":"TextBlock","text": "target: launcher mode: main"},
393 ],
394 "data": { "plan": plan }
395 });
396 serde_json::to_string_pretty(&card).context("failed to encode adaptive card")?
397 }
398 };
399 println!("{rendered}");
400 Ok(())
401}
402
403fn render_text_plan(plan: &WizardPlan) -> String {
404 let mut out = String::new();
405 out.push_str(&format!(
406 "wizard plan v{}: {}.{}\n",
407 plan.plan_version, plan.metadata.target, plan.metadata.mode
408 ));
409 out.push_str(&format!("locale: {}\n", plan.metadata.locale));
410 out.push_str(&format!("steps: {}\n", plan.steps.len()));
411 for (idx, step) in plan.steps.iter().enumerate() {
412 match step {
413 crate::wizard::plan::WizardStep::RunCommand(cmd) => {
414 out.push_str(&format!(
415 "{}. RunCommand {} {}\n",
416 idx + 1,
417 cmd.program,
418 cmd.args.join(" ")
419 ));
420 }
421 other => out.push_str(&format!("{}. {:?}\n", idx + 1, other)),
422 }
423 }
424 out
425}
426
427fn prompt_launcher_answers(mode: ExecutionMode, locale: &str) -> Result<Option<serde_json::Value>> {
428 let interactive = io::stdin().is_terminal() && io::stdout().is_terminal();
429 if !interactive {
430 bail!(
431 "{}",
432 i18n::t(locale, "cli.wizard.error.interactive_required")
433 );
434 }
435
436 loop {
437 eprintln!("{}", i18n::t(locale, "cli.wizard.launcher.title"));
438 eprintln!();
439 eprintln!("{}", i18n::t(locale, "cli.wizard.launcher.option_pack"));
440 eprintln!("{}", i18n::t(locale, "cli.wizard.launcher.option_bundle"));
441 eprintln!("{}", i18n::t(locale, "cli.wizard.launcher.option_exit"));
442 eprintln!();
443 eprint!("{}", i18n::t(locale, "cli.wizard.launcher.select_option"));
444 io::stderr().flush()?;
445
446 let mut input = String::new();
447 io::stdin().read_line(&mut input)?;
448 match parse_launcher_menu_choice(input.trim(), true, locale)? {
449 LauncherMenuChoice::Pack => return Ok(Some(build_launcher_answers(mode, "pack"))),
450 LauncherMenuChoice::Bundle => return Ok(Some(build_launcher_answers(mode, "bundle"))),
451 LauncherMenuChoice::MainMenu => {
452 eprintln!();
453 continue;
454 }
455 LauncherMenuChoice::Exit => return Ok(None),
456 }
457 }
458}
459
460fn parse_launcher_menu_choice(
461 input: &str,
462 in_main_menu: bool,
463 locale: &str,
464) -> Result<LauncherMenuChoice> {
465 match input.trim() {
466 "1" if in_main_menu => Ok(LauncherMenuChoice::Pack),
467 "2" if in_main_menu => Ok(LauncherMenuChoice::Bundle),
468 "0" if in_main_menu => Ok(LauncherMenuChoice::Exit),
469 "0" => Ok(LauncherMenuChoice::MainMenu),
470 "m" | "M" => Ok(LauncherMenuChoice::MainMenu),
471 _ => bail!("{}", i18n::t(locale, "cli.wizard.error.invalid_selection")),
472 }
473}
474
475fn build_launcher_answers(mode: ExecutionMode, selected_action: &str) -> serde_json::Value {
476 let mut answers = serde_json::Map::new();
477 answers.insert(
478 "selected_action".to_string(),
479 serde_json::Value::String(selected_action.to_string()),
480 );
481 if mode == ExecutionMode::DryRun {
482 answers.insert(
483 "delegate_answer_document".to_string(),
484 serde_json::Value::Object(Default::default()),
485 );
486 }
487 serde_json::Value::Object(answers)
488}
489
490fn load_answer_document(
491 path_or_url: &str,
492 requested_schema_version: Option<&str>,
493 migrate: bool,
494) -> Result<LoadedAnswers> {
495 let mut doc = read_answer_document_from_path_or_url(path_or_url)?;
496 if is_launcher_answer_document(&doc) {
497 if let Some(schema_version) = requested_schema_version
498 && doc.schema_version != schema_version
499 {
500 if migrate {
501 doc = migrate_answer_document(doc, schema_version);
502 } else {
503 bail!(
504 "answers schema_version `{}` does not match requested `{}`; re-run with --migrate",
505 doc.schema_version,
506 schema_version
507 );
508 }
509 }
510
511 if !doc.answers.is_object() {
512 bail!(
513 "AnswerDocument `answers` must be a JSON object in {}",
514 path_or_url
515 );
516 }
517
518 return Ok(LoadedAnswers {
519 answers: doc.answers.clone(),
520 inferred_locale: Some(doc.locale),
521 schema_version: Some(doc.schema_version),
522 });
523 }
524
525 if let Some(selected_action) = delegated_selected_action(&doc) {
526 return Ok(LoadedAnswers {
527 answers: wrap_delegated_answer_document(selected_action, &doc),
528 inferred_locale: Some(doc.locale),
529 schema_version: Some(
530 requested_schema_version
531 .unwrap_or(DEFAULT_SCHEMA_VERSION)
532 .to_string(),
533 ),
534 });
535 }
536
537 validate_answer_document_identity(&doc, path_or_url)?;
538 unreachable!("launcher identity validation must error for unsupported documents");
539}
540
541fn read_answer_document(path: &Path) -> Result<AnswerDocument> {
542 let raw =
543 fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
544 let value: serde_json::Value = serde_json::from_str(&raw)
545 .with_context(|| format!("failed to parse {}", path.display()))?;
546 serde_json::from_value(value)
547 .with_context(|| format!("failed to parse AnswerDocument from {}", path.display()))
548}
549
550fn read_answer_document_from_path_or_url(path_or_url: &str) -> Result<AnswerDocument> {
551 let raw = if path_or_url.starts_with("http://") || path_or_url.starts_with("https://") {
552 let client = reqwest::blocking::Client::builder()
554 .timeout(std::time::Duration::from_secs(30))
555 .build()
556 .with_context(|| "failed to create HTTP client")?;
557 let response = client
558 .get(path_or_url)
559 .send()
560 .with_context(|| format!("failed to fetch {}", path_or_url))?;
561 if !response.status().is_success() {
562 bail!(
563 "failed to fetch {}: HTTP {}",
564 path_or_url,
565 response.status()
566 );
567 }
568 response
569 .text()
570 .with_context(|| format!("failed to read response from {}", path_or_url))?
571 } else {
572 let path = Path::new(path_or_url);
573 fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?
574 };
575 let value: serde_json::Value =
576 serde_json::from_str(&raw).with_context(|| format!("failed to parse {}", path_or_url))?;
577 serde_json::from_value(value)
578 .with_context(|| format!("failed to parse AnswerDocument from {}", path_or_url))
579}
580
581fn validate_answer_document_identity(doc: &AnswerDocument, path_or_url: &str) -> Result<()> {
582 if !is_launcher_answer_document(doc) {
583 bail!(
584 "unsupported wizard_id `{}` in {}; expected `{}`",
585 doc.wizard_id,
586 path_or_url,
587 WIZARD_ID
588 );
589 }
590 if doc.schema_id != SCHEMA_ID {
591 bail!(
592 "unsupported schema_id `{}` in {}; expected `{}`",
593 doc.schema_id,
594 path_or_url,
595 SCHEMA_ID
596 );
597 }
598 Ok(())
599}
600
601fn is_launcher_answer_document(doc: &AnswerDocument) -> bool {
602 doc.wizard_id == WIZARD_ID && doc.schema_id == SCHEMA_ID
603}
604
605fn delegated_selected_action(doc: &AnswerDocument) -> Option<&'static str> {
606 if doc.wizard_id.starts_with(BUNDLE_WIZARD_ID_PREFIX) {
607 Some("bundle")
608 } else if doc.wizard_id.starts_with(PACK_WIZARD_ID_PREFIX) {
609 Some("pack")
610 } else {
611 None
612 }
613}
614
615fn wrap_delegated_answer_document(
616 selected_action: &str,
617 doc: &AnswerDocument,
618) -> serde_json::Value {
619 serde_json::json!({
620 "selected_action": selected_action,
621 "delegate_answer_document": doc,
622 })
623}
624
625fn merge_answers(
626 cli_overrides: Option<serde_json::Value>,
627 parent_prefill: Option<serde_json::Value>,
628 answers_file: Option<serde_json::Value>,
629 provider_defaults: Option<serde_json::Value>,
630) -> WizardAnswers {
631 let mut out = BTreeMap::<String, serde_json::Value>::new();
632 merge_obj(&mut out, provider_defaults);
633 merge_obj(&mut out, answers_file);
634 merge_obj(&mut out, parent_prefill);
635 merge_obj(&mut out, cli_overrides);
636 WizardAnswers {
637 data: serde_json::Value::Object(out.into_iter().collect()),
638 }
639}
640
641fn merge_obj(dst: &mut BTreeMap<String, serde_json::Value>, src: Option<serde_json::Value>) {
642 if let Some(serde_json::Value::Object(map)) = src {
643 for (k, v) in map {
644 dst.insert(k, v);
645 }
646 }
647}
648
649fn migrate_answer_document(mut doc: AnswerDocument, target_schema_version: &str) -> AnswerDocument {
650 doc.schema_version = target_schema_version.to_string();
651 doc
652}
653
654fn build_answer_document(
655 locale: &str,
656 schema_version: &str,
657 answers: &WizardAnswers,
658 plan: &WizardPlan,
659) -> AnswerDocument {
660 let locks = plan
661 .inputs
662 .iter()
663 .map(|(key, value)| (key.clone(), serde_json::Value::String(value.clone())))
664 .collect();
665 AnswerDocument {
666 wizard_id: WIZARD_ID.to_string(),
667 schema_id: SCHEMA_ID.to_string(),
668 schema_version: schema_version.to_string(),
669 locale: locale.to_string(),
670 answers: answers.data.clone(),
671 locks,
672 }
673}
674
675fn build_interactive_answer_document(
676 locale: &str,
677 schema_version: &str,
678 selected_action: &str,
679 delegated_doc: &AnswerDocument,
680) -> AnswerDocument {
681 AnswerDocument {
682 wizard_id: WIZARD_ID.to_string(),
683 schema_id: SCHEMA_ID.to_string(),
684 schema_version: schema_version.to_string(),
685 locale: locale.to_string(),
686 answers: wrap_delegated_answer_document(selected_action, delegated_doc),
687 locks: serde_json::Map::new(),
688 }
689}
690
691struct DelegatedEmitCapture {
692 _temp_dir: Option<TempDir>,
693 path: Option<PathBuf>,
694}
695
696fn delegated_emit_capture(emit_answers: Option<&Path>) -> Result<DelegatedEmitCapture> {
697 let Some(_) = emit_answers else {
698 return Ok(DelegatedEmitCapture {
699 _temp_dir: None,
700 path: None,
701 });
702 };
703 let temp_dir = tempfile::Builder::new()
704 .prefix("greentic-dev-wizard-delegate-")
705 .tempdir()
706 .context("failed to create tempdir for delegated answers capture")?;
707 let path = temp_dir.path().join("delegated-answers.json");
708 Ok(DelegatedEmitCapture {
709 _temp_dir: Some(temp_dir),
710 path: Some(path),
711 })
712}
713
714fn write_answer_document(path: &Path, doc: &AnswerDocument) -> Result<()> {
715 let rendered = serde_json::to_string_pretty(doc).context("render answers envelope JSON")?;
716 fs::write(path, rendered).with_context(|| format!("failed to write {}", path.display()))
717}
718
719fn annotate_execution_metadata(
720 plan: &mut WizardPlan,
721 report: &crate::wizard::executor::ExecutionReport,
722) {
723 for (program, version) in &report.resolved_versions {
724 plan.inputs
725 .insert(format!("resolved_versions.{program}"), version.clone());
726 }
727 plan.inputs.insert(
728 "executed_commands".to_string(),
729 report.commands_executed.to_string(),
730 );
731}
732
733#[cfg(test)]
734mod tests {
735 use std::collections::BTreeMap;
736 use std::fs;
737 use std::path::Path;
738 use std::path::PathBuf;
739
740 use serde_json::json;
741 use tempfile::TempDir;
742
743 use super::{
744 AnswerDocument, LauncherMenuChoice, SCHEMA_ID, WIZARD_ID, build_answer_document,
745 build_interactive_answer_document, build_launcher_answers, interactive_delegate_args,
746 is_launcher_answer_document, merge_answers, parse_launcher_menu_choice,
747 run_interactive_delegate, validate_answer_document_identity,
748 wrap_delegated_answer_document,
749 };
750 use crate::wizard::plan::{WizardFrontend, WizardPlan, WizardPlanMetadata};
751
752 fn write_stub_bin(dir: &Path, name: &str, body: &str) -> PathBuf {
753 #[cfg(windows)]
754 let path = dir.join(format!("{name}.cmd"));
755 #[cfg(not(windows))]
756 let path = dir.join(name);
757
758 #[cfg(windows)]
759 let script = format!("@echo off\r\n{body}\r\n");
760 #[cfg(not(windows))]
761 let script = format!("#!/bin/sh\n{body}\n");
762
763 fs::write(&path, script).expect("write stub");
764 #[cfg(not(windows))]
765 {
766 use std::os::unix::fs::PermissionsExt;
767 let mut perms = fs::metadata(&path).expect("metadata").permissions();
768 perms.set_mode(0o755);
769 fs::set_permissions(&path, perms).expect("set perms");
770 }
771 path
772 }
773
774 fn prepend_path(dir: &Path) -> String {
775 let old = std::env::var("PATH").unwrap_or_default();
776 let sep = if cfg!(windows) { ';' } else { ':' };
777 format!("{}{}{}", dir.display(), sep, old)
778 }
779
780 #[test]
781 fn answer_precedence_cli_over_file() {
782 let merged = merge_answers(
783 Some(json!({"foo":"cli"})),
784 None,
785 Some(json!({"foo":"file","bar":"file"})),
786 None,
787 );
788 assert_eq!(merged.data["foo"], "cli");
789 assert_eq!(merged.data["bar"], "file");
790 }
791
792 #[test]
793 fn build_answer_document_sets_launcher_identity_fields() {
794 let answers = merge_answers(None, None, Some(json!({"selected_action":"pack"})), None);
795 let plan = WizardPlan {
796 plan_version: 1,
797 created_at: None,
798 metadata: WizardPlanMetadata {
799 target: "launcher".to_string(),
800 mode: "main".to_string(),
801 locale: "en-US".to_string(),
802 frontend: WizardFrontend::Json,
803 },
804 inputs: BTreeMap::from([(
805 "resolved_versions.greentic-pack".to_string(),
806 "greentic-pack 0.1".to_string(),
807 )]),
808 steps: vec![],
809 };
810
811 let doc = build_answer_document("en-US", "1.0.0", &answers, &plan);
812
813 assert_eq!(doc.wizard_id, WIZARD_ID);
814 assert_eq!(doc.schema_id, SCHEMA_ID);
815 assert_eq!(doc.schema_version, "1.0.0");
816 assert_eq!(doc.locale, "en-US");
817 assert_eq!(doc.answers["selected_action"], "pack");
818 assert_eq!(
819 doc.locks.get("resolved_versions.greentic-pack"),
820 Some(&json!("greentic-pack 0.1"))
821 );
822 }
823
824 #[test]
825 fn reject_non_launcher_answer_document_id() {
826 let doc = AnswerDocument {
827 wizard_id: "greentic-dev.wizard.pack.build".to_string(),
828 schema_id: SCHEMA_ID.to_string(),
829 schema_version: "1.0.0".to_string(),
830 locale: "en-US".to_string(),
831 answers: json!({}),
832 locks: serde_json::Map::new(),
833 };
834 let err = validate_answer_document_identity(&doc, "answers.json").unwrap_err();
835 assert!(err.to_string().contains("unsupported wizard_id"));
836 }
837
838 #[test]
839 fn launcher_identity_matches_expected_pair() {
840 let doc = AnswerDocument {
841 wizard_id: WIZARD_ID.to_string(),
842 schema_id: SCHEMA_ID.to_string(),
843 schema_version: "1.0.0".to_string(),
844 locale: "en-US".to_string(),
845 answers: json!({}),
846 locks: serde_json::Map::new(),
847 };
848 assert!(is_launcher_answer_document(&doc));
849 }
850
851 #[test]
852 fn wrap_delegated_bundle_document_builds_launcher_shape() {
853 let doc = AnswerDocument {
854 wizard_id: "greentic-bundle.wizard.main".to_string(),
855 schema_id: "greentic-bundle.main".to_string(),
856 schema_version: "1.0.0".to_string(),
857 locale: "en-US".to_string(),
858 answers: json!({"selected_action":"create"}),
859 locks: serde_json::Map::new(),
860 };
861 let wrapped = wrap_delegated_answer_document("bundle", &doc);
862 assert_eq!(wrapped["selected_action"], "bundle");
863 assert_eq!(
864 wrapped["delegate_answer_document"]["wizard_id"],
865 "greentic-bundle.wizard.main"
866 );
867 }
868
869 #[test]
870 fn parse_main_menu_navigation_keys() {
871 assert_eq!(
872 parse_launcher_menu_choice("1", true, "en-US").expect("parse"),
873 LauncherMenuChoice::Pack
874 );
875 assert_eq!(
876 parse_launcher_menu_choice("2", true, "en-US").expect("parse"),
877 LauncherMenuChoice::Bundle
878 );
879 assert_eq!(
880 parse_launcher_menu_choice("0", true, "en-US").expect("parse"),
881 LauncherMenuChoice::Exit
882 );
883 assert_eq!(
884 parse_launcher_menu_choice("M", true, "en-US").expect("parse"),
885 LauncherMenuChoice::MainMenu
886 );
887 }
888
889 #[test]
890 fn parse_nested_menu_zero_returns_to_main_menu() {
891 assert_eq!(
892 parse_launcher_menu_choice("0", false, "en-US").expect("parse"),
893 LauncherMenuChoice::MainMenu
894 );
895 }
896
897 #[test]
898 fn build_launcher_answers_includes_selected_action() {
899 let answers = build_launcher_answers(super::ExecutionMode::DryRun, "bundle");
900 assert_eq!(answers["selected_action"], "bundle");
901 assert!(answers.get("delegate_answer_document").is_some());
902 }
903
904 #[test]
905 fn build_interactive_answer_document_wraps_delegate() {
906 let delegated = AnswerDocument {
907 wizard_id: "greentic-bundle.wizard.main".to_string(),
908 schema_id: "greentic-bundle.main".to_string(),
909 schema_version: "1.0.0".to_string(),
910 locale: "en-US".to_string(),
911 answers: json!({"selected_action":"create"}),
912 locks: serde_json::Map::new(),
913 };
914
915 let doc = build_interactive_answer_document("en-US", "1.2.3", "bundle", &delegated);
916
917 assert_eq!(doc.wizard_id, WIZARD_ID);
918 assert_eq!(doc.schema_id, SCHEMA_ID);
919 assert_eq!(doc.schema_version, "1.2.3");
920 assert_eq!(doc.answers["selected_action"], "bundle");
921 assert_eq!(
922 doc.answers["delegate_answer_document"]["wizard_id"],
923 "greentic-bundle.wizard.main"
924 );
925 }
926
927 #[test]
928 fn bundle_delegate_receives_locale_flag() {
929 assert_eq!(
930 interactive_delegate_args("greentic-bundle", "en-GB", None),
931 vec!["--locale", "en-GB", "wizard"]
932 );
933 }
934
935 #[test]
936 fn pack_delegate_keeps_plain_wizard_args() {
937 assert_eq!(
938 interactive_delegate_args("greentic-pack", "en-GB", None),
939 vec!["wizard"]
940 );
941 }
942
943 #[test]
944 fn bundle_delegate_emit_answers_uses_run_subcommand() {
945 assert_eq!(
946 interactive_delegate_args(
947 "greentic-bundle",
948 "en-GB",
949 Some(Path::new("/tmp/emitted.json"))
950 ),
951 vec![
952 "--locale",
953 "en-GB",
954 "wizard",
955 "run",
956 "--emit-answers",
957 "/tmp/emitted.json",
958 ]
959 );
960 }
961
962 #[test]
963 fn pack_delegate_emit_answers_uses_run_subcommand() {
964 assert_eq!(
965 interactive_delegate_args(
966 "greentic-pack",
967 "en-GB",
968 Some(Path::new("/tmp/emitted.json"))
969 ),
970 vec!["wizard", "run", "--emit-answers", "/tmp/emitted.json"]
971 );
972 }
973
974 #[test]
975 fn interactive_bundle_delegate_emit_answers_writes_launcher_document() {
976 let tmp = TempDir::new().expect("temp dir");
977 let bin_dir = tmp.path().join("bin");
978 fs::create_dir_all(&bin_dir).expect("create bin dir");
979 let emitted = tmp.path().join("answers-envelope.json");
980 let runlog = tmp.path().join("bundle-run.log");
981 let original_path = std::env::var_os("PATH");
982
983 write_stub_bin(
984 &bin_dir,
985 "greentic-bundle",
986 &format!(
987 r#"
988echo "$@" > "{}"
989if [ "$1" != "--locale" ] || [ "$2" != "en-US" ] || [ "$3" != "wizard" ] || [ "$4" != "run" ] || [ "$5" != "--emit-answers" ]; then
990 echo "unexpected argv: $@" >&2
991 exit 9
992fi
993cat > "$6" <<'EOF'
994{{
995 "wizard_id": "greentic-bundle.wizard.main",
996 "schema_id": "greentic-bundle.main",
997 "schema_version": "1.0.0",
998 "locale": "en-US",
999 "answers": {{
1000 "selected_action": "create"
1001 }},
1002 "locks": {{}}
1003}}
1004EOF
1005exit 0
1006"#,
1007 runlog.display()
1008 ),
1009 );
1010
1011 unsafe {
1012 std::env::set_var("PATH", prepend_path(&bin_dir));
1013 }
1014 let result = run_interactive_delegate(
1015 &json!({"selected_action":"bundle"}),
1016 "en-US",
1017 Some(&emitted),
1018 Some("1.2.3"),
1019 );
1020 if let Some(path) = original_path {
1021 unsafe {
1022 std::env::set_var("PATH", path);
1023 }
1024 } else {
1025 unsafe {
1026 std::env::remove_var("PATH");
1027 }
1028 }
1029
1030 result.expect("interactive delegate succeeds");
1031
1032 let argv = fs::read_to_string(&runlog).expect("read run log");
1033 assert!(argv.contains("wizard run --emit-answers"));
1034 assert!(
1035 !argv.contains("wizard --emit-answers"),
1036 "bundle delegate should not receive unsupported bare wizard emit flags"
1037 );
1038
1039 let emitted_doc: serde_json::Value =
1040 serde_json::from_str(&fs::read_to_string(&emitted).expect("read emitted answers"))
1041 .expect("parse emitted answers");
1042 assert_eq!(emitted_doc["wizard_id"], WIZARD_ID);
1043 assert_eq!(emitted_doc["schema_id"], SCHEMA_ID);
1044 assert_eq!(emitted_doc["schema_version"], "1.2.3");
1045 assert_eq!(emitted_doc["answers"]["selected_action"], "bundle");
1046 assert_eq!(
1047 emitted_doc["answers"]["delegate_answer_document"]["wizard_id"],
1048 "greentic-bundle.wizard.main"
1049 );
1050 }
1051}