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)?;
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}