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    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}