Skip to main content

greentic_dev/wizard/
mod.rs

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