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};
16use tempfile::TempDir;
17
18use crate::cli::{WizardApplyArgs, WizardLaunchArgs, WizardValidateArgs};
19use crate::i18n;
20use crate::passthrough::resolve_binary;
21use crate::wizard::executor::ExecuteOptions;
22use crate::wizard::plan::{WizardAnswers, WizardFrontend, WizardPlan};
23use crate::wizard::provider::{ProviderRequest, ShellWizardProvider, WizardProvider};
24
25const DEFAULT_LOCALE: &str = "en-US";
26const DEFAULT_SCHEMA_VERSION: &str = "1.0.0";
27const WIZARD_ID: &str = "greentic-dev.wizard.launcher.main";
28const SCHEMA_ID: &str = "greentic-dev.launcher.main";
29const BUNDLE_WIZARD_ID_PREFIX: &str = "greentic-bundle.";
30const PACK_WIZARD_ID_PREFIX: &str = "greentic-pack.";
31const EMBEDDED_WIZARD_ROOT_ZERO_ACTION_ENV: &str = "GREENTIC_WIZARD_ROOT_ZERO_ACTION";
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34enum ExecutionMode {
35    DryRun,
36    Execute,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40enum LauncherMenuChoice {
41    Pack,
42    Bundle,
43    MainMenu,
44    Exit,
45}
46
47#[derive(Debug, Clone)]
48struct LoadedAnswers {
49    answers: serde_json::Value,
50    inferred_locale: Option<String>,
51    schema_version: Option<String>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
55struct AnswerDocument {
56    wizard_id: String,
57    schema_id: String,
58    schema_version: String,
59    locale: String,
60    answers: serde_json::Value,
61    #[serde(default)]
62    locks: serde_json::Map<String, serde_json::Value>,
63}
64
65pub fn launch(args: WizardLaunchArgs) -> Result<()> {
66    let mode = if args.dry_run {
67        ExecutionMode::DryRun
68    } else {
69        ExecutionMode::Execute
70    };
71
72    if let Some(answers_path) = args.answers.as_deref() {
73        let loaded =
74            load_answer_document(answers_path, args.schema_version.as_deref(), args.migrate)?;
75
76        // When --answers is provided, imply --yes --non-interactive for automation
77        return run_from_inputs(
78            args.frontend,
79            args.locale,
80            loaded,
81            args.out,
82            mode,
83            true,
84            true,
85            args.unsafe_commands,
86            args.allow_destructive,
87            args.emit_answers,
88            args.schema_version,
89        );
90    }
91
92    let locale = i18n::select_locale(args.locale.as_deref());
93    if mode == ExecutionMode::DryRun {
94        let Some(answers) = prompt_launcher_answers(mode, &locale)? else {
95            return Ok(());
96        };
97        let loaded = LoadedAnswers {
98            answers,
99            inferred_locale: None,
100            schema_version: args.schema_version.clone(),
101        };
102
103        return run_from_inputs(
104            args.frontend,
105            Some(locale),
106            loaded,
107            args.out,
108            mode,
109            args.yes,
110            args.non_interactive,
111            args.unsafe_commands,
112            args.allow_destructive,
113            args.emit_answers,
114            args.schema_version,
115        );
116    }
117
118    loop {
119        let Some(answers) = prompt_launcher_answers(mode, &locale)? else {
120            return Ok(());
121        };
122
123        run_interactive_delegate(
124            &answers,
125            &locale,
126            args.emit_answers.as_deref(),
127            args.schema_version.as_deref(),
128        )?;
129        if args.emit_answers.is_some() {
130            return Ok(());
131        }
132    }
133}
134
135fn run_interactive_delegate(
136    answers: &serde_json::Value,
137    locale: &str,
138    emit_answers: Option<&Path>,
139    requested_schema_version: Option<&str>,
140) -> Result<()> {
141    let selected_action = answers
142        .get("selected_action")
143        .and_then(|value| value.as_str())
144        .ok_or_else(|| anyhow::anyhow!("missing required answers.selected_action"))?;
145
146    let program = match selected_action {
147        "pack" => "greentic-pack",
148        "bundle" => "greentic-bundle",
149        other => bail!("unsupported selected_action `{other}`; expected `pack` or `bundle`"),
150    };
151
152    let bin = resolve_binary(program)?;
153    let delegated_emit = delegated_emit_capture(emit_answers)?;
154    let mut command = Command::new(&bin);
155    command
156        .args(interactive_delegate_args(
157            program,
158            locale,
159            delegated_emit.path.as_deref(),
160        ))
161        .env("LANG", locale)
162        .env("LC_ALL", locale)
163        .env("LC_MESSAGES", locale)
164        .stdin(Stdio::inherit())
165        .stdout(Stdio::inherit())
166        .stderr(Stdio::inherit());
167    if program == "greentic-bundle" {
168        command.env(EMBEDDED_WIZARD_ROOT_ZERO_ACTION_ENV, "back");
169    }
170    let status = command
171        .status()
172        .map_err(|e| anyhow::anyhow!("failed to execute {}: {e}", bin.display()))?;
173    if !status.success() {
174        bail!(
175            "wizard step command failed: {} {:?} (exit code {:?})",
176            program,
177            ["wizard"],
178            status.code()
179        );
180    }
181
182    if let (Some(output_path), Some(delegated_emit_path)) =
183        (emit_answers, delegated_emit.path.as_deref())
184    {
185        let delegated_doc = read_answer_document(delegated_emit_path)?;
186        let Some(delegated_action) = delegated_selected_action(&delegated_doc) else {
187            bail!(
188                "unsupported delegated wizard_id `{}` in {}; expected `greentic-pack.*` or `greentic-bundle.*`",
189                delegated_doc.wizard_id,
190                delegated_emit_path.display()
191            );
192        };
193        if delegated_action != selected_action {
194            bail!(
195                "delegated answers wizard_id `{}` did not match selected_action `{selected_action}`",
196                delegated_doc.wizard_id
197            );
198        }
199        let schema_version = requested_schema_version.unwrap_or(DEFAULT_SCHEMA_VERSION);
200        let launcher_doc = build_interactive_answer_document(
201            locale,
202            schema_version,
203            selected_action,
204            &delegated_doc,
205        );
206        write_answer_document(output_path, &launcher_doc)?;
207    }
208
209    Ok(())
210}
211
212fn interactive_delegate_args(
213    program: &str,
214    locale: &str,
215    emit_answers: Option<&Path>,
216) -> Vec<String> {
217    let mut args = if program == "greentic-bundle" {
218        vec![
219            "--locale".to_string(),
220            locale.to_string(),
221            "wizard".to_string(),
222        ]
223    } else {
224        vec!["wizard".to_string()]
225    };
226    if let Some(path) = emit_answers {
227        args.push("run".to_string());
228        args.push("--emit-answers".to_string());
229        args.push(path.display().to_string());
230    }
231    args
232}
233
234pub fn validate(args: WizardValidateArgs) -> Result<()> {
235    let loaded = load_answer_document(&args.answers, args.schema_version.as_deref(), args.migrate)?;
236
237    run_from_inputs(
238        args.frontend,
239        args.locale,
240        loaded,
241        args.out,
242        ExecutionMode::DryRun,
243        true,
244        true,
245        false,
246        false,
247        args.emit_answers,
248        args.schema_version,
249    )
250}
251
252pub fn apply(args: WizardApplyArgs) -> Result<()> {
253    let loaded = load_answer_document(&args.answers, args.schema_version.as_deref(), args.migrate)?;
254
255    run_from_inputs(
256        args.frontend,
257        args.locale,
258        loaded,
259        args.out,
260        ExecutionMode::Execute,
261        args.yes,
262        args.non_interactive,
263        args.unsafe_commands,
264        args.allow_destructive,
265        args.emit_answers,
266        args.schema_version,
267    )
268}
269
270#[allow(clippy::too_many_arguments)]
271fn run_from_inputs(
272    frontend_raw: String,
273    cli_locale: Option<String>,
274    loaded: LoadedAnswers,
275    out: Option<PathBuf>,
276    mode: ExecutionMode,
277    yes: bool,
278    non_interactive: bool,
279    unsafe_commands: bool,
280    allow_destructive: bool,
281    emit_answers: Option<PathBuf>,
282    requested_schema_version: Option<String>,
283) -> Result<()> {
284    let locale = i18n::select_locale(
285        cli_locale
286            .as_deref()
287            .or(loaded.inferred_locale.as_deref())
288            .or(Some(DEFAULT_LOCALE)),
289    );
290    let frontend = WizardFrontend::parse(&frontend_raw).ok_or_else(|| {
291        anyhow::anyhow!(
292            "unsupported frontend `{}`; expected text|json|adaptive-card",
293            frontend_raw
294        )
295    })?;
296
297    if registry::resolve("launcher", "main").is_none() {
298        bail!("launcher mapping missing for `launcher.main`");
299    }
300
301    let merged_answers = merge_answers(None, None, Some(loaded.answers.clone()), None);
302    let delegated_answers_path = persist_delegated_answers_if_present(
303        &paths_for_provider(out.as_deref())?,
304        &merged_answers,
305    )?;
306    let provider = ShellWizardProvider;
307    let req = ProviderRequest {
308        frontend: frontend.clone(),
309        locale: locale.clone(),
310        dry_run: mode == ExecutionMode::DryRun,
311        answers: merged_answers.clone(),
312        delegated_answers_path,
313    };
314    let mut plan = provider.build_plan(&req)?;
315
316    let out_dir = persistence::resolve_out_dir(out.as_deref());
317    let paths = persistence::prepare_dir(&out_dir)?;
318    persistence::persist_plan_and_answers(&paths, &merged_answers, &plan)?;
319
320    render_plan(&plan)?;
321
322    if mode == ExecutionMode::Execute {
323        confirm::ensure_execute_allowed(
324            &crate::i18n::tf(
325                &locale,
326                "runtime.wizard.confirm.summary",
327                &[
328                    ("target", plan.metadata.target.clone()),
329                    ("mode", plan.metadata.mode.clone()),
330                    ("step_count", plan.steps.len().to_string()),
331                ],
332            ),
333            yes,
334            non_interactive,
335            &locale,
336        )?;
337        let report = executor::execute(
338            &plan,
339            &paths.exec_log_path,
340            &ExecuteOptions {
341                unsafe_commands,
342                allow_destructive,
343                locale: locale.clone(),
344            },
345        )?;
346        annotate_execution_metadata(&mut plan, &report);
347        persistence::persist_plan_and_answers(&paths, &merged_answers, &plan)?;
348    }
349
350    if let Some(path) = emit_answers {
351        let schema_version = requested_schema_version
352            .or(loaded.schema_version)
353            .unwrap_or_else(|| DEFAULT_SCHEMA_VERSION.to_string());
354        let doc = build_answer_document(&locale, &schema_version, &merged_answers, &plan);
355        write_answer_document(&path, &doc)?;
356    }
357
358    Ok(())
359}
360
361fn paths_for_provider(out: Option<&Path>) -> Result<persistence::PersistedPaths> {
362    let out_dir = persistence::resolve_out_dir(out);
363    persistence::prepare_dir(&out_dir)
364}
365
366fn persist_delegated_answers_if_present(
367    paths: &persistence::PersistedPaths,
368    answers: &WizardAnswers,
369) -> Result<Option<PathBuf>> {
370    let Some(delegated_answers) = answers.data.get("delegate_answer_document") else {
371        return Ok(None);
372    };
373    if !delegated_answers.is_object() {
374        bail!("answers.delegate_answer_document must be a JSON object");
375    }
376    persistence::persist_delegated_answers(&paths.delegated_answers_path, delegated_answers)?;
377    Ok(Some(paths.delegated_answers_path.clone()))
378}
379
380fn render_plan(plan: &WizardPlan) -> Result<()> {
381    let rendered = match plan.metadata.frontend {
382        WizardFrontend::Json => {
383            serde_json::to_string_pretty(plan).context("failed to encode wizard plan")?
384        }
385        WizardFrontend::Text => render_text_plan(plan),
386        WizardFrontend::AdaptiveCard => {
387            let card = serde_json::json!({
388                "type": "AdaptiveCard",
389                "version": "1.5",
390                "body": [
391                    {"type":"TextBlock","weight":"Bolder","text":"greentic-dev launcher wizard plan"},
392                    {"type":"TextBlock","text": "target: launcher mode: main"},
393                ],
394                "data": { "plan": plan }
395            });
396            serde_json::to_string_pretty(&card).context("failed to encode adaptive card")?
397        }
398    };
399    println!("{rendered}");
400    Ok(())
401}
402
403fn render_text_plan(plan: &WizardPlan) -> String {
404    let mut out = String::new();
405    out.push_str(&format!(
406        "wizard plan v{}: {}.{}\n",
407        plan.plan_version, plan.metadata.target, plan.metadata.mode
408    ));
409    out.push_str(&format!("locale: {}\n", plan.metadata.locale));
410    out.push_str(&format!("steps: {}\n", plan.steps.len()));
411    for (idx, step) in plan.steps.iter().enumerate() {
412        match step {
413            crate::wizard::plan::WizardStep::RunCommand(cmd) => {
414                out.push_str(&format!(
415                    "{}. RunCommand {} {}\n",
416                    idx + 1,
417                    cmd.program,
418                    cmd.args.join(" ")
419                ));
420            }
421            other => out.push_str(&format!("{}. {:?}\n", idx + 1, other)),
422        }
423    }
424    out
425}
426
427fn prompt_launcher_answers(mode: ExecutionMode, locale: &str) -> Result<Option<serde_json::Value>> {
428    let interactive = io::stdin().is_terminal() && io::stdout().is_terminal();
429    if !interactive {
430        bail!(
431            "{}",
432            i18n::t(locale, "cli.wizard.error.interactive_required")
433        );
434    }
435
436    loop {
437        eprintln!("{}", i18n::t(locale, "cli.wizard.launcher.title"));
438        eprintln!();
439        eprintln!("{}", i18n::t(locale, "cli.wizard.launcher.option_pack"));
440        eprintln!("{}", i18n::t(locale, "cli.wizard.launcher.option_bundle"));
441        eprintln!("{}", i18n::t(locale, "cli.wizard.launcher.option_exit"));
442        eprintln!();
443        eprint!("{}", i18n::t(locale, "cli.wizard.launcher.select_option"));
444        io::stderr().flush()?;
445
446        let mut input = String::new();
447        io::stdin().read_line(&mut input)?;
448        match parse_launcher_menu_choice(input.trim(), true, locale)? {
449            LauncherMenuChoice::Pack => return Ok(Some(build_launcher_answers(mode, "pack"))),
450            LauncherMenuChoice::Bundle => return Ok(Some(build_launcher_answers(mode, "bundle"))),
451            LauncherMenuChoice::MainMenu => {
452                eprintln!();
453                continue;
454            }
455            LauncherMenuChoice::Exit => return Ok(None),
456        }
457    }
458}
459
460fn parse_launcher_menu_choice(
461    input: &str,
462    in_main_menu: bool,
463    locale: &str,
464) -> Result<LauncherMenuChoice> {
465    match input.trim() {
466        "1" if in_main_menu => Ok(LauncherMenuChoice::Pack),
467        "2" if in_main_menu => Ok(LauncherMenuChoice::Bundle),
468        "0" if in_main_menu => Ok(LauncherMenuChoice::Exit),
469        "0" => Ok(LauncherMenuChoice::MainMenu),
470        "m" | "M" => Ok(LauncherMenuChoice::MainMenu),
471        _ => bail!("{}", i18n::t(locale, "cli.wizard.error.invalid_selection")),
472    }
473}
474
475fn build_launcher_answers(mode: ExecutionMode, selected_action: &str) -> serde_json::Value {
476    let mut answers = serde_json::Map::new();
477    answers.insert(
478        "selected_action".to_string(),
479        serde_json::Value::String(selected_action.to_string()),
480    );
481    if mode == ExecutionMode::DryRun {
482        answers.insert(
483            "delegate_answer_document".to_string(),
484            serde_json::Value::Object(Default::default()),
485        );
486    }
487    serde_json::Value::Object(answers)
488}
489
490fn load_answer_document(
491    path_or_url: &str,
492    requested_schema_version: Option<&str>,
493    migrate: bool,
494) -> Result<LoadedAnswers> {
495    let mut doc = read_answer_document_from_path_or_url(path_or_url)?;
496    if is_launcher_answer_document(&doc) {
497        if let Some(schema_version) = requested_schema_version
498            && doc.schema_version != schema_version
499        {
500            if migrate {
501                doc = migrate_answer_document(doc, schema_version);
502            } else {
503                bail!(
504                    "answers schema_version `{}` does not match requested `{}`; re-run with --migrate",
505                    doc.schema_version,
506                    schema_version
507                );
508            }
509        }
510
511        if !doc.answers.is_object() {
512            bail!(
513                "AnswerDocument `answers` must be a JSON object in {}",
514                path_or_url
515            );
516        }
517
518        return Ok(LoadedAnswers {
519            answers: doc.answers.clone(),
520            inferred_locale: Some(doc.locale),
521            schema_version: Some(doc.schema_version),
522        });
523    }
524
525    if let Some(selected_action) = delegated_selected_action(&doc) {
526        return Ok(LoadedAnswers {
527            answers: wrap_delegated_answer_document(selected_action, &doc),
528            inferred_locale: Some(doc.locale),
529            schema_version: Some(
530                requested_schema_version
531                    .unwrap_or(DEFAULT_SCHEMA_VERSION)
532                    .to_string(),
533            ),
534        });
535    }
536
537    validate_answer_document_identity(&doc, path_or_url)?;
538    unreachable!("launcher identity validation must error for unsupported documents");
539}
540
541fn read_answer_document(path: &Path) -> Result<AnswerDocument> {
542    let raw =
543        fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
544    let value: serde_json::Value = serde_json::from_str(&raw)
545        .with_context(|| format!("failed to parse {}", path.display()))?;
546    serde_json::from_value(value)
547        .with_context(|| format!("failed to parse AnswerDocument from {}", path.display()))
548}
549
550fn read_answer_document_from_path_or_url(path_or_url: &str) -> Result<AnswerDocument> {
551    let raw = if path_or_url.starts_with("http://") || path_or_url.starts_with("https://") {
552        // Fetch from remote URL
553        let client = reqwest::blocking::Client::builder()
554            .timeout(std::time::Duration::from_secs(30))
555            .build()
556            .with_context(|| "failed to create HTTP client")?;
557        let response = client
558            .get(path_or_url)
559            .send()
560            .with_context(|| format!("failed to fetch {}", path_or_url))?;
561        if !response.status().is_success() {
562            bail!(
563                "failed to fetch {}: HTTP {}",
564                path_or_url,
565                response.status()
566            );
567        }
568        response
569            .text()
570            .with_context(|| format!("failed to read response from {}", path_or_url))?
571    } else {
572        let path = Path::new(path_or_url);
573        fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?
574    };
575    let value: serde_json::Value =
576        serde_json::from_str(&raw).with_context(|| format!("failed to parse {}", path_or_url))?;
577    serde_json::from_value(value)
578        .with_context(|| format!("failed to parse AnswerDocument from {}", path_or_url))
579}
580
581fn validate_answer_document_identity(doc: &AnswerDocument, path_or_url: &str) -> Result<()> {
582    if !is_launcher_answer_document(doc) {
583        bail!(
584            "unsupported wizard_id `{}` in {}; expected `{}`",
585            doc.wizard_id,
586            path_or_url,
587            WIZARD_ID
588        );
589    }
590    if doc.schema_id != SCHEMA_ID {
591        bail!(
592            "unsupported schema_id `{}` in {}; expected `{}`",
593            doc.schema_id,
594            path_or_url,
595            SCHEMA_ID
596        );
597    }
598    Ok(())
599}
600
601fn is_launcher_answer_document(doc: &AnswerDocument) -> bool {
602    doc.wizard_id == WIZARD_ID && doc.schema_id == SCHEMA_ID
603}
604
605fn delegated_selected_action(doc: &AnswerDocument) -> Option<&'static str> {
606    if doc.wizard_id.starts_with(BUNDLE_WIZARD_ID_PREFIX) {
607        Some("bundle")
608    } else if doc.wizard_id.starts_with(PACK_WIZARD_ID_PREFIX) {
609        Some("pack")
610    } else {
611        None
612    }
613}
614
615fn wrap_delegated_answer_document(
616    selected_action: &str,
617    doc: &AnswerDocument,
618) -> serde_json::Value {
619    serde_json::json!({
620        "selected_action": selected_action,
621        "delegate_answer_document": doc,
622    })
623}
624
625fn merge_answers(
626    cli_overrides: Option<serde_json::Value>,
627    parent_prefill: Option<serde_json::Value>,
628    answers_file: Option<serde_json::Value>,
629    provider_defaults: Option<serde_json::Value>,
630) -> WizardAnswers {
631    let mut out = BTreeMap::<String, serde_json::Value>::new();
632    merge_obj(&mut out, provider_defaults);
633    merge_obj(&mut out, answers_file);
634    merge_obj(&mut out, parent_prefill);
635    merge_obj(&mut out, cli_overrides);
636    WizardAnswers {
637        data: serde_json::Value::Object(out.into_iter().collect()),
638    }
639}
640
641fn merge_obj(dst: &mut BTreeMap<String, serde_json::Value>, src: Option<serde_json::Value>) {
642    if let Some(serde_json::Value::Object(map)) = src {
643        for (k, v) in map {
644            dst.insert(k, v);
645        }
646    }
647}
648
649fn migrate_answer_document(mut doc: AnswerDocument, target_schema_version: &str) -> AnswerDocument {
650    doc.schema_version = target_schema_version.to_string();
651    doc
652}
653
654fn build_answer_document(
655    locale: &str,
656    schema_version: &str,
657    answers: &WizardAnswers,
658    plan: &WizardPlan,
659) -> AnswerDocument {
660    let locks = plan
661        .inputs
662        .iter()
663        .map(|(key, value)| (key.clone(), serde_json::Value::String(value.clone())))
664        .collect();
665    AnswerDocument {
666        wizard_id: WIZARD_ID.to_string(),
667        schema_id: SCHEMA_ID.to_string(),
668        schema_version: schema_version.to_string(),
669        locale: locale.to_string(),
670        answers: answers.data.clone(),
671        locks,
672    }
673}
674
675fn build_interactive_answer_document(
676    locale: &str,
677    schema_version: &str,
678    selected_action: &str,
679    delegated_doc: &AnswerDocument,
680) -> AnswerDocument {
681    AnswerDocument {
682        wizard_id: WIZARD_ID.to_string(),
683        schema_id: SCHEMA_ID.to_string(),
684        schema_version: schema_version.to_string(),
685        locale: locale.to_string(),
686        answers: wrap_delegated_answer_document(selected_action, delegated_doc),
687        locks: serde_json::Map::new(),
688    }
689}
690
691struct DelegatedEmitCapture {
692    _temp_dir: Option<TempDir>,
693    path: Option<PathBuf>,
694}
695
696fn delegated_emit_capture(emit_answers: Option<&Path>) -> Result<DelegatedEmitCapture> {
697    let Some(_) = emit_answers else {
698        return Ok(DelegatedEmitCapture {
699            _temp_dir: None,
700            path: None,
701        });
702    };
703    let temp_dir = tempfile::Builder::new()
704        .prefix("greentic-dev-wizard-delegate-")
705        .tempdir()
706        .context("failed to create tempdir for delegated answers capture")?;
707    let path = temp_dir.path().join("delegated-answers.json");
708    Ok(DelegatedEmitCapture {
709        _temp_dir: Some(temp_dir),
710        path: Some(path),
711    })
712}
713
714fn write_answer_document(path: &Path, doc: &AnswerDocument) -> Result<()> {
715    let rendered = serde_json::to_string_pretty(doc).context("render answers envelope JSON")?;
716    fs::write(path, rendered).with_context(|| format!("failed to write {}", path.display()))
717}
718
719fn annotate_execution_metadata(
720    plan: &mut WizardPlan,
721    report: &crate::wizard::executor::ExecutionReport,
722) {
723    for (program, version) in &report.resolved_versions {
724        plan.inputs
725            .insert(format!("resolved_versions.{program}"), version.clone());
726    }
727    plan.inputs.insert(
728        "executed_commands".to_string(),
729        report.commands_executed.to_string(),
730    );
731}
732
733#[cfg(test)]
734mod tests {
735    use std::collections::BTreeMap;
736    use std::fs;
737    use std::path::Path;
738    use std::path::PathBuf;
739
740    use serde_json::json;
741    use tempfile::TempDir;
742
743    use super::{
744        AnswerDocument, LauncherMenuChoice, SCHEMA_ID, WIZARD_ID, build_answer_document,
745        build_interactive_answer_document, build_launcher_answers, interactive_delegate_args,
746        is_launcher_answer_document, merge_answers, parse_launcher_menu_choice,
747        run_interactive_delegate, validate_answer_document_identity,
748        wrap_delegated_answer_document,
749    };
750    use crate::wizard::plan::{WizardFrontend, WizardPlan, WizardPlanMetadata};
751
752    fn write_stub_bin(dir: &Path, name: &str, body: &str) -> PathBuf {
753        #[cfg(windows)]
754        let path = dir.join(format!("{name}.cmd"));
755        #[cfg(not(windows))]
756        let path = dir.join(name);
757
758        #[cfg(windows)]
759        let script = format!("@echo off\r\n{body}\r\n");
760        #[cfg(not(windows))]
761        let script = format!("#!/bin/sh\n{body}\n");
762
763        fs::write(&path, script).expect("write stub");
764        #[cfg(not(windows))]
765        {
766            use std::os::unix::fs::PermissionsExt;
767            let mut perms = fs::metadata(&path).expect("metadata").permissions();
768            perms.set_mode(0o755);
769            fs::set_permissions(&path, perms).expect("set perms");
770        }
771        path
772    }
773
774    fn prepend_path(dir: &Path) -> String {
775        let old = std::env::var("PATH").unwrap_or_default();
776        let sep = if cfg!(windows) { ';' } else { ':' };
777        format!("{}{}{}", dir.display(), sep, old)
778    }
779
780    #[test]
781    fn answer_precedence_cli_over_file() {
782        let merged = merge_answers(
783            Some(json!({"foo":"cli"})),
784            None,
785            Some(json!({"foo":"file","bar":"file"})),
786            None,
787        );
788        assert_eq!(merged.data["foo"], "cli");
789        assert_eq!(merged.data["bar"], "file");
790    }
791
792    #[test]
793    fn build_answer_document_sets_launcher_identity_fields() {
794        let answers = merge_answers(None, None, Some(json!({"selected_action":"pack"})), None);
795        let plan = WizardPlan {
796            plan_version: 1,
797            created_at: None,
798            metadata: WizardPlanMetadata {
799                target: "launcher".to_string(),
800                mode: "main".to_string(),
801                locale: "en-US".to_string(),
802                frontend: WizardFrontend::Json,
803            },
804            inputs: BTreeMap::from([(
805                "resolved_versions.greentic-pack".to_string(),
806                "greentic-pack 0.1".to_string(),
807            )]),
808            steps: vec![],
809        };
810
811        let doc = build_answer_document("en-US", "1.0.0", &answers, &plan);
812
813        assert_eq!(doc.wizard_id, WIZARD_ID);
814        assert_eq!(doc.schema_id, SCHEMA_ID);
815        assert_eq!(doc.schema_version, "1.0.0");
816        assert_eq!(doc.locale, "en-US");
817        assert_eq!(doc.answers["selected_action"], "pack");
818        assert_eq!(
819            doc.locks.get("resolved_versions.greentic-pack"),
820            Some(&json!("greentic-pack 0.1"))
821        );
822    }
823
824    #[test]
825    fn reject_non_launcher_answer_document_id() {
826        let doc = AnswerDocument {
827            wizard_id: "greentic-dev.wizard.pack.build".to_string(),
828            schema_id: SCHEMA_ID.to_string(),
829            schema_version: "1.0.0".to_string(),
830            locale: "en-US".to_string(),
831            answers: json!({}),
832            locks: serde_json::Map::new(),
833        };
834        let err = validate_answer_document_identity(&doc, "answers.json").unwrap_err();
835        assert!(err.to_string().contains("unsupported wizard_id"));
836    }
837
838    #[test]
839    fn launcher_identity_matches_expected_pair() {
840        let doc = AnswerDocument {
841            wizard_id: WIZARD_ID.to_string(),
842            schema_id: SCHEMA_ID.to_string(),
843            schema_version: "1.0.0".to_string(),
844            locale: "en-US".to_string(),
845            answers: json!({}),
846            locks: serde_json::Map::new(),
847        };
848        assert!(is_launcher_answer_document(&doc));
849    }
850
851    #[test]
852    fn wrap_delegated_bundle_document_builds_launcher_shape() {
853        let doc = AnswerDocument {
854            wizard_id: "greentic-bundle.wizard.main".to_string(),
855            schema_id: "greentic-bundle.main".to_string(),
856            schema_version: "1.0.0".to_string(),
857            locale: "en-US".to_string(),
858            answers: json!({"selected_action":"create"}),
859            locks: serde_json::Map::new(),
860        };
861        let wrapped = wrap_delegated_answer_document("bundle", &doc);
862        assert_eq!(wrapped["selected_action"], "bundle");
863        assert_eq!(
864            wrapped["delegate_answer_document"]["wizard_id"],
865            "greentic-bundle.wizard.main"
866        );
867    }
868
869    #[test]
870    fn parse_main_menu_navigation_keys() {
871        assert_eq!(
872            parse_launcher_menu_choice("1", true, "en-US").expect("parse"),
873            LauncherMenuChoice::Pack
874        );
875        assert_eq!(
876            parse_launcher_menu_choice("2", true, "en-US").expect("parse"),
877            LauncherMenuChoice::Bundle
878        );
879        assert_eq!(
880            parse_launcher_menu_choice("0", true, "en-US").expect("parse"),
881            LauncherMenuChoice::Exit
882        );
883        assert_eq!(
884            parse_launcher_menu_choice("M", true, "en-US").expect("parse"),
885            LauncherMenuChoice::MainMenu
886        );
887    }
888
889    #[test]
890    fn parse_nested_menu_zero_returns_to_main_menu() {
891        assert_eq!(
892            parse_launcher_menu_choice("0", false, "en-US").expect("parse"),
893            LauncherMenuChoice::MainMenu
894        );
895    }
896
897    #[test]
898    fn build_launcher_answers_includes_selected_action() {
899        let answers = build_launcher_answers(super::ExecutionMode::DryRun, "bundle");
900        assert_eq!(answers["selected_action"], "bundle");
901        assert!(answers.get("delegate_answer_document").is_some());
902    }
903
904    #[test]
905    fn build_interactive_answer_document_wraps_delegate() {
906        let delegated = AnswerDocument {
907            wizard_id: "greentic-bundle.wizard.main".to_string(),
908            schema_id: "greentic-bundle.main".to_string(),
909            schema_version: "1.0.0".to_string(),
910            locale: "en-US".to_string(),
911            answers: json!({"selected_action":"create"}),
912            locks: serde_json::Map::new(),
913        };
914
915        let doc = build_interactive_answer_document("en-US", "1.2.3", "bundle", &delegated);
916
917        assert_eq!(doc.wizard_id, WIZARD_ID);
918        assert_eq!(doc.schema_id, SCHEMA_ID);
919        assert_eq!(doc.schema_version, "1.2.3");
920        assert_eq!(doc.answers["selected_action"], "bundle");
921        assert_eq!(
922            doc.answers["delegate_answer_document"]["wizard_id"],
923            "greentic-bundle.wizard.main"
924        );
925    }
926
927    #[test]
928    fn bundle_delegate_receives_locale_flag() {
929        assert_eq!(
930            interactive_delegate_args("greentic-bundle", "en-GB", None),
931            vec!["--locale", "en-GB", "wizard"]
932        );
933    }
934
935    #[test]
936    fn pack_delegate_keeps_plain_wizard_args() {
937        assert_eq!(
938            interactive_delegate_args("greentic-pack", "en-GB", None),
939            vec!["wizard"]
940        );
941    }
942
943    #[test]
944    fn bundle_delegate_emit_answers_uses_run_subcommand() {
945        assert_eq!(
946            interactive_delegate_args(
947                "greentic-bundle",
948                "en-GB",
949                Some(Path::new("/tmp/emitted.json"))
950            ),
951            vec![
952                "--locale",
953                "en-GB",
954                "wizard",
955                "run",
956                "--emit-answers",
957                "/tmp/emitted.json",
958            ]
959        );
960    }
961
962    #[test]
963    fn pack_delegate_emit_answers_uses_run_subcommand() {
964        assert_eq!(
965            interactive_delegate_args(
966                "greentic-pack",
967                "en-GB",
968                Some(Path::new("/tmp/emitted.json"))
969            ),
970            vec!["wizard", "run", "--emit-answers", "/tmp/emitted.json"]
971        );
972    }
973
974    #[test]
975    fn interactive_bundle_delegate_emit_answers_writes_launcher_document() {
976        let tmp = TempDir::new().expect("temp dir");
977        let bin_dir = tmp.path().join("bin");
978        fs::create_dir_all(&bin_dir).expect("create bin dir");
979        let emitted = tmp.path().join("answers-envelope.json");
980        let runlog = tmp.path().join("bundle-run.log");
981        let original_path = std::env::var_os("PATH");
982
983        write_stub_bin(
984            &bin_dir,
985            "greentic-bundle",
986            &format!(
987                r#"
988echo "$@" > "{}"
989if [ "$1" != "--locale" ] || [ "$2" != "en-US" ] || [ "$3" != "wizard" ] || [ "$4" != "run" ] || [ "$5" != "--emit-answers" ]; then
990  echo "unexpected argv: $@" >&2
991  exit 9
992fi
993cat > "$6" <<'EOF'
994{{
995  "wizard_id": "greentic-bundle.wizard.main",
996  "schema_id": "greentic-bundle.main",
997  "schema_version": "1.0.0",
998  "locale": "en-US",
999  "answers": {{
1000    "selected_action": "create"
1001  }},
1002  "locks": {{}}
1003}}
1004EOF
1005exit 0
1006"#,
1007                runlog.display()
1008            ),
1009        );
1010
1011        unsafe {
1012            std::env::set_var("PATH", prepend_path(&bin_dir));
1013        }
1014        let result = run_interactive_delegate(
1015            &json!({"selected_action":"bundle"}),
1016            "en-US",
1017            Some(&emitted),
1018            Some("1.2.3"),
1019        );
1020        if let Some(path) = original_path {
1021            unsafe {
1022                std::env::set_var("PATH", path);
1023            }
1024        } else {
1025            unsafe {
1026                std::env::remove_var("PATH");
1027            }
1028        }
1029
1030        result.expect("interactive delegate succeeds");
1031
1032        let argv = fs::read_to_string(&runlog).expect("read run log");
1033        assert!(argv.contains("wizard run --emit-answers"));
1034        assert!(
1035            !argv.contains("wizard --emit-answers"),
1036            "bundle delegate should not receive unsupported bare wizard emit flags"
1037        );
1038
1039        let emitted_doc: serde_json::Value =
1040            serde_json::from_str(&fs::read_to_string(&emitted).expect("read emitted answers"))
1041                .expect("parse emitted answers");
1042        assert_eq!(emitted_doc["wizard_id"], WIZARD_ID);
1043        assert_eq!(emitted_doc["schema_id"], SCHEMA_ID);
1044        assert_eq!(emitted_doc["schema_version"], "1.2.3");
1045        assert_eq!(emitted_doc["answers"]["selected_action"], "bundle");
1046        assert_eq!(
1047            emitted_doc["answers"]["delegate_answer_document"]["wizard_id"],
1048            "greentic-bundle.wizard.main"
1049        );
1050    }
1051}