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