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