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