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 delegated_answers_path = persist_delegated_answers_if_present(
252        &paths_for_provider(out.as_deref())?,
253        &merged_answers,
254    )?;
255    let provider = ShellWizardProvider;
256    let req = ProviderRequest {
257        frontend: frontend.clone(),
258        locale: locale.clone(),
259        dry_run: mode == ExecutionMode::DryRun,
260        answers: merged_answers.clone(),
261        delegated_answers_path,
262    };
263    let mut plan = provider.build_plan(&req)?;
264
265    let out_dir = persistence::resolve_out_dir(out.as_deref());
266    let paths = persistence::prepare_dir(&out_dir)?;
267    persistence::persist_plan_and_answers(&paths, &merged_answers, &plan)?;
268
269    render_plan(&plan)?;
270
271    if mode == ExecutionMode::Execute {
272        confirm::ensure_execute_allowed(
273            &crate::i18n::tf(
274                &locale,
275                "runtime.wizard.confirm.summary",
276                &[
277                    ("target", plan.metadata.target.clone()),
278                    ("mode", plan.metadata.mode.clone()),
279                    ("step_count", plan.steps.len().to_string()),
280                ],
281            ),
282            yes,
283            non_interactive,
284            &locale,
285        )?;
286        let report = executor::execute(
287            &plan,
288            &paths.exec_log_path,
289            &ExecuteOptions {
290                unsafe_commands,
291                allow_destructive,
292                locale: locale.clone(),
293            },
294        )?;
295        annotate_execution_metadata(&mut plan, &report);
296        persistence::persist_plan_and_answers(&paths, &merged_answers, &plan)?;
297    }
298
299    if let Some(path) = emit_answers {
300        let schema_version = requested_schema_version
301            .or(loaded.schema_version)
302            .unwrap_or_else(|| DEFAULT_SCHEMA_VERSION.to_string());
303        let doc = build_answer_document(&locale, &schema_version, &merged_answers, &plan);
304        write_answer_document(&path, &doc)?;
305    }
306
307    Ok(())
308}
309
310fn paths_for_provider(out: Option<&Path>) -> Result<persistence::PersistedPaths> {
311    let out_dir = persistence::resolve_out_dir(out);
312    persistence::prepare_dir(&out_dir)
313}
314
315fn persist_delegated_answers_if_present(
316    paths: &persistence::PersistedPaths,
317    answers: &WizardAnswers,
318) -> Result<Option<PathBuf>> {
319    let Some(delegated_answers) = answers.data.get("delegate_answer_document") else {
320        return Ok(None);
321    };
322    if !delegated_answers.is_object() {
323        bail!("answers.delegate_answer_document must be a JSON object");
324    }
325    persistence::persist_delegated_answers(&paths.delegated_answers_path, delegated_answers)?;
326    Ok(Some(paths.delegated_answers_path.clone()))
327}
328
329fn render_plan(plan: &WizardPlan) -> Result<()> {
330    let rendered = match plan.metadata.frontend {
331        WizardFrontend::Json => {
332            serde_json::to_string_pretty(plan).context("failed to encode wizard plan")?
333        }
334        WizardFrontend::Text => render_text_plan(plan),
335        WizardFrontend::AdaptiveCard => {
336            let card = serde_json::json!({
337                "type": "AdaptiveCard",
338                "version": "1.5",
339                "body": [
340                    {"type":"TextBlock","weight":"Bolder","text":"greentic-dev launcher wizard plan"},
341                    {"type":"TextBlock","text": "target: launcher mode: main"},
342                ],
343                "data": { "plan": plan }
344            });
345            serde_json::to_string_pretty(&card).context("failed to encode adaptive card")?
346        }
347    };
348    println!("{rendered}");
349    Ok(())
350}
351
352fn render_text_plan(plan: &WizardPlan) -> String {
353    let mut out = String::new();
354    out.push_str(&format!(
355        "wizard plan v{}: {}.{}\n",
356        plan.plan_version, plan.metadata.target, plan.metadata.mode
357    ));
358    out.push_str(&format!("locale: {}\n", plan.metadata.locale));
359    out.push_str(&format!("steps: {}\n", plan.steps.len()));
360    for (idx, step) in plan.steps.iter().enumerate() {
361        match step {
362            crate::wizard::plan::WizardStep::RunCommand(cmd) => {
363                out.push_str(&format!(
364                    "{}. RunCommand {} {}\n",
365                    idx + 1,
366                    cmd.program,
367                    cmd.args.join(" ")
368                ));
369            }
370            other => out.push_str(&format!("{}. {:?}\n", idx + 1, other)),
371        }
372    }
373    out
374}
375
376fn prompt_launcher_answers(mode: ExecutionMode, locale: &str) -> Result<Option<serde_json::Value>> {
377    let interactive = io::stdin().is_terminal() && io::stdout().is_terminal();
378    if !interactive {
379        bail!(
380            "{}",
381            i18n::t(locale, "cli.wizard.error.interactive_required")
382        );
383    }
384
385    loop {
386        eprintln!("{}", i18n::t(locale, "cli.wizard.launcher.title"));
387        eprintln!();
388        eprintln!("{}", i18n::t(locale, "cli.wizard.launcher.option_pack"));
389        eprintln!("{}", i18n::t(locale, "cli.wizard.launcher.option_bundle"));
390        eprintln!("{}", i18n::t(locale, "cli.wizard.launcher.option_exit"));
391        eprintln!();
392        eprint!("{}", i18n::t(locale, "cli.wizard.launcher.select_option"));
393        io::stderr().flush()?;
394
395        let mut input = String::new();
396        io::stdin().read_line(&mut input)?;
397        match parse_launcher_menu_choice(input.trim(), true, locale)? {
398            LauncherMenuChoice::Pack => return Ok(Some(build_launcher_answers(mode, "pack"))),
399            LauncherMenuChoice::Bundle => return Ok(Some(build_launcher_answers(mode, "bundle"))),
400            LauncherMenuChoice::MainMenu => {
401                eprintln!();
402                continue;
403            }
404            LauncherMenuChoice::Exit => return Ok(None),
405        }
406    }
407}
408
409fn parse_launcher_menu_choice(
410    input: &str,
411    in_main_menu: bool,
412    locale: &str,
413) -> Result<LauncherMenuChoice> {
414    match input.trim() {
415        "1" if in_main_menu => Ok(LauncherMenuChoice::Pack),
416        "2" if in_main_menu => Ok(LauncherMenuChoice::Bundle),
417        "0" if in_main_menu => Ok(LauncherMenuChoice::Exit),
418        "0" => Ok(LauncherMenuChoice::MainMenu),
419        "m" | "M" => Ok(LauncherMenuChoice::MainMenu),
420        _ => bail!("{}", i18n::t(locale, "cli.wizard.error.invalid_selection")),
421    }
422}
423
424fn build_launcher_answers(mode: ExecutionMode, selected_action: &str) -> serde_json::Value {
425    let mut answers = serde_json::Map::new();
426    answers.insert(
427        "selected_action".to_string(),
428        serde_json::Value::String(selected_action.to_string()),
429    );
430    if mode == ExecutionMode::DryRun {
431        answers.insert(
432            "delegate_answer_document".to_string(),
433            serde_json::Value::Object(Default::default()),
434        );
435    }
436    serde_json::Value::Object(answers)
437}
438
439fn load_answer_document(
440    path: &Path,
441    requested_schema_version: Option<&str>,
442    migrate: bool,
443) -> Result<LoadedAnswers> {
444    let raw =
445        fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
446    let value: serde_json::Value = serde_json::from_str(&raw)
447        .with_context(|| format!("failed to parse {}", path.display()))?;
448
449    let mut doc: AnswerDocument = serde_json::from_value(value)
450        .with_context(|| format!("failed to parse AnswerDocument from {}", path.display()))?;
451    validate_answer_document_identity(&doc, path)?;
452
453    if let Some(schema_version) = requested_schema_version
454        && doc.schema_version != schema_version
455    {
456        if migrate {
457            doc = migrate_answer_document(doc, schema_version);
458        } else {
459            bail!(
460                "answers schema_version `{}` does not match requested `{}`; re-run with --migrate",
461                doc.schema_version,
462                schema_version
463            );
464        }
465    }
466
467    if !doc.answers.is_object() {
468        bail!(
469            "AnswerDocument `answers` must be a JSON object in {}",
470            path.display()
471        );
472    }
473
474    Ok(LoadedAnswers {
475        answers: doc.answers.clone(),
476        inferred_locale: Some(doc.locale),
477        schema_version: Some(doc.schema_version),
478    })
479}
480
481fn validate_answer_document_identity(doc: &AnswerDocument, path: &Path) -> Result<()> {
482    if doc.wizard_id != WIZARD_ID {
483        bail!(
484            "unsupported wizard_id `{}` in {}; expected `{}`",
485            doc.wizard_id,
486            path.display(),
487            WIZARD_ID
488        );
489    }
490    if doc.schema_id != SCHEMA_ID {
491        bail!(
492            "unsupported schema_id `{}` in {}; expected `{}`",
493            doc.schema_id,
494            path.display(),
495            SCHEMA_ID
496        );
497    }
498    Ok(())
499}
500
501fn merge_answers(
502    cli_overrides: Option<serde_json::Value>,
503    parent_prefill: Option<serde_json::Value>,
504    answers_file: Option<serde_json::Value>,
505    provider_defaults: Option<serde_json::Value>,
506) -> WizardAnswers {
507    let mut out = BTreeMap::<String, serde_json::Value>::new();
508    merge_obj(&mut out, provider_defaults);
509    merge_obj(&mut out, answers_file);
510    merge_obj(&mut out, parent_prefill);
511    merge_obj(&mut out, cli_overrides);
512    WizardAnswers {
513        data: serde_json::Value::Object(out.into_iter().collect()),
514    }
515}
516
517fn merge_obj(dst: &mut BTreeMap<String, serde_json::Value>, src: Option<serde_json::Value>) {
518    if let Some(serde_json::Value::Object(map)) = src {
519        for (k, v) in map {
520            dst.insert(k, v);
521        }
522    }
523}
524
525fn migrate_answer_document(mut doc: AnswerDocument, target_schema_version: &str) -> AnswerDocument {
526    doc.schema_version = target_schema_version.to_string();
527    doc
528}
529
530fn build_answer_document(
531    locale: &str,
532    schema_version: &str,
533    answers: &WizardAnswers,
534    plan: &WizardPlan,
535) -> AnswerDocument {
536    AnswerDocument {
537        wizard_id: WIZARD_ID.to_string(),
538        schema_id: SCHEMA_ID.to_string(),
539        schema_version: schema_version.to_string(),
540        locale: locale.to_string(),
541        answers: answers.data.clone(),
542        locks: plan.inputs.clone(),
543    }
544}
545
546fn write_answer_document(path: &Path, doc: &AnswerDocument) -> Result<()> {
547    let rendered = serde_json::to_string_pretty(doc).context("render answers envelope JSON")?;
548    fs::write(path, rendered).with_context(|| format!("failed to write {}", path.display()))
549}
550
551fn annotate_execution_metadata(
552    plan: &mut WizardPlan,
553    report: &crate::wizard::executor::ExecutionReport,
554) {
555    for (program, version) in &report.resolved_versions {
556        plan.inputs
557            .insert(format!("resolved_versions.{program}"), version.clone());
558    }
559    plan.inputs.insert(
560        "executed_commands".to_string(),
561        report.commands_executed.to_string(),
562    );
563}
564
565#[cfg(test)]
566mod tests {
567    use std::collections::BTreeMap;
568    use std::path::Path;
569
570    use serde_json::json;
571
572    use super::{
573        AnswerDocument, LauncherMenuChoice, SCHEMA_ID, WIZARD_ID, build_answer_document,
574        build_launcher_answers, interactive_delegate_args, merge_answers,
575        parse_launcher_menu_choice, validate_answer_document_identity,
576    };
577    use crate::wizard::plan::{WizardFrontend, WizardPlan, WizardPlanMetadata};
578
579    #[test]
580    fn answer_precedence_cli_over_file() {
581        let merged = merge_answers(
582            Some(json!({"foo":"cli"})),
583            None,
584            Some(json!({"foo":"file","bar":"file"})),
585            None,
586        );
587        assert_eq!(merged.data["foo"], "cli");
588        assert_eq!(merged.data["bar"], "file");
589    }
590
591    #[test]
592    fn build_answer_document_sets_launcher_identity_fields() {
593        let answers = merge_answers(None, None, Some(json!({"selected_action":"pack"})), None);
594        let plan = WizardPlan {
595            plan_version: 1,
596            created_at: None,
597            metadata: WizardPlanMetadata {
598                target: "launcher".to_string(),
599                mode: "main".to_string(),
600                locale: "en-US".to_string(),
601                frontend: WizardFrontend::Json,
602            },
603            inputs: BTreeMap::from([(
604                "resolved_versions.greentic-pack".to_string(),
605                "greentic-pack 0.1".to_string(),
606            )]),
607            steps: vec![],
608        };
609
610        let doc = build_answer_document("en-US", "1.0.0", &answers, &plan);
611
612        assert_eq!(doc.wizard_id, WIZARD_ID);
613        assert_eq!(doc.schema_id, SCHEMA_ID);
614        assert_eq!(doc.schema_version, "1.0.0");
615        assert_eq!(doc.locale, "en-US");
616        assert_eq!(doc.answers["selected_action"], "pack");
617        assert_eq!(
618            doc.locks.get("resolved_versions.greentic-pack"),
619            Some(&"greentic-pack 0.1".to_string())
620        );
621    }
622
623    #[test]
624    fn reject_non_launcher_answer_document_id() {
625        let doc = AnswerDocument {
626            wizard_id: "greentic-dev.wizard.pack.build".to_string(),
627            schema_id: SCHEMA_ID.to_string(),
628            schema_version: "1.0.0".to_string(),
629            locale: "en-US".to_string(),
630            answers: json!({}),
631            locks: BTreeMap::new(),
632        };
633        let err = validate_answer_document_identity(&doc, Path::new("answers.json")).unwrap_err();
634        assert!(err.to_string().contains("unsupported wizard_id"));
635    }
636
637    #[test]
638    fn parse_main_menu_navigation_keys() {
639        assert_eq!(
640            parse_launcher_menu_choice("1", true, "en-US").expect("parse"),
641            LauncherMenuChoice::Pack
642        );
643        assert_eq!(
644            parse_launcher_menu_choice("2", true, "en-US").expect("parse"),
645            LauncherMenuChoice::Bundle
646        );
647        assert_eq!(
648            parse_launcher_menu_choice("0", true, "en-US").expect("parse"),
649            LauncherMenuChoice::Exit
650        );
651        assert_eq!(
652            parse_launcher_menu_choice("M", true, "en-US").expect("parse"),
653            LauncherMenuChoice::MainMenu
654        );
655    }
656
657    #[test]
658    fn parse_nested_menu_zero_returns_to_main_menu() {
659        assert_eq!(
660            parse_launcher_menu_choice("0", false, "en-US").expect("parse"),
661            LauncherMenuChoice::MainMenu
662        );
663    }
664
665    #[test]
666    fn build_launcher_answers_includes_selected_action() {
667        let answers = build_launcher_answers(super::ExecutionMode::DryRun, "bundle");
668        assert_eq!(answers["selected_action"], "bundle");
669        assert!(answers.get("delegate_answer_document").is_some());
670    }
671
672    #[test]
673    fn bundle_delegate_receives_locale_flag() {
674        assert_eq!(
675            interactive_delegate_args("greentic-bundle", "en-GB"),
676            vec!["--locale", "en-GB", "wizard"]
677        );
678    }
679
680    #[test]
681    fn pack_delegate_keeps_plain_wizard_args() {
682        assert_eq!(
683            interactive_delegate_args("greentic-pack", "en-GB"),
684            vec!["wizard"]
685        );
686    }
687}