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