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