1mod confirm;
2mod executor;
3mod persistence;
4pub mod plan;
5mod provider;
6mod registry;
7
8use std::collections::BTreeMap;
9use std::fs;
10use std::io::{self, IsTerminal, Write};
11use std::path::{Path, PathBuf};
12use std::process::{Command, Stdio};
13
14use anyhow::{Context, Result, bail};
15use serde::{Deserialize, Serialize};
16
17use crate::cli::{WizardApplyArgs, WizardLaunchArgs, WizardValidateArgs};
18use crate::i18n;
19use crate::passthrough::resolve_binary;
20use crate::wizard::executor::ExecuteOptions;
21use crate::wizard::plan::{WizardAnswers, WizardFrontend, WizardPlan};
22use crate::wizard::provider::{ProviderRequest, ShellWizardProvider, WizardProvider};
23
24const DEFAULT_LOCALE: &str = "en-US";
25const DEFAULT_SCHEMA_VERSION: &str = "1.0.0";
26const WIZARD_ID: &str = "greentic-dev.wizard.launcher.main";
27const SCHEMA_ID: &str = "greentic-dev.launcher.main";
28const EMBEDDED_WIZARD_ROOT_ZERO_ACTION_ENV: &str = "GREENTIC_WIZARD_ROOT_ZERO_ACTION";
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31enum ExecutionMode {
32 DryRun,
33 Execute,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37enum LauncherMenuChoice {
38 Pack,
39 Bundle,
40 MainMenu,
41 Exit,
42}
43
44#[derive(Debug, Clone)]
45struct LoadedAnswers {
46 answers: serde_json::Value,
47 inferred_locale: Option<String>,
48 schema_version: Option<String>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
52struct AnswerDocument {
53 wizard_id: String,
54 schema_id: String,
55 schema_version: String,
56 locale: String,
57 answers: serde_json::Value,
58 #[serde(default)]
59 locks: BTreeMap<String, String>,
60}
61
62pub fn launch(args: WizardLaunchArgs) -> Result<()> {
63 let mode = if args.dry_run {
64 ExecutionMode::DryRun
65 } else {
66 ExecutionMode::Execute
67 };
68
69 let locale = i18n::select_locale(args.locale.as_deref());
70 if mode == ExecutionMode::DryRun {
71 let Some(answers) = prompt_launcher_answers(mode, &locale)? else {
72 return Ok(());
73 };
74 let loaded = LoadedAnswers {
75 answers,
76 inferred_locale: None,
77 schema_version: args.schema_version.clone(),
78 };
79
80 return run_from_inputs(
81 args.frontend,
82 Some(locale),
83 loaded,
84 args.out,
85 mode,
86 args.yes,
87 args.non_interactive,
88 args.unsafe_commands,
89 args.allow_destructive,
90 args.emit_answers,
91 args.schema_version,
92 );
93 }
94
95 loop {
96 let Some(answers) = prompt_launcher_answers(mode, &locale)? else {
97 return Ok(());
98 };
99
100 run_interactive_delegate(&answers, &locale)?;
101 }
102}
103
104fn run_interactive_delegate(answers: &serde_json::Value, locale: &str) -> Result<()> {
105 let selected_action = answers
106 .get("selected_action")
107 .and_then(|value| value.as_str())
108 .ok_or_else(|| anyhow::anyhow!("missing required answers.selected_action"))?;
109
110 let program = match selected_action {
111 "pack" => "greentic-pack",
112 "bundle" => "greentic-bundle",
113 other => bail!("unsupported selected_action `{other}`; expected `pack` or `bundle`"),
114 };
115
116 let bin = resolve_binary(program)?;
117 let mut command = Command::new(&bin);
118 command
119 .args(interactive_delegate_args(program, locale))
120 .env("LANG", locale)
121 .env("LC_ALL", locale)
122 .env("LC_MESSAGES", locale)
123 .stdin(Stdio::inherit())
124 .stdout(Stdio::inherit())
125 .stderr(Stdio::inherit());
126 if program == "greentic-bundle" {
127 command.env(EMBEDDED_WIZARD_ROOT_ZERO_ACTION_ENV, "back");
128 }
129 let status = command
130 .status()
131 .map_err(|e| anyhow::anyhow!("failed to execute {}: {e}", bin.display()))?;
132 if status.success() {
133 Ok(())
134 } else {
135 bail!(
136 "wizard step command failed: {} {:?} (exit code {:?})",
137 program,
138 ["wizard"],
139 status.code()
140 );
141 }
142}
143
144fn interactive_delegate_args(program: &str, locale: &str) -> Vec<String> {
145 if program == "greentic-bundle" {
146 vec![
147 "--locale".to_string(),
148 locale.to_string(),
149 "wizard".to_string(),
150 ]
151 } else {
152 vec!["wizard".to_string()]
153 }
154}
155
156pub fn validate(args: WizardValidateArgs) -> Result<()> {
157 let loaded = load_answer_document(
158 args.answers.as_path(),
159 args.schema_version.as_deref(),
160 args.migrate,
161 )?;
162
163 run_from_inputs(
164 args.frontend,
165 args.locale,
166 loaded,
167 args.out,
168 ExecutionMode::DryRun,
169 true,
170 true,
171 false,
172 false,
173 args.emit_answers,
174 args.schema_version,
175 )
176}
177
178pub fn apply(args: WizardApplyArgs) -> Result<()> {
179 let loaded = load_answer_document(
180 args.answers.as_path(),
181 args.schema_version.as_deref(),
182 args.migrate,
183 )?;
184
185 run_from_inputs(
186 args.frontend,
187 args.locale,
188 loaded,
189 args.out,
190 ExecutionMode::Execute,
191 args.yes,
192 args.non_interactive,
193 args.unsafe_commands,
194 args.allow_destructive,
195 args.emit_answers,
196 args.schema_version,
197 )
198}
199
200#[allow(clippy::too_many_arguments)]
201fn run_from_inputs(
202 frontend_raw: String,
203 cli_locale: Option<String>,
204 loaded: LoadedAnswers,
205 out: Option<PathBuf>,
206 mode: ExecutionMode,
207 yes: bool,
208 non_interactive: bool,
209 unsafe_commands: bool,
210 allow_destructive: bool,
211 emit_answers: Option<PathBuf>,
212 requested_schema_version: Option<String>,
213) -> Result<()> {
214 let locale = i18n::select_locale(
215 cli_locale
216 .as_deref()
217 .or(loaded.inferred_locale.as_deref())
218 .or(Some(DEFAULT_LOCALE)),
219 );
220 let frontend = WizardFrontend::parse(&frontend_raw).ok_or_else(|| {
221 anyhow::anyhow!(
222 "unsupported frontend `{}`; expected text|json|adaptive-card",
223 frontend_raw
224 )
225 })?;
226
227 if registry::resolve("launcher", "main").is_none() {
228 bail!("launcher mapping missing for `launcher.main`");
229 }
230
231 let merged_answers = merge_answers(None, None, Some(loaded.answers.clone()), None);
232 let provider = ShellWizardProvider;
233 let req = ProviderRequest {
234 frontend: frontend.clone(),
235 locale: locale.clone(),
236 dry_run: mode == ExecutionMode::DryRun,
237 answers: merged_answers.clone(),
238 };
239 let mut plan = provider.build_plan(&req)?;
240
241 let out_dir = persistence::resolve_out_dir(out.as_deref());
242 let paths = persistence::prepare_dir(&out_dir)?;
243 persistence::persist_plan_and_answers(&paths, &merged_answers, &plan)?;
244
245 render_plan(&plan)?;
246
247 if mode == ExecutionMode::Execute {
248 confirm::ensure_execute_allowed(
249 &crate::i18n::tf(
250 &locale,
251 "runtime.wizard.confirm.summary",
252 &[
253 ("target", plan.metadata.target.clone()),
254 ("mode", plan.metadata.mode.clone()),
255 ("step_count", plan.steps.len().to_string()),
256 ],
257 ),
258 yes,
259 non_interactive,
260 &locale,
261 )?;
262 let report = executor::execute(
263 &plan,
264 &paths.exec_log_path,
265 &ExecuteOptions {
266 unsafe_commands,
267 allow_destructive,
268 locale: locale.clone(),
269 },
270 )?;
271 annotate_execution_metadata(&mut plan, &report);
272 persistence::persist_plan_and_answers(&paths, &merged_answers, &plan)?;
273 }
274
275 if let Some(path) = emit_answers {
276 let schema_version = requested_schema_version
277 .or(loaded.schema_version)
278 .unwrap_or_else(|| DEFAULT_SCHEMA_VERSION.to_string());
279 let doc = build_answer_document(&locale, &schema_version, &merged_answers, &plan);
280 write_answer_document(&path, &doc)?;
281 }
282
283 Ok(())
284}
285
286fn render_plan(plan: &WizardPlan) -> Result<()> {
287 let rendered = match plan.metadata.frontend {
288 WizardFrontend::Json => {
289 serde_json::to_string_pretty(plan).context("failed to encode wizard plan")?
290 }
291 WizardFrontend::Text => render_text_plan(plan),
292 WizardFrontend::AdaptiveCard => {
293 let card = serde_json::json!({
294 "type": "AdaptiveCard",
295 "version": "1.5",
296 "body": [
297 {"type":"TextBlock","weight":"Bolder","text":"greentic-dev launcher wizard plan"},
298 {"type":"TextBlock","text": "target: launcher mode: main"},
299 ],
300 "data": { "plan": plan }
301 });
302 serde_json::to_string_pretty(&card).context("failed to encode adaptive card")?
303 }
304 };
305 println!("{rendered}");
306 Ok(())
307}
308
309fn render_text_plan(plan: &WizardPlan) -> String {
310 let mut out = String::new();
311 out.push_str(&format!(
312 "wizard plan v{}: {}.{}\n",
313 plan.plan_version, plan.metadata.target, plan.metadata.mode
314 ));
315 out.push_str(&format!("locale: {}\n", plan.metadata.locale));
316 out.push_str(&format!("steps: {}\n", plan.steps.len()));
317 for (idx, step) in plan.steps.iter().enumerate() {
318 match step {
319 crate::wizard::plan::WizardStep::RunCommand(cmd) => {
320 out.push_str(&format!(
321 "{}. RunCommand {} {}\n",
322 idx + 1,
323 cmd.program,
324 cmd.args.join(" ")
325 ));
326 }
327 other => out.push_str(&format!("{}. {:?}\n", idx + 1, other)),
328 }
329 }
330 out
331}
332
333fn prompt_launcher_answers(mode: ExecutionMode, locale: &str) -> Result<Option<serde_json::Value>> {
334 let interactive = io::stdin().is_terminal() && io::stdout().is_terminal();
335 if !interactive {
336 bail!(
337 "{}",
338 i18n::t(locale, "cli.wizard.error.interactive_required")
339 );
340 }
341
342 loop {
343 eprintln!("{}", i18n::t(locale, "cli.wizard.launcher.title"));
344 eprintln!();
345 eprintln!("{}", i18n::t(locale, "cli.wizard.launcher.option_pack"));
346 eprintln!("{}", i18n::t(locale, "cli.wizard.launcher.option_bundle"));
347 eprintln!("{}", i18n::t(locale, "cli.wizard.launcher.option_exit"));
348 eprintln!();
349 eprint!("{}", i18n::t(locale, "cli.wizard.launcher.select_option"));
350 io::stderr().flush()?;
351
352 let mut input = String::new();
353 io::stdin().read_line(&mut input)?;
354 match parse_launcher_menu_choice(input.trim(), true, locale)? {
355 LauncherMenuChoice::Pack => return Ok(Some(build_launcher_answers(mode, "pack"))),
356 LauncherMenuChoice::Bundle => return Ok(Some(build_launcher_answers(mode, "bundle"))),
357 LauncherMenuChoice::MainMenu => {
358 eprintln!();
359 continue;
360 }
361 LauncherMenuChoice::Exit => return Ok(None),
362 }
363 }
364}
365
366fn parse_launcher_menu_choice(
367 input: &str,
368 in_main_menu: bool,
369 locale: &str,
370) -> Result<LauncherMenuChoice> {
371 match input.trim() {
372 "1" if in_main_menu => Ok(LauncherMenuChoice::Pack),
373 "2" if in_main_menu => Ok(LauncherMenuChoice::Bundle),
374 "0" if in_main_menu => Ok(LauncherMenuChoice::Exit),
375 "0" => Ok(LauncherMenuChoice::MainMenu),
376 "m" | "M" => Ok(LauncherMenuChoice::MainMenu),
377 _ => bail!("{}", i18n::t(locale, "cli.wizard.error.invalid_selection")),
378 }
379}
380
381fn build_launcher_answers(mode: ExecutionMode, selected_action: &str) -> serde_json::Value {
382 let mut answers = serde_json::Map::new();
383 answers.insert(
384 "selected_action".to_string(),
385 serde_json::Value::String(selected_action.to_string()),
386 );
387 if mode == ExecutionMode::DryRun {
388 answers.insert(
389 "delegate_answer_document".to_string(),
390 serde_json::Value::Object(Default::default()),
391 );
392 }
393 serde_json::Value::Object(answers)
394}
395
396fn load_answer_document(
397 path: &Path,
398 requested_schema_version: Option<&str>,
399 migrate: bool,
400) -> Result<LoadedAnswers> {
401 let raw =
402 fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
403 let value: serde_json::Value = serde_json::from_str(&raw)
404 .with_context(|| format!("failed to parse {}", path.display()))?;
405
406 let mut doc: AnswerDocument = serde_json::from_value(value)
407 .with_context(|| format!("failed to parse AnswerDocument from {}", path.display()))?;
408 validate_answer_document_identity(&doc, path)?;
409
410 if let Some(schema_version) = requested_schema_version
411 && doc.schema_version != schema_version
412 {
413 if migrate {
414 doc = migrate_answer_document(doc, schema_version);
415 } else {
416 bail!(
417 "answers schema_version `{}` does not match requested `{}`; re-run with --migrate",
418 doc.schema_version,
419 schema_version
420 );
421 }
422 }
423
424 if !doc.answers.is_object() {
425 bail!(
426 "AnswerDocument `answers` must be a JSON object in {}",
427 path.display()
428 );
429 }
430
431 Ok(LoadedAnswers {
432 answers: doc.answers.clone(),
433 inferred_locale: Some(doc.locale),
434 schema_version: Some(doc.schema_version),
435 })
436}
437
438fn validate_answer_document_identity(doc: &AnswerDocument, path: &Path) -> Result<()> {
439 if doc.wizard_id != WIZARD_ID {
440 bail!(
441 "unsupported wizard_id `{}` in {}; expected `{}`",
442 doc.wizard_id,
443 path.display(),
444 WIZARD_ID
445 );
446 }
447 if doc.schema_id != SCHEMA_ID {
448 bail!(
449 "unsupported schema_id `{}` in {}; expected `{}`",
450 doc.schema_id,
451 path.display(),
452 SCHEMA_ID
453 );
454 }
455 Ok(())
456}
457
458fn merge_answers(
459 cli_overrides: Option<serde_json::Value>,
460 parent_prefill: Option<serde_json::Value>,
461 answers_file: Option<serde_json::Value>,
462 provider_defaults: Option<serde_json::Value>,
463) -> WizardAnswers {
464 let mut out = BTreeMap::<String, serde_json::Value>::new();
465 merge_obj(&mut out, provider_defaults);
466 merge_obj(&mut out, answers_file);
467 merge_obj(&mut out, parent_prefill);
468 merge_obj(&mut out, cli_overrides);
469 WizardAnswers {
470 data: serde_json::Value::Object(out.into_iter().collect()),
471 }
472}
473
474fn merge_obj(dst: &mut BTreeMap<String, serde_json::Value>, src: Option<serde_json::Value>) {
475 if let Some(serde_json::Value::Object(map)) = src {
476 for (k, v) in map {
477 dst.insert(k, v);
478 }
479 }
480}
481
482fn migrate_answer_document(mut doc: AnswerDocument, target_schema_version: &str) -> AnswerDocument {
483 doc.schema_version = target_schema_version.to_string();
484 doc
485}
486
487fn build_answer_document(
488 locale: &str,
489 schema_version: &str,
490 answers: &WizardAnswers,
491 plan: &WizardPlan,
492) -> AnswerDocument {
493 AnswerDocument {
494 wizard_id: WIZARD_ID.to_string(),
495 schema_id: SCHEMA_ID.to_string(),
496 schema_version: schema_version.to_string(),
497 locale: locale.to_string(),
498 answers: answers.data.clone(),
499 locks: plan.inputs.clone(),
500 }
501}
502
503fn write_answer_document(path: &Path, doc: &AnswerDocument) -> Result<()> {
504 let rendered = serde_json::to_string_pretty(doc).context("render answers envelope JSON")?;
505 fs::write(path, rendered).with_context(|| format!("failed to write {}", path.display()))
506}
507
508fn annotate_execution_metadata(
509 plan: &mut WizardPlan,
510 report: &crate::wizard::executor::ExecutionReport,
511) {
512 for (program, version) in &report.resolved_versions {
513 plan.inputs
514 .insert(format!("resolved_versions.{program}"), version.clone());
515 }
516 plan.inputs.insert(
517 "executed_commands".to_string(),
518 report.commands_executed.to_string(),
519 );
520}
521
522#[cfg(test)]
523mod tests {
524 use std::collections::BTreeMap;
525 use std::path::Path;
526
527 use serde_json::json;
528
529 use super::{
530 AnswerDocument, LauncherMenuChoice, SCHEMA_ID, WIZARD_ID, build_answer_document,
531 build_launcher_answers, interactive_delegate_args, merge_answers,
532 parse_launcher_menu_choice, validate_answer_document_identity,
533 };
534 use crate::wizard::plan::{WizardFrontend, WizardPlan, WizardPlanMetadata};
535
536 #[test]
537 fn answer_precedence_cli_over_file() {
538 let merged = merge_answers(
539 Some(json!({"foo":"cli"})),
540 None,
541 Some(json!({"foo":"file","bar":"file"})),
542 None,
543 );
544 assert_eq!(merged.data["foo"], "cli");
545 assert_eq!(merged.data["bar"], "file");
546 }
547
548 #[test]
549 fn build_answer_document_sets_launcher_identity_fields() {
550 let answers = merge_answers(None, None, Some(json!({"selected_action":"pack"})), None);
551 let plan = WizardPlan {
552 plan_version: 1,
553 created_at: None,
554 metadata: WizardPlanMetadata {
555 target: "launcher".to_string(),
556 mode: "main".to_string(),
557 locale: "en-US".to_string(),
558 frontend: WizardFrontend::Json,
559 },
560 inputs: BTreeMap::from([(
561 "resolved_versions.greentic-pack".to_string(),
562 "greentic-pack 0.1".to_string(),
563 )]),
564 steps: vec![],
565 };
566
567 let doc = build_answer_document("en-US", "1.0.0", &answers, &plan);
568
569 assert_eq!(doc.wizard_id, WIZARD_ID);
570 assert_eq!(doc.schema_id, SCHEMA_ID);
571 assert_eq!(doc.schema_version, "1.0.0");
572 assert_eq!(doc.locale, "en-US");
573 assert_eq!(doc.answers["selected_action"], "pack");
574 assert_eq!(
575 doc.locks.get("resolved_versions.greentic-pack"),
576 Some(&"greentic-pack 0.1".to_string())
577 );
578 }
579
580 #[test]
581 fn reject_non_launcher_answer_document_id() {
582 let doc = AnswerDocument {
583 wizard_id: "greentic-dev.wizard.pack.build".to_string(),
584 schema_id: SCHEMA_ID.to_string(),
585 schema_version: "1.0.0".to_string(),
586 locale: "en-US".to_string(),
587 answers: json!({}),
588 locks: BTreeMap::new(),
589 };
590 let err = validate_answer_document_identity(&doc, Path::new("answers.json")).unwrap_err();
591 assert!(err.to_string().contains("unsupported wizard_id"));
592 }
593
594 #[test]
595 fn parse_main_menu_navigation_keys() {
596 assert_eq!(
597 parse_launcher_menu_choice("1", true, "en-US").expect("parse"),
598 LauncherMenuChoice::Pack
599 );
600 assert_eq!(
601 parse_launcher_menu_choice("2", true, "en-US").expect("parse"),
602 LauncherMenuChoice::Bundle
603 );
604 assert_eq!(
605 parse_launcher_menu_choice("0", true, "en-US").expect("parse"),
606 LauncherMenuChoice::Exit
607 );
608 assert_eq!(
609 parse_launcher_menu_choice("M", true, "en-US").expect("parse"),
610 LauncherMenuChoice::MainMenu
611 );
612 }
613
614 #[test]
615 fn parse_nested_menu_zero_returns_to_main_menu() {
616 assert_eq!(
617 parse_launcher_menu_choice("0", false, "en-US").expect("parse"),
618 LauncherMenuChoice::MainMenu
619 );
620 }
621
622 #[test]
623 fn build_launcher_answers_includes_selected_action() {
624 let answers = build_launcher_answers(super::ExecutionMode::DryRun, "bundle");
625 assert_eq!(answers["selected_action"], "bundle");
626 assert!(answers.get("delegate_answer_document").is_some());
627 }
628
629 #[test]
630 fn bundle_delegate_receives_locale_flag() {
631 assert_eq!(
632 interactive_delegate_args("greentic-bundle", "en-GB"),
633 vec!["--locale", "en-GB", "wizard"]
634 );
635 }
636
637 #[test]
638 fn pack_delegate_keeps_plain_wizard_args() {
639 assert_eq!(
640 interactive_delegate_args("greentic-pack", "en-GB"),
641 vec!["wizard"]
642 );
643 }
644}