Skip to main content

greentic_dev/wizard/
mod.rs

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}