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