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