Skip to main content

greentic_component/wizard/
mod.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result, anyhow, bail};
6use ciborium::Value as CborValue;
7use greentic_types::cbor::canonical;
8use greentic_types::i18n_text::I18nText;
9use greentic_types::schemas::component::v0_6_0::{ChoiceOption, ComponentQaSpec, QaMode, Question};
10use serde::Serialize;
11use serde_json::Map as JsonMap;
12use serde_json::Value as JsonValue;
13use serde_json::json;
14
15use crate::scaffold::config_schema::ConfigSchemaInput;
16use crate::scaffold::deps::{DependencyMode, DependencyTemplates, resolve_dependency_templates};
17use crate::scaffold::runtime_capabilities::RuntimeCapabilitiesInput;
18
19pub const PLAN_VERSION: u32 = 1;
20pub const TEMPLATE_VERSION: &str = "component-scaffold-v0.6.0";
21pub const GENERATOR_ID: &str = "greentic-component/wizard-provider";
22
23fn question(id: &str, label_key: &str, help_key: &str, required: bool) -> Question {
24    question_json(json!({
25        "id": id,
26        "label": I18nText::new(label_key, None),
27        "help": I18nText::new(help_key, None),
28        "error": null,
29        "kind": { "type": "text" },
30        "required": required,
31        "default": null
32    }))
33}
34
35fn question_bool(id: &str, label_key: &str, help_key: &str, required: bool) -> Question {
36    question_json(json!({
37        "id": id,
38        "label": I18nText::new(label_key, None),
39        "help": I18nText::new(help_key, None),
40        "error": null,
41        "kind": { "type": "bool" },
42        "required": required,
43        "default": null
44    }))
45}
46
47fn question_choice(
48    id: &str,
49    label_key: &str,
50    help_key: &str,
51    required: bool,
52    options: Vec<ChoiceOption>,
53) -> Question {
54    question_json(json!({
55        "id": id,
56        "label": I18nText::new(label_key, None),
57        "help": I18nText::new(help_key, None),
58        "error": null,
59        "kind": {
60            "type": "choice",
61            "options": options
62        },
63        "required": required,
64        "default": null
65    }))
66}
67
68fn question_json(value: JsonValue) -> Question {
69    serde_json::from_value(value).expect("question should deserialize")
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
73pub enum WizardMode {
74    Default,
75    Setup,
76    Update,
77    Remove,
78}
79
80#[derive(Debug, Clone)]
81pub struct AnswersPayload {
82    pub json: String,
83    pub cbor: Vec<u8>,
84}
85
86#[derive(Debug, Clone)]
87pub struct WizardRequest {
88    pub name: String,
89    pub organization: String,
90    pub abi_version: String,
91    pub mode: WizardMode,
92    pub target: PathBuf,
93    pub answers: Option<AnswersPayload>,
94    pub required_capabilities: Vec<String>,
95    pub provided_capabilities: Vec<String>,
96    pub user_operations: Vec<String>,
97    pub default_operation: Option<String>,
98    pub runtime_capabilities: RuntimeCapabilitiesInput,
99    pub config_schema: ConfigSchemaInput,
100}
101
102#[derive(Debug, Clone, Serialize)]
103pub struct ApplyResult {
104    pub plan: WizardPlanEnvelope,
105    pub warnings: Vec<String>,
106}
107
108#[derive(Debug, Clone, Serialize)]
109pub struct WizardPlanEnvelope {
110    pub plan_version: u32,
111    pub metadata: WizardPlanMetadata,
112    pub target_root: PathBuf,
113    pub plan: WizardPlan,
114}
115
116#[derive(Debug, Clone, Serialize)]
117pub struct WizardPlanMetadata {
118    pub generator: String,
119    pub template_version: String,
120    pub template_digest_blake3: String,
121    pub requested_abi_version: String,
122}
123
124// Compat shim: keep deterministic plan JSON stable without requiring newer
125// greentic-types exports during cargo package verification.
126#[derive(Debug, Clone, Serialize)]
127pub struct WizardPlan {
128    pub meta: WizardPlanMeta,
129    pub steps: Vec<WizardStep>,
130}
131
132#[derive(Debug, Clone, Serialize)]
133pub struct WizardPlanMeta {
134    pub id: String,
135    pub target: WizardTarget,
136    pub mode: WizardPlanMode,
137}
138
139#[derive(Debug, Clone, Serialize)]
140#[serde(rename_all = "snake_case")]
141pub enum WizardTarget {
142    Component,
143}
144
145#[derive(Debug, Clone, Serialize)]
146#[serde(rename_all = "snake_case")]
147pub enum WizardPlanMode {
148    Scaffold,
149}
150
151#[derive(Debug, Clone, Serialize)]
152#[serde(tag = "type", rename_all = "snake_case")]
153pub enum WizardStep {
154    EnsureDir { paths: Vec<String> },
155    WriteFiles { files: BTreeMap<String, String> },
156    RunCli { command: String },
157    Delegate { id: String },
158    BuildComponent { project_root: String },
159    TestComponent { project_root: String, full: bool },
160    Doctor { project_root: String },
161}
162
163pub fn spec_scaffold(mode: WizardMode) -> ComponentQaSpec {
164    let title = match mode {
165        WizardMode::Default => "wizard.component.default.title",
166        WizardMode::Setup => "wizard.component.setup.title",
167        WizardMode::Update => "wizard.component.update.title",
168        WizardMode::Remove => "wizard.component.remove.title",
169    };
170    ComponentQaSpec {
171        mode: qa_mode(mode),
172        title: I18nText::new(title, None),
173        description: Some(I18nText::new("wizard.component.description", None)),
174        questions: vec![
175            question(
176                "component.name",
177                "wizard.component.name.label",
178                "wizard.component.name.help",
179                true,
180            ),
181            question(
182                "component.path",
183                "wizard.component.path.label",
184                "wizard.component.path.help",
185                false,
186            ),
187            question_choice(
188                "component.kind",
189                "wizard.component.kind.label",
190                "wizard.component.kind.help",
191                false,
192                vec![
193                    ChoiceOption {
194                        value: "tool".to_string(),
195                        label: I18nText::new("wizard.component.kind.option.tool", None),
196                    },
197                    ChoiceOption {
198                        value: "source".to_string(),
199                        label: I18nText::new("wizard.component.kind.option.source", None),
200                    },
201                ],
202            ),
203            question_bool(
204                "component.features.enabled",
205                "wizard.component.features.enabled.label",
206                "wizard.component.features.enabled.help",
207                false,
208            ),
209        ],
210        defaults: BTreeMap::from([(
211            "component.features.enabled".to_string(),
212            CborValue::Bool(true),
213        )]),
214    }
215}
216
217pub fn apply_scaffold(request: WizardRequest, dry_run: bool) -> Result<ApplyResult> {
218    let warnings = abi_warnings(&request.abi_version);
219    let (prefill_answers_json, prefill_answers_cbor, mut mapping_warnings) =
220        normalize_answers(request.answers, request.mode)?;
221    let mut all_warnings = warnings;
222    all_warnings.append(&mut mapping_warnings);
223    let user_operations = if request.user_operations.is_empty() {
224        vec!["handle_message".to_string()]
225    } else {
226        request.user_operations.clone()
227    };
228    let default_operation = request
229        .default_operation
230        .clone()
231        .or_else(|| user_operations.first().cloned())
232        .unwrap_or_else(|| "handle_message".to_string());
233    let context = WizardContext {
234        name: request.name,
235        organization: request.organization,
236        abi_version: request.abi_version.clone(),
237        prefill_mode: request.mode,
238        prefill_answers_cbor,
239        prefill_answers_json,
240        user_operations,
241        default_operation,
242        runtime_capabilities: request.runtime_capabilities,
243        config_schema: request.config_schema,
244        dependency_templates: resolve_dependency_templates(
245            DependencyMode::from_env(),
246            &request.target,
247        ),
248    };
249
250    let files = build_files(&context)?;
251    let plan = build_plan(request.target, &request.abi_version, files);
252    if !dry_run {
253        execute_plan(&plan)?;
254    }
255
256    Ok(ApplyResult {
257        plan,
258        warnings: all_warnings,
259    })
260}
261
262pub fn execute_plan(envelope: &WizardPlanEnvelope) -> Result<()> {
263    for step in &envelope.plan.steps {
264        match step {
265            WizardStep::EnsureDir { paths } => {
266                for path in paths {
267                    let dir = envelope.target_root.join(path);
268                    fs::create_dir_all(&dir).with_context(|| {
269                        format!("wizard: failed to create directory {}", dir.display())
270                    })?;
271                }
272            }
273            WizardStep::WriteFiles { files } => {
274                for (relative_path, content) in files {
275                    let target = envelope.target_root.join(relative_path);
276                    if let Some(parent) = target.parent() {
277                        fs::create_dir_all(parent).with_context(|| {
278                            format!("wizard: failed to create directory {}", parent.display())
279                        })?;
280                    }
281                    let bytes = decode_step_content(relative_path, content)?;
282                    fs::write(&target, bytes)
283                        .with_context(|| format!("wizard: failed to write {}", target.display()))?;
284                    #[cfg(unix)]
285                    if is_executable_heuristic(Path::new(relative_path)) {
286                        use std::os::unix::fs::PermissionsExt;
287                        let mut permissions = fs::metadata(&target)
288                            .with_context(|| {
289                                format!("wizard: failed to stat {}", target.display())
290                            })?
291                            .permissions();
292                        permissions.set_mode(0o755);
293                        fs::set_permissions(&target, permissions).with_context(|| {
294                            format!("wizard: failed to set executable bit {}", target.display())
295                        })?;
296                    }
297                }
298            }
299            WizardStep::RunCli { command, .. } => {
300                bail!("wizard: unsupported plan step run_cli ({command})")
301            }
302            WizardStep::Delegate { id, .. } => {
303                bail!("wizard: unsupported plan step delegate ({})", id.as_str())
304            }
305            WizardStep::BuildComponent { project_root } => {
306                bail!("wizard: unsupported plan step build_component ({project_root})")
307            }
308            WizardStep::TestComponent { project_root, .. } => {
309                bail!("wizard: unsupported plan step test_component ({project_root})")
310            }
311            WizardStep::Doctor { project_root } => {
312                bail!("wizard: unsupported plan step doctor ({project_root})")
313            }
314        }
315    }
316    Ok(())
317}
318
319fn is_executable_heuristic(path: &Path) -> bool {
320    matches!(
321        path.extension().and_then(|ext| ext.to_str()),
322        Some("sh" | "bash" | "zsh" | "ps1")
323    ) || path
324        .file_name()
325        .and_then(|name| name.to_str())
326        .map(|name| name == "Makefile")
327        .unwrap_or(false)
328}
329
330pub fn load_answers_payload(path: &Path) -> Result<AnswersPayload> {
331    let json = fs::read_to_string(path)
332        .with_context(|| format!("wizard: failed to open answers file {}", path.display()))?;
333    let value: JsonValue = serde_json::from_str(&json)
334        .with_context(|| format!("wizard: answers file {} is not valid JSON", path.display()))?;
335    let cbor = canonical::to_canonical_cbor_allow_floats(&value)
336        .map_err(|err| anyhow!("wizard: failed to encode answers as CBOR: {err}"))?;
337    Ok(AnswersPayload { json, cbor })
338}
339
340struct WizardContext {
341    name: String,
342    organization: String,
343    abi_version: String,
344    prefill_mode: WizardMode,
345    prefill_answers_cbor: Option<Vec<u8>>,
346    prefill_answers_json: Option<String>,
347    user_operations: Vec<String>,
348    default_operation: String,
349    runtime_capabilities: RuntimeCapabilitiesInput,
350    config_schema: ConfigSchemaInput,
351    dependency_templates: DependencyTemplates,
352}
353
354type NormalizedAnswers = (Option<String>, Option<Vec<u8>>, Vec<String>);
355
356fn normalize_answers(
357    answers: Option<AnswersPayload>,
358    mode: WizardMode,
359) -> Result<NormalizedAnswers> {
360    let warnings = Vec::new();
361    let Some(payload) = answers else {
362        return Ok((None, None, warnings));
363    };
364    let mut value: JsonValue = serde_json::from_str(&payload.json).with_context(|| {
365        "wizard: answers JSON payload should be valid after initial parse".to_string()
366    })?;
367    let JsonValue::Object(mut root) = value else {
368        return Ok((Some(payload.json), Some(payload.cbor), warnings));
369    };
370
371    let enabled = extract_bool(&root, &["component.features.enabled", "enabled"]);
372    if let Some(flag) = enabled {
373        root.insert("enabled".to_string(), JsonValue::Bool(flag));
374    } else if matches!(
375        mode,
376        WizardMode::Default | WizardMode::Setup | WizardMode::Update
377    ) {
378        root.insert("enabled".to_string(), JsonValue::Bool(true));
379    }
380
381    value = JsonValue::Object(root);
382    let json = serde_json::to_string_pretty(&value)?;
383    let cbor = canonical::to_canonical_cbor_allow_floats(&value)
384        .map_err(|err| anyhow!("wizard: failed to encode normalized answers as CBOR: {err}"))?;
385    Ok((Some(json), Some(cbor), warnings))
386}
387
388fn extract_bool(root: &JsonMap<String, JsonValue>, keys: &[&str]) -> Option<bool> {
389    for key in keys {
390        if let Some(value) = root.get(*key)
391            && let Some(flag) = value.as_bool()
392        {
393            return Some(flag);
394        }
395        if let Some(flag) = nested_bool(root, key) {
396            return Some(flag);
397        }
398    }
399    None
400}
401
402fn nested_bool(root: &JsonMap<String, JsonValue>, dotted: &str) -> Option<bool> {
403    nested_value(root, dotted).and_then(|value| value.as_bool())
404}
405
406fn nested_value<'a>(root: &'a JsonMap<String, JsonValue>, dotted: &str) -> Option<&'a JsonValue> {
407    let mut parts = dotted.split('.');
408    let first = parts.next()?;
409    let mut current = root.get(first)?;
410    for segment in parts {
411        let JsonValue::Object(map) = current else {
412            return None;
413        };
414        current = map.get(segment)?;
415    }
416    Some(current)
417}
418
419struct GeneratedFile {
420    path: PathBuf,
421    contents: Vec<u8>,
422}
423
424fn build_files(context: &WizardContext) -> Result<Vec<GeneratedFile>> {
425    let mut files = vec![
426        text_file("Cargo.toml", render_cargo_toml(context)),
427        text_file("rust-toolchain.toml", render_rust_toolchain_toml()),
428        text_file("README.md", render_readme(context)),
429        text_file("component.manifest.json", render_manifest_json(context)),
430        text_file(
431            "schemas/component.schema.json",
432            render_component_schema_json(context),
433        ),
434        text_file("Makefile", render_makefile()),
435        text_file("build.rs", render_build_rs()),
436        text_file("src/lib.rs", render_lib_rs(context)),
437        text_file("src/qa.rs", render_qa_rs()),
438        text_file("src/i18n.rs", render_i18n_rs()),
439        text_file("src/i18n_bundle.rs", render_i18n_bundle_rs()),
440        text_file("assets/i18n/en.json", render_i18n_bundle()),
441        text_file("assets/i18n/locales.json", render_i18n_locales_json()),
442        text_file("tools/i18n.sh", render_i18n_sh()),
443    ];
444
445    if let (Some(json), Some(cbor)) = (
446        context.prefill_answers_json.as_ref(),
447        context.prefill_answers_cbor.as_ref(),
448    ) {
449        let mode = match context.prefill_mode {
450            WizardMode::Default => "default",
451            WizardMode::Setup => "setup",
452            WizardMode::Update => "update",
453            WizardMode::Remove => "remove",
454        };
455        files.push(text_file(
456            &format!("examples/{mode}.answers.json"),
457            json.clone(),
458        ));
459        files.push(binary_file(
460            &format!("examples/{mode}.answers.cbor"),
461            cbor.clone(),
462        ));
463    }
464
465    Ok(files)
466}
467
468fn build_plan(target: PathBuf, abi_version: &str, files: Vec<GeneratedFile>) -> WizardPlanEnvelope {
469    let mut dirs = BTreeSet::new();
470    for file in &files {
471        if let Some(parent) = file.path.parent()
472            && !parent.as_os_str().is_empty()
473        {
474            dirs.insert(parent.to_path_buf());
475        }
476    }
477    let mut steps: Vec<WizardStep> = Vec::new();
478    if !dirs.is_empty() {
479        let paths = dirs
480            .into_iter()
481            .map(|path| path.to_string_lossy().into_owned())
482            .collect::<Vec<_>>();
483        steps.push(WizardStep::EnsureDir { paths });
484    }
485
486    let mut file_map = BTreeMap::new();
487    for file in &files {
488        let key = file.path.to_string_lossy().into_owned();
489        file_map.insert(key, encode_step_content(&file.path, &file.contents));
490    }
491    if !file_map.is_empty() {
492        steps.push(WizardStep::WriteFiles { files: file_map });
493    }
494
495    let plan = WizardPlan {
496        meta: WizardPlanMeta {
497            id: "greentic.component.scaffold".to_string(),
498            target: WizardTarget::Component,
499            mode: WizardPlanMode::Scaffold,
500        },
501        steps,
502    };
503    let metadata = WizardPlanMetadata {
504        generator: GENERATOR_ID.to_string(),
505        template_version: TEMPLATE_VERSION.to_string(),
506        template_digest_blake3: template_digest_hex(&files),
507        requested_abi_version: abi_version.to_string(),
508    };
509    WizardPlanEnvelope {
510        plan_version: PLAN_VERSION,
511        metadata,
512        target_root: target,
513        plan,
514    }
515}
516
517const STEP_BASE64_PREFIX: &str = "base64:";
518
519fn encode_step_content(path: &Path, bytes: &[u8]) -> String {
520    if path
521        .extension()
522        .and_then(|ext| ext.to_str())
523        .is_some_and(|ext| ext == "cbor")
524    {
525        format!(
526            "{STEP_BASE64_PREFIX}{}",
527            base64::Engine::encode(&base64::engine::general_purpose::STANDARD, bytes)
528        )
529    } else {
530        String::from_utf8(bytes.to_vec()).unwrap_or_default()
531    }
532}
533
534fn decode_step_content(relative_path: &str, content: &str) -> Result<Vec<u8>> {
535    if relative_path.ends_with(".cbor") && content.starts_with(STEP_BASE64_PREFIX) {
536        let raw = content.trim_start_matches(STEP_BASE64_PREFIX);
537        let decoded = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, raw)
538            .map_err(|err| anyhow!("wizard: invalid base64 content for {relative_path}: {err}"))?;
539        return Ok(decoded);
540    }
541    Ok(content.as_bytes().to_vec())
542}
543
544fn template_digest_hex(files: &[GeneratedFile]) -> String {
545    let mut hasher = blake3::Hasher::new();
546    for file in files {
547        hasher.update(file.path.to_string_lossy().as_bytes());
548        hasher.update(&[0]);
549        hasher.update(&file.contents);
550        hasher.update(&[0xff]);
551    }
552    hasher.finalize().to_hex().to_string()
553}
554
555fn abi_warnings(abi_version: &str) -> Vec<String> {
556    if abi_version == "0.6.0" {
557        Vec::new()
558    } else {
559        vec![format!(
560            "wizard: warning: only component@0.6.0 template is generated (requested {abi_version})"
561        )]
562    }
563}
564
565fn qa_mode(mode: WizardMode) -> QaMode {
566    match mode {
567        WizardMode::Default => QaMode::Default,
568        WizardMode::Setup => QaMode::Setup,
569        WizardMode::Update => QaMode::Update,
570        WizardMode::Remove => QaMode::Remove,
571    }
572}
573
574fn render_rust_toolchain_toml() -> String {
575    r#"[toolchain]
576channel = "1.95.0"
577components = ["clippy", "rustfmt"]
578targets = ["wasm32-wasip2", "x86_64-unknown-linux-gnu"]
579profile = "minimal"
580"#
581    .to_string()
582}
583
584fn text_file(path: &str, contents: String) -> GeneratedFile {
585    GeneratedFile {
586        path: PathBuf::from(path),
587        contents: contents.into_bytes(),
588    }
589}
590
591fn binary_file(path: &str, contents: Vec<u8>) -> GeneratedFile {
592    GeneratedFile {
593        path: PathBuf::from(path),
594        contents,
595    }
596}
597
598fn render_cargo_toml(context: &WizardContext) -> String {
599    format!(
600        r#"[package]
601name = "{name}"
602version = "0.1.0"
603edition = "2024"
604license = "MIT"
605rust-version = "1.95"
606description = "Greentic component {name}"
607build = "build.rs"
608
609[lib]
610crate-type = ["cdylib", "rlib"]
611
612[package.metadata.greentic]
613abi_version = "{abi_version}"
614
615[package.metadata.component]
616package = "greentic:component"
617
618[package.metadata.component.target]
619world = "greentic:component/component@0.6.0"
620
621[dependencies]
622greentic-types = {{ {greentic_types} }}
623greentic-interfaces-guest = {{ {greentic_interfaces_guest}, default-features = false, features = ["component-v0-6"] }}
624serde = {{ version = "1", features = ["derive"] }}
625serde_json = "1"
626
627[build-dependencies]
628greentic-types = {{ {greentic_types} }}
629serde_json = "1"
630"#,
631        name = context.name,
632        abi_version = context.abi_version,
633        greentic_types = context.dependency_templates.greentic_types,
634        greentic_interfaces_guest = context.dependency_templates.greentic_interfaces_guest
635    )
636}
637
638fn render_readme(context: &WizardContext) -> String {
639    format!(
640        r#"# {name}
641
642Generated by `greentic-component wizard` for component@0.6.0.
643
644## Next steps
645- Extend QA flows in `src/qa.rs` and i18n keys in `src/i18n.rs`.
646- Canonical `component-qa` and `component-i18n` guest exports come from `greentic-interfaces-guest`, so no local QA/i18n WIT files are required.
647- Generate/update locales via `./tools/i18n.sh`.
648- Rebuild to embed translations: `cargo build`.
649
650## QA ops
651- `qa-spec`: emits setup/update/remove semantics and accepts `default|setup|install|update|upgrade|remove`.
652- `apply-answers`: returns base response shape `{{ ok, config?, warnings, errors }}`.
653- `i18n-keys`: returns i18n keys used by QA/setup messaging.
654
655## ABI version
656Requested ABI version: {abi_version}
657
658Note: the wizard currently emits a fixed 0.6.0 template.
659"#,
660        name = context.name,
661        abi_version = context.abi_version
662    )
663}
664
665fn render_makefile() -> String {
666    r#"SHELL := /bin/sh
667
668NAME := $(shell awk 'BEGIN{in_pkg=0} /^\[package\]/{in_pkg=1; next} /^\[/{in_pkg=0} in_pkg && /^name = / {gsub(/"/ , "", $$3); print $$3; exit}' Cargo.toml)
669NAME_UNDERSCORE := $(subst -,_,$(NAME))
670ABI_VERSION := $(shell awk 'BEGIN{in_meta=0} /^\[package.metadata.greentic\]/{in_meta=1; next} /^\[/{in_meta=0} in_meta && /^abi_version = / {gsub(/"/ , "", $$3); print $$3; exit}' Cargo.toml)
671ABI_VERSION_UNDERSCORE := $(subst .,_,$(ABI_VERSION))
672DIST_DIR := dist
673WASM_OUT := $(DIST_DIR)/$(NAME)__$(ABI_VERSION_UNDERSCORE).wasm
674GREENTIC_COMPONENT ?= greentic-component
675
676.PHONY: build test fmt clippy wasm doctor
677
678build:
679	cargo build
680
681test:
682	cargo test
683
684fmt:
685	cargo fmt
686
687clippy:
688	cargo clippy --all-targets --all-features -- -D warnings
689
690wasm:
691	if ! cargo component --version >/dev/null 2>&1; then \
692		echo "cargo-component is required to produce a valid component@0.6.0 wasm"; \
693		echo "install with: cargo install cargo-component --locked"; \
694		exit 1; \
695	fi
696	RUSTFLAGS= CARGO_ENCODED_RUSTFLAGS= $(GREENTIC_COMPONENT) build --manifest ./component.manifest.json
697	WASM_SRC=""; \
698	for cand in \
699		"$${CARGO_TARGET_DIR:-target}/wasm32-wasip2/release/$(NAME_UNDERSCORE).wasm" \
700		"$${CARGO_TARGET_DIR:-target}/wasm32-wasip2/release/$(NAME).wasm" \
701		"target/wasm32-wasip2/release/$(NAME_UNDERSCORE).wasm" \
702		"target/wasm32-wasip2/release/$(NAME).wasm"; do \
703		if [ -f "$$cand" ]; then WASM_SRC="$$cand"; break; fi; \
704	done; \
705	if [ -z "$$WASM_SRC" ]; then \
706		echo "unable to locate wasm32-wasip2 component build artifact for $(NAME)"; \
707		exit 1; \
708	fi; \
709	mkdir -p $(DIST_DIR); \
710	cp "$$WASM_SRC" $(WASM_OUT); \
711	$(GREENTIC_COMPONENT) hash ./component.manifest.json --wasm $(WASM_OUT)
712
713doctor:
714	$(GREENTIC_COMPONENT) doctor $(WASM_OUT) --manifest ./component.manifest.json
715"#
716    .to_string()
717}
718
719fn render_manifest_json(context: &WizardContext) -> String {
720    let name_snake = context.name.replace('-', "_");
721    let mut operations = context
722        .user_operations
723        .iter()
724        .map(|operation_name| {
725            json!({
726                "name": operation_name,
727                "input_schema": {
728                    "$schema": "https://json-schema.org/draft/2020-12/schema",
729                    "title": format!("{} {} input", context.name, operation_name),
730                    "type": "object",
731                    "required": ["input"],
732                    "properties": {
733                        "input": {
734                            "type": "string",
735                            "default": format!("Hello from {}!", context.name)
736                        }
737                    },
738                    "additionalProperties": false
739                },
740                "output_schema": {
741                    "$schema": "https://json-schema.org/draft/2020-12/schema",
742                    "title": format!("{} {} output", context.name, operation_name),
743                    "type": "object",
744                    "required": ["message"],
745                    "properties": {
746                        "message": { "type": "string" }
747                    },
748                    "additionalProperties": false
749                }
750            })
751        })
752        .collect::<Vec<_>>();
753    operations.extend([
754        json!({
755            "name": "qa-spec",
756            "input_schema": {
757                "$schema": "https://json-schema.org/draft/2020-12/schema",
758                "title": format!("{} qa-spec input", context.name),
759                "type": "object",
760                "properties": {
761                    "mode": {
762                        "type": "string",
763                        "enum": ["default", "setup", "install", "update", "upgrade", "remove"]
764                    }
765                },
766                "required": ["mode"],
767                "additionalProperties": false
768            },
769            "output_schema": {
770                "type": "object",
771                "properties": {
772                    "mode": {
773                        "type": "string",
774                        "enum": ["setup", "update", "remove"]
775                    },
776                    "title_i18n_key": { "type": "string" },
777                    "description_i18n_key": { "type": "string" },
778                    "fields": {
779                        "type": "array",
780                        "items": { "type": "object" }
781                    }
782                },
783                "required": ["mode", "fields"],
784                "additionalProperties": true
785            }
786        }),
787        json!({
788            "name": "apply-answers",
789            "input_schema": {
790                "$schema": "https://json-schema.org/draft/2020-12/schema",
791                "title": format!("{} apply-answers input", context.name),
792                "type": "object",
793                "properties": {
794                    "mode": { "type": "string" },
795                    "current_config": { "type": "object" },
796                    "answers": { "type": "object" }
797                },
798                "additionalProperties": true
799            },
800            "output_schema": {
801                "$schema": "https://json-schema.org/draft/2020-12/schema",
802                "title": format!("{} apply-answers output", context.name),
803                "type": "object",
804                "required": ["ok", "warnings", "errors"],
805                "properties": {
806                    "ok": { "type": "boolean" },
807                    "warnings": { "type": "array", "items": { "type": "string" } },
808                    "errors": { "type": "array", "items": { "type": "string" } },
809                    "config": { "type": "object" }
810                },
811                "additionalProperties": true
812            }
813        }),
814        json!({
815            "name": "i18n-keys",
816            "input_schema": {
817                "$schema": "https://json-schema.org/draft/2020-12/schema",
818                "title": format!("{} i18n-keys input", context.name),
819                "type": "object",
820                "additionalProperties": false
821            },
822            "output_schema": {
823                "$schema": "https://json-schema.org/draft/2020-12/schema",
824                "title": format!("{} i18n-keys output", context.name),
825                "type": "array",
826                "items": { "type": "string" }
827            }
828        }),
829    ]);
830
831    let mut manifest = json!({
832        "$schema": "https://greenticai.github.io/greentic-component/schemas/v1/component.manifest.schema.json",
833        "id": format!("{}.{}", context.organization, context.name),
834        "name": context.name,
835        "version": "0.1.0",
836        "world": "greentic:component/component@0.6.0",
837        "describe_export": "describe",
838        "operations": operations,
839        "default_operation": context.default_operation,
840        "config_schema": context.config_schema.manifest_schema(),
841        "supports": ["messaging"],
842        "profiles": {
843            "default": "stateless",
844            "supported": ["stateless"]
845        },
846        "secret_requirements": context.runtime_capabilities.manifest_secret_requirements(),
847        "capabilities": context.runtime_capabilities.manifest_capabilities(),
848        "limits": {
849            "memory_mb": 128,
850            "wall_time_ms": 1000
851        },
852        "artifacts": {
853            "component_wasm": format!("target/wasm32-wasip2/release/{name_snake}.wasm")
854        },
855        "hashes": {
856            "component_wasm": "blake3:0000000000000000000000000000000000000000000000000000000000000000"
857        },
858        "dev_flows": {
859            "default": {
860                "format": "flow-ir-json",
861                "graph": {
862                    "nodes": [
863                        { "id": "start", "type": "start" },
864                        { "id": "end", "type": "end" }
865                    ],
866                    "edges": [
867                        { "from": "start", "to": "end" }
868                    ]
869                }
870            }
871        }
872    });
873    if let Some(telemetry) = context.runtime_capabilities.manifest_telemetry() {
874        manifest["telemetry"] = telemetry;
875    }
876    serde_json::to_string_pretty(&manifest).expect("wizard manifest should serialize")
877}
878
879fn render_component_schema_json(context: &WizardContext) -> String {
880    serde_json::to_string_pretty(&context.config_schema.component_schema_file(&context.name))
881        .expect("wizard config schema should serialize")
882}
883
884fn render_lib_rs(context: &WizardContext) -> String {
885    let user_describe_ops = render_lib_user_describe_ops(context);
886    let config_schema_rust = context.config_schema.rust_schema_ir();
887    format!(
888        r#"#[cfg(target_arch = "wasm32")]
889use std::collections::BTreeMap;
890
891#[cfg(target_arch = "wasm32")]
892use greentic_interfaces_guest::component_v0_6::node;
893#[cfg(target_arch = "wasm32")]
894use greentic_types::cbor::canonical;
895#[cfg(target_arch = "wasm32")]
896use greentic_types::schemas::common::schema_ir::{{AdditionalProperties, SchemaIr}};
897#[cfg(target_arch = "wasm32")]
898use greentic_types::schemas::component::v0_6_0::{{ComponentInfo, I18nText}};
899
900// i18n: runtime lookup + embedded CBOR bundle helpers.
901pub mod i18n;
902pub mod i18n_bundle;
903// qa: mode normalization, QA spec generation, apply-answers validation.
904pub mod qa;
905
906const COMPONENT_NAME: &str = "{name}";
907#[cfg(target_arch = "wasm32")]
908const COMPONENT_ORG: &str = "{organization}";
909#[cfg(target_arch = "wasm32")]
910const COMPONENT_VERSION: &str = "0.1.0";
911
912#[cfg(target_arch = "wasm32")]
913#[used]
914#[unsafe(link_section = ".greentic.wasi")]
915static WASI_TARGET_MARKER: [u8; 13] = *b"wasm32-wasip2";
916
917#[cfg(target_arch = "wasm32")]
918struct Component;
919
920#[cfg(target_arch = "wasm32")]
921impl node::Guest for Component {{
922    // Component metadata advertised to host/operator tooling.
923    // Extend here when you add more operations or capability declarations.
924    fn describe() -> node::ComponentDescriptor {{
925        let input_schema_cbor = input_schema_cbor();
926        let output_schema_cbor = output_schema_cbor();
927        let mut ops = vec![
928{user_describe_ops}
929        ];
930        ops.extend(vec![
931            node::Op {{
932                name: "qa-spec".to_string(),
933                summary: Some("Return QA spec for requested mode".to_string()),
934                input: node::IoSchema {{
935                    schema: node::SchemaSource::InlineCbor(input_schema_cbor.clone()),
936                    content_type: "application/cbor".to_string(),
937                    schema_version: None,
938                }},
939                output: node::IoSchema {{
940                    schema: node::SchemaSource::InlineCbor(output_schema_cbor.clone()),
941                    content_type: "application/cbor".to_string(),
942                    schema_version: None,
943                }},
944                examples: Vec::new(),
945            }},
946            node::Op {{
947                name: "apply-answers".to_string(),
948                summary: Some("Apply QA answers and optionally return config override".to_string()),
949                input: node::IoSchema {{
950                    schema: node::SchemaSource::InlineCbor(input_schema_cbor.clone()),
951                    content_type: "application/cbor".to_string(),
952                    schema_version: None,
953                }},
954                output: node::IoSchema {{
955                    schema: node::SchemaSource::InlineCbor(output_schema_cbor.clone()),
956                    content_type: "application/cbor".to_string(),
957                    schema_version: None,
958                }},
959                examples: Vec::new(),
960            }},
961            node::Op {{
962                name: "i18n-keys".to_string(),
963                summary: Some("Return i18n keys referenced by QA/setup".to_string()),
964                input: node::IoSchema {{
965                    schema: node::SchemaSource::InlineCbor(input_schema_cbor.clone()),
966                    content_type: "application/cbor".to_string(),
967                    schema_version: None,
968                }},
969                output: node::IoSchema {{
970                    schema: node::SchemaSource::InlineCbor(output_schema_cbor),
971                    content_type: "application/cbor".to_string(),
972                    schema_version: None,
973                }},
974                examples: Vec::new(),
975            }},
976        ]);
977        node::ComponentDescriptor {{
978            name: COMPONENT_NAME.to_string(),
979            version: COMPONENT_VERSION.to_string(),
980            summary: Some(format!("Greentic component {{COMPONENT_NAME}}")),
981            capabilities: Vec::new(),
982            ops,
983            schemas: Vec::new(),
984            setup: None,
985        }}
986    }}
987
988    // Single ABI entrypoint. Keep this dispatcher model intact.
989    // Extend behavior by adding/adjusting operation branches in `run_component_cbor`.
990    fn invoke(
991        operation: String,
992        envelope: node::InvocationEnvelope,
993    ) -> Result<node::InvocationResult, node::NodeError> {{
994        let output = run_component_cbor(&operation, envelope.payload_cbor);
995        Ok(node::InvocationResult {{
996            ok: true,
997            output_cbor: output,
998            output_metadata_cbor: None,
999        }})
1000    }}
1001}}
1002
1003#[cfg(target_arch = "wasm32")]
1004#[repr(C)]
1005struct CabiList {{
1006    ptr: *mut u8,
1007    len: usize,
1008}}
1009
1010#[cfg(target_arch = "wasm32")]
1011#[repr(C)]
1012struct CabiStringList {{
1013    ptr: *mut CabiList,
1014    len: usize,
1015}}
1016
1017#[cfg(target_arch = "wasm32")]
1018static mut QA_SPEC_RET: CabiList = CabiList {{
1019    ptr: std::ptr::null_mut(),
1020    len: 0,
1021}};
1022
1023#[cfg(target_arch = "wasm32")]
1024static mut APPLY_ANSWERS_RET: CabiList = CabiList {{
1025    ptr: std::ptr::null_mut(),
1026    len: 0,
1027}};
1028
1029#[cfg(target_arch = "wasm32")]
1030static mut I18N_KEYS_RET: CabiStringList = CabiStringList {{
1031    ptr: std::ptr::null_mut(),
1032    len: 0,
1033}};
1034
1035#[cfg(target_arch = "wasm32")]
1036fn cabi_mode(mode: i32) -> qa::NormalizedMode {{
1037    match mode {{
1038        0 | 1 => qa::NormalizedMode::Setup,
1039        2 => qa::NormalizedMode::Update,
1040        3 => qa::NormalizedMode::Remove,
1041        _ => qa::NormalizedMode::Setup,
1042    }}
1043}}
1044
1045#[cfg(target_arch = "wasm32")]
1046unsafe fn export_vec_bytes(bytes: Vec<u8>, ret: *mut CabiList) -> *mut u8 {{
1047    let boxed = bytes.into_boxed_slice();
1048    let ptr = boxed.as_ptr() as *mut u8;
1049    let len = boxed.len();
1050    std::mem::forget(boxed);
1051    unsafe {{
1052        (*ret).ptr = ptr;
1053        (*ret).len = len;
1054        ret.cast()
1055    }}
1056}}
1057
1058#[cfg(target_arch = "wasm32")]
1059unsafe fn post_return_vec_bytes(arg0: *mut u8) {{
1060    let ret = unsafe {{ &*(arg0.cast::<CabiList>()) }};
1061    if ret.len == 0 || ret.ptr.is_null() {{
1062        return;
1063    }}
1064    let layout = std::alloc::Layout::array::<u8>(ret.len).expect("byte layout");
1065    unsafe {{
1066        std::alloc::dealloc(ret.ptr, layout);
1067    }}
1068}}
1069
1070#[cfg(target_arch = "wasm32")]
1071unsafe fn export_i18n_keys_list(keys: Vec<String>) -> *mut u8 {{
1072    let len = keys.len();
1073    let layout = std::alloc::Layout::array::<CabiList>(len).expect("string list layout");
1074    let ptr = if layout.size() == 0 {{
1075        std::ptr::null_mut()
1076    }} else {{
1077        let raw = unsafe {{ std::alloc::alloc(layout) }}.cast::<CabiList>();
1078        if raw.is_null() {{
1079            std::alloc::handle_alloc_error(layout);
1080        }}
1081        raw
1082    }};
1083    for (idx, key) in keys.into_iter().enumerate() {{
1084        let boxed = key.into_bytes().into_boxed_slice();
1085        let item_ptr = boxed.as_ptr() as *mut u8;
1086        let item_len = boxed.len();
1087        std::mem::forget(boxed);
1088        unsafe {{
1089            ptr.add(idx).write(CabiList {{
1090                ptr: item_ptr,
1091                len: item_len,
1092            }});
1093        }}
1094    }}
1095    unsafe {{
1096        I18N_KEYS_RET.ptr = ptr;
1097        I18N_KEYS_RET.len = len;
1098        (&raw mut I18N_KEYS_RET).cast()
1099    }}
1100}}
1101
1102#[cfg(target_arch = "wasm32")]
1103unsafe fn post_return_i18n_keys(arg0: *mut u8) {{
1104    let ret = unsafe {{ &*(arg0.cast::<CabiStringList>()) }};
1105    for idx in 0..ret.len {{
1106        let item = unsafe {{ &*ret.ptr.add(idx) }};
1107        if item.len == 0 || item.ptr.is_null() {{
1108            continue;
1109        }}
1110        let layout = std::alloc::Layout::array::<u8>(item.len).expect("string layout");
1111        unsafe {{
1112            std::alloc::dealloc(item.ptr, layout);
1113        }}
1114    }}
1115    if ret.len == 0 || ret.ptr.is_null() {{
1116        return;
1117    }}
1118    let layout = std::alloc::Layout::array::<CabiList>(ret.len).expect("string list layout");
1119    unsafe {{
1120        std::alloc::dealloc(ret.ptr.cast(), layout);
1121    }}
1122}}
1123
1124#[cfg(target_arch = "wasm32")]
1125#[unsafe(export_name = "greentic:component/component-qa@0.6.0#qa-spec")]
1126unsafe extern "C" fn export_component_qa_spec(mode: i32) -> *mut u8 {{
1127    let bytes = qa::qa_spec_cbor(cabi_mode(mode));
1128    unsafe {{ export_vec_bytes(bytes, &raw mut QA_SPEC_RET) }}
1129}}
1130
1131#[cfg(target_arch = "wasm32")]
1132#[unsafe(export_name = "cabi_post_greentic:component/component-qa@0.6.0#qa-spec")]
1133unsafe extern "C" fn post_return_component_qa_spec(arg0: *mut u8) {{
1134    unsafe {{ post_return_vec_bytes(arg0) }}
1135}}
1136
1137#[cfg(target_arch = "wasm32")]
1138#[unsafe(export_name = "greentic:component/component-qa@0.6.0#apply-answers")]
1139unsafe extern "C" fn export_component_apply_answers(
1140    mode: i32,
1141    current_config_ptr: *mut u8,
1142    current_config_len: usize,
1143    answers_ptr: *mut u8,
1144    answers_len: usize,
1145) -> *mut u8 {{
1146    let current_config = unsafe {{
1147        Vec::from_raw_parts(current_config_ptr, current_config_len, current_config_len)
1148    }};
1149    let answers = unsafe {{ Vec::from_raw_parts(answers_ptr, answers_len, answers_len) }};
1150    let bytes = qa::apply_answers_cbor(cabi_mode(mode), &current_config, &answers);
1151    unsafe {{ export_vec_bytes(bytes, &raw mut APPLY_ANSWERS_RET) }}
1152}}
1153
1154#[cfg(target_arch = "wasm32")]
1155#[unsafe(export_name = "cabi_post_greentic:component/component-qa@0.6.0#apply-answers")]
1156unsafe extern "C" fn post_return_component_apply_answers(arg0: *mut u8) {{
1157    unsafe {{ post_return_vec_bytes(arg0) }}
1158}}
1159
1160#[cfg(target_arch = "wasm32")]
1161#[unsafe(export_name = "greentic:component/component-i18n@0.6.0#i18n-keys")]
1162unsafe extern "C" fn export_component_i18n_keys() -> *mut u8 {{
1163    unsafe {{ export_i18n_keys_list(qa::i18n_keys()) }}
1164}}
1165
1166#[cfg(target_arch = "wasm32")]
1167#[unsafe(export_name = "cabi_post_greentic:component/component-i18n@0.6.0#i18n-keys")]
1168unsafe extern "C" fn post_return_component_i18n_keys(arg0: *mut u8) {{
1169    unsafe {{ post_return_i18n_keys(arg0) }}
1170}}
1171
1172#[cfg(target_arch = "wasm32")]
1173greentic_interfaces_guest::export_component_v060!(Component);
1174
1175// Default user-operation implementation.
1176// Replace this with domain logic for your component.
1177pub fn handle_message(operation: &str, input: &str) -> String {{
1178    format!("{{COMPONENT_NAME}}::{{operation}} => {{}}", input.trim())
1179}}
1180
1181#[cfg(target_arch = "wasm32")]
1182fn encode_cbor<T: serde::Serialize>(value: &T) -> Vec<u8> {{
1183    canonical::to_canonical_cbor_allow_floats(value).expect("encode cbor")
1184}}
1185
1186#[cfg(target_arch = "wasm32")]
1187// Accept canonical CBOR first, then fall back to JSON for local debugging.
1188fn parse_payload(input: &[u8]) -> serde_json::Value {{
1189    if let Ok(value) = canonical::from_cbor(input) {{
1190        return value;
1191    }}
1192    serde_json::from_slice(input).unwrap_or_else(|_| serde_json::json!({{}}))
1193}}
1194
1195#[cfg(target_arch = "wasm32")]
1196// Keep ingress compatibility: default/setup/install -> setup, update/upgrade -> update.
1197fn normalized_mode(payload: &serde_json::Value) -> qa::NormalizedMode {{
1198    let mode = payload
1199        .get("mode")
1200        .and_then(|v| v.as_str())
1201        .or_else(|| payload.get("operation").and_then(|v| v.as_str()))
1202        .unwrap_or("setup");
1203    qa::normalize_mode(mode).unwrap_or(qa::NormalizedMode::Setup)
1204}}
1205
1206#[cfg(target_arch = "wasm32")]
1207// Minimal schema for generic operation input.
1208// Extend these schemas when you harden operation contracts.
1209fn input_schema() -> SchemaIr {{
1210    SchemaIr::Object {{
1211        properties: BTreeMap::from([(
1212            "input".to_string(),
1213            SchemaIr::String {{
1214                min_len: Some(0),
1215                max_len: None,
1216                regex: None,
1217                format: None,
1218            }},
1219        )]),
1220        required: vec!["input".to_string()],
1221        additional: AdditionalProperties::Allow,
1222    }}
1223}}
1224
1225#[cfg(target_arch = "wasm32")]
1226fn output_schema() -> SchemaIr {{
1227    SchemaIr::Object {{
1228        properties: BTreeMap::from([(
1229            "message".to_string(),
1230            SchemaIr::String {{
1231                min_len: Some(0),
1232                max_len: None,
1233                regex: None,
1234                format: None,
1235            }},
1236        )]),
1237        required: vec!["message".to_string()],
1238        additional: AdditionalProperties::Allow,
1239    }}
1240}}
1241
1242#[cfg(target_arch = "wasm32")]
1243#[allow(dead_code)]
1244fn config_schema() -> SchemaIr {{
1245    {config_schema_rust}
1246}}
1247
1248#[cfg(target_arch = "wasm32")]
1249#[allow(dead_code)]
1250fn component_info() -> ComponentInfo {{
1251    ComponentInfo {{
1252        id: format!("{{COMPONENT_ORG}}.{{COMPONENT_NAME}}"),
1253        version: COMPONENT_VERSION.to_string(),
1254        role: "tool".to_string(),
1255        display_name: Some(I18nText::new("component.display_name", Some(COMPONENT_NAME.to_string()))),
1256    }}
1257}}
1258
1259#[cfg(target_arch = "wasm32")]
1260fn input_schema_cbor() -> Vec<u8> {{
1261    encode_cbor(&input_schema())
1262}}
1263
1264#[cfg(target_arch = "wasm32")]
1265fn output_schema_cbor() -> Vec<u8> {{
1266    encode_cbor(&output_schema())
1267}}
1268
1269#[cfg(target_arch = "wasm32")]
1270// Central operation dispatcher.
1271// This is the primary extension point for new operations.
1272fn run_component_cbor(operation: &str, input: Vec<u8>) -> Vec<u8> {{
1273    let value = parse_payload(&input);
1274    let output = match operation {{
1275        "qa-spec" => {{
1276            let mode = normalized_mode(&value);
1277            qa::qa_spec_json(mode)
1278        }}
1279        "apply-answers" => {{
1280            let mode = normalized_mode(&value);
1281            qa::apply_answers(mode, &value)
1282        }}
1283        "i18n-keys" => serde_json::Value::Array(
1284            qa::i18n_keys()
1285                .into_iter()
1286                .map(serde_json::Value::String)
1287                .collect(),
1288        ),
1289        _ => {{
1290            let op_name = value
1291                .get("operation")
1292                .and_then(|v| v.as_str())
1293                .unwrap_or(operation);
1294            let input_text = value
1295                .get("input")
1296                .and_then(|v| v.as_str())
1297                .map(ToOwned::to_owned)
1298                .unwrap_or_else(|| value.to_string());
1299            serde_json::json!({{
1300                "message": handle_message(op_name, &input_text)
1301            }})
1302        }}
1303    }};
1304    encode_cbor(&output)
1305}}
1306"#,
1307        name = context.name,
1308        organization = context.organization,
1309        user_describe_ops = user_describe_ops
1310    )
1311}
1312
1313fn render_lib_user_describe_ops(context: &WizardContext) -> String {
1314    context
1315        .user_operations
1316        .iter()
1317        .map(|name| {
1318            format!(
1319                r#"            node::Op {{
1320                name: "{name}".to_string(),
1321                summary: Some("Handle a single message input".to_string()),
1322                input: node::IoSchema {{
1323                    schema: node::SchemaSource::InlineCbor(input_schema_cbor.clone()),
1324                    content_type: "application/cbor".to_string(),
1325                    schema_version: None,
1326                }},
1327                output: node::IoSchema {{
1328                    schema: node::SchemaSource::InlineCbor(output_schema_cbor.clone()),
1329                    content_type: "application/cbor".to_string(),
1330                    schema_version: None,
1331                }},
1332                examples: Vec::new(),
1333            }}"#,
1334                name = name
1335            )
1336        })
1337        .collect::<Vec<_>>()
1338        .join(",\n")
1339}
1340
1341fn render_qa_rs() -> String {
1342    r#"use greentic_types::cbor::canonical;
1343use greentic_types::i18n_text::I18nText;
1344use greentic_types::schemas::component::v0_6_0::{QaMode, Question};
1345use serde_json::{json, Value as JsonValue};
1346
1347// Internal normalized lifecycle semantics used by scaffolded QA operations.
1348// Input compatibility accepts legacy/provision aliases via `normalize_mode`.
1349#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1350pub enum NormalizedMode {
1351    Setup,
1352    Update,
1353    Remove,
1354}
1355
1356impl NormalizedMode {
1357    pub fn as_str(self) -> &'static str {
1358        match self {
1359            Self::Setup => "setup",
1360            Self::Update => "update",
1361            Self::Remove => "remove",
1362        }
1363    }
1364}
1365
1366// Compatibility mapping for mode strings from operator/flow payloads.
1367pub fn normalize_mode(raw: &str) -> Option<NormalizedMode> {
1368    match raw {
1369        "default" | "setup" | "install" => Some(NormalizedMode::Setup),
1370        "update" | "upgrade" => Some(NormalizedMode::Update),
1371        "remove" => Some(NormalizedMode::Remove),
1372        _ => None,
1373    }
1374}
1375
1376// Primary QA authoring entrypoint.
1377// Extend question sets here for your real setup/update/remove requirements.
1378pub fn qa_spec_cbor(mode: NormalizedMode) -> Vec<u8> {
1379    canonical::to_canonical_cbor_allow_floats(&qa_spec_json(mode)).unwrap_or_default()
1380}
1381
1382pub fn qa_spec_json(mode: NormalizedMode) -> JsonValue {
1383    let (title_key, description_key, questions) = match mode {
1384        NormalizedMode::Setup => (
1385            "qa.install.title",
1386            Some("qa.install.description"),
1387            vec![
1388                question("api_key", "qa.field.api_key.label", "qa.field.api_key.help", true),
1389                question("region", "qa.field.region.label", "qa.field.region.help", true),
1390                question(
1391                    "webhook_base_url",
1392                    "qa.field.webhook_base_url.label",
1393                    "qa.field.webhook_base_url.help",
1394                    true,
1395                ),
1396                question("enabled", "qa.field.enabled.label", "qa.field.enabled.help", false),
1397            ],
1398        ),
1399        NormalizedMode::Update => (
1400            "qa.update.title",
1401            Some("qa.update.description"),
1402            vec![
1403                question("api_key", "qa.field.api_key.label", "qa.field.api_key.help", false),
1404                question("region", "qa.field.region.label", "qa.field.region.help", false),
1405                question(
1406                    "webhook_base_url",
1407                    "qa.field.webhook_base_url.label",
1408                    "qa.field.webhook_base_url.help",
1409                    false,
1410                ),
1411                question("enabled", "qa.field.enabled.label", "qa.field.enabled.help", false),
1412            ],
1413        ),
1414        NormalizedMode::Remove => (
1415            "qa.remove.title",
1416            Some("qa.remove.description"),
1417            vec![question(
1418                "confirm_remove",
1419                "qa.field.confirm_remove.label",
1420                "qa.field.confirm_remove.help",
1421                true,
1422            )],
1423        ),
1424    };
1425
1426    json!({
1427        "mode": match mode {
1428            NormalizedMode::Setup => QaMode::Setup,
1429            NormalizedMode::Update => QaMode::Update,
1430            NormalizedMode::Remove => QaMode::Remove,
1431        },
1432        "title": I18nText::new(title_key, None),
1433        "description": description_key.map(|key| I18nText::new(key, None)),
1434        "questions": questions,
1435        "defaults": {}
1436    })
1437}
1438
1439pub fn apply_answers_cbor(
1440    mode: NormalizedMode,
1441    current_config: &[u8],
1442    answers: &[u8],
1443) -> Vec<u8> {
1444    let payload = json!({
1445        "current_config": decode_json_or_empty(current_config),
1446        "answers": decode_json_or_empty(answers),
1447    });
1448    canonical::to_canonical_cbor_allow_floats(&apply_answers(mode, &payload)).unwrap_or_default()
1449}
1450
1451fn question(id: &str, label_key: &str, help_key: &str, required: bool) -> Question {
1452    serde_json::from_value(json!({
1453        "id": id,
1454        "label": I18nText::new(label_key, None),
1455        "help": I18nText::new(help_key, None),
1456        "error": null,
1457        "kind": { "type": "text" },
1458        "required": required,
1459        "default": null
1460    }))
1461    .expect("question should deserialize")
1462}
1463
1464// Used by `i18n-keys` operation and contract checks in operator.
1465pub fn i18n_keys() -> Vec<String> {
1466    crate::i18n::all_keys()
1467}
1468
1469// Apply answers and return operator-friendly base shape:
1470// { ok, config?, warnings, errors, ...optional metadata }
1471// Extend this method for domain validation rules and config patching.
1472pub fn apply_answers(mode: NormalizedMode, payload: &JsonValue) -> JsonValue {
1473    let answers = payload.get("answers").cloned().unwrap_or_else(|| json!({}));
1474    let current_config = payload
1475        .get("current_config")
1476        .cloned()
1477        .unwrap_or_else(|| json!({}));
1478
1479    let mut errors = Vec::new();
1480    match mode {
1481        NormalizedMode::Setup => {
1482            for key in ["api_key", "region", "webhook_base_url"] {
1483                if answers.get(key).and_then(|v| v.as_str()).is_none() {
1484                    errors.push(json!({
1485                        "key": "qa.error.required",
1486                        "msg_key": "qa.error.required",
1487                        "fields": [key]
1488                    }));
1489                }
1490            }
1491        }
1492        NormalizedMode::Remove => {
1493            if answers
1494                .get("confirm_remove")
1495                .and_then(|v| v.as_str())
1496                .map(|v| v != "true")
1497                .unwrap_or(true)
1498            {
1499                errors.push(json!({
1500                    "key": "qa.error.remove_confirmation",
1501                    "msg_key": "qa.error.remove_confirmation",
1502                    "fields": ["confirm_remove"]
1503                }));
1504            }
1505        }
1506        NormalizedMode::Update => {}
1507    }
1508
1509    if !errors.is_empty() {
1510        return json!({
1511            "ok": false,
1512            "warnings": [],
1513            "errors": errors,
1514            "meta": {
1515                "mode": mode.as_str(),
1516                "version": "v1"
1517            }
1518        });
1519    }
1520
1521    let mut config = match current_config {
1522        JsonValue::Object(map) => map,
1523        _ => serde_json::Map::new(),
1524    };
1525    if let JsonValue::Object(map) = answers {
1526        for (key, value) in map {
1527            config.insert(key, value);
1528        }
1529    }
1530    if mode == NormalizedMode::Remove {
1531        config.insert("enabled".to_string(), JsonValue::Bool(false));
1532    }
1533
1534    json!({
1535        "ok": true,
1536        "config": config,
1537        "warnings": [],
1538        "errors": [],
1539        "meta": {
1540            "mode": mode.as_str(),
1541            "version": "v1"
1542        },
1543        "audit": {
1544            "reasons": ["qa.apply_answers"],
1545            "timings_ms": {}
1546        }
1547    })
1548}
1549
1550fn decode_json_or_empty(bytes: &[u8]) -> JsonValue {
1551    if let Ok(value) = canonical::from_cbor(bytes) {
1552        return value;
1553    }
1554    serde_json::from_slice(bytes).unwrap_or_else(|_| json!({}))
1555}
1556"#
1557    .to_string()
1558}
1559
1560#[allow(dead_code)]
1561fn render_descriptor_rs(context: &WizardContext) -> String {
1562    let _ = context;
1563    String::new()
1564}
1565
1566#[allow(dead_code)]
1567fn render_capability_list(capabilities: &[String]) -> String {
1568    let _ = capabilities;
1569    "&[]".to_string()
1570}
1571
1572#[allow(dead_code)]
1573fn render_schema_rs() -> String {
1574    r#"use std::collections::BTreeMap;
1575
1576use greentic_types::cbor::canonical;
1577use greentic_types::schemas::common::schema_ir::{AdditionalProperties, SchemaIr};
1578
1579pub fn input_schema() -> SchemaIr {
1580    object_schema(vec![(
1581        "message",
1582        SchemaIr::String {
1583            min_len: Some(1),
1584            max_len: Some(1024),
1585            regex: None,
1586            format: None,
1587        },
1588    )])
1589}
1590
1591pub fn output_schema() -> SchemaIr {
1592    object_schema(vec![(
1593        "result",
1594        SchemaIr::String {
1595            min_len: Some(1),
1596            max_len: Some(1024),
1597            regex: None,
1598            format: None,
1599        },
1600    )])
1601}
1602
1603pub fn config_schema() -> SchemaIr {
1604    object_schema(vec![("enabled", SchemaIr::Bool)])
1605}
1606
1607pub fn input_schema_cbor() -> Vec<u8> {
1608    canonical::to_canonical_cbor_allow_floats(&input_schema()).unwrap_or_default()
1609}
1610
1611pub fn output_schema_cbor() -> Vec<u8> {
1612    canonical::to_canonical_cbor_allow_floats(&output_schema()).unwrap_or_default()
1613}
1614
1615pub fn config_schema_cbor() -> Vec<u8> {
1616    canonical::to_canonical_cbor_allow_floats(&config_schema()).unwrap_or_default()
1617}
1618
1619fn object_schema(props: Vec<(&str, SchemaIr)>) -> SchemaIr {
1620    let mut properties = BTreeMap::new();
1621    let mut required = Vec::new();
1622    for (name, schema) in props {
1623        properties.insert(name.to_string(), schema);
1624        required.push(name.to_string());
1625    }
1626    SchemaIr::Object {
1627        properties,
1628        required,
1629        additional: AdditionalProperties::Forbid,
1630    }
1631}
1632"#
1633    .to_string()
1634}
1635
1636#[allow(dead_code)]
1637fn render_runtime_rs() -> String {
1638    r#"use std::collections::BTreeMap;
1639
1640use greentic_types::cbor::canonical;
1641use serde_json::Value as JsonValue;
1642
1643pub fn run(input: Vec<u8>, state: Vec<u8>) -> (Vec<u8>, Vec<u8>) {
1644    let input_map = decode_map(&input);
1645    let message = input_map
1646        .get("message")
1647        .and_then(|value| value.as_str())
1648        .unwrap_or("ok");
1649    let mut output = BTreeMap::new();
1650    output.insert(
1651        "result".to_string(),
1652        JsonValue::String(format!("processed: {message}")),
1653    );
1654    let output_cbor = canonical::to_canonical_cbor_allow_floats(&output).unwrap_or_default();
1655    let state_cbor = canonicalize_or_empty(&state);
1656    (output_cbor, state_cbor)
1657}
1658
1659fn canonicalize_or_empty(bytes: &[u8]) -> Vec<u8> {
1660    let empty = || {
1661        canonical::to_canonical_cbor_allow_floats(&BTreeMap::<String, JsonValue>::new())
1662            .unwrap_or_default()
1663    };
1664    if bytes.is_empty() {
1665        return empty();
1666    }
1667    let value: JsonValue = match canonical::from_cbor(bytes) {
1668        Ok(value) => value,
1669        Err(_) => return empty(),
1670    };
1671    canonical::to_canonical_cbor_allow_floats(&value).unwrap_or_default()
1672}
1673
1674fn decode_map(bytes: &[u8]) -> BTreeMap<String, JsonValue> {
1675    if bytes.is_empty() {
1676        return BTreeMap::new();
1677    }
1678    let value: JsonValue = match canonical::from_cbor(bytes) {
1679        Ok(value) => value,
1680        Err(_) => return BTreeMap::new(),
1681    };
1682    let JsonValue::Object(map) = value else {
1683        return BTreeMap::new();
1684    };
1685    map.into_iter().collect()
1686}
1687"#
1688    .to_string()
1689}
1690
1691fn render_i18n_rs() -> String {
1692    r#"use std::collections::BTreeMap;
1693use std::sync::OnceLock;
1694
1695use crate::i18n_bundle::{unpack_locales_from_cbor, LocaleBundle};
1696
1697// Generated by build.rs: static embedded CBOR translation bundle.
1698include!(concat!(env!("OUT_DIR"), "/i18n_bundle.rs"));
1699
1700// Decode once for process lifetime.
1701static I18N_BUNDLE: OnceLock<LocaleBundle> = OnceLock::new();
1702
1703fn bundle() -> &'static LocaleBundle {
1704    I18N_BUNDLE.get_or_init(|| unpack_locales_from_cbor(I18N_BUNDLE_CBOR).unwrap_or_default())
1705}
1706
1707// Fallback precedence is deterministic:
1708// exact locale -> base language -> en
1709fn locale_chain(locale: &str) -> Vec<String> {
1710    let normalized = locale.replace('_', "-");
1711    let mut chain = vec![normalized.clone()];
1712    if let Some((base, _)) = normalized.split_once('-') {
1713        chain.push(base.to_string());
1714    }
1715    chain.push("en".to_string());
1716    chain
1717}
1718
1719// Translation lookup function used throughout generated QA/setup code.
1720// Extend by adding pluralization/context handling if your component needs it.
1721pub fn t(locale: &str, key: &str) -> String {
1722    for candidate in locale_chain(locale) {
1723        if let Some(map) = bundle().get(&candidate)
1724            && let Some(value) = map.get(key)
1725        {
1726            return value.clone();
1727        }
1728    }
1729    key.to_string()
1730}
1731
1732// Returns canonical source key list (from `en`).
1733pub fn all_keys() -> Vec<String> {
1734    let Some(en) = bundle().get("en") else {
1735        return Vec::new();
1736    };
1737    en.keys().cloned().collect()
1738}
1739
1740// Returns English dictionary for diagnostics/tests/tools.
1741pub fn en_messages() -> BTreeMap<String, String> {
1742    bundle().get("en").cloned().unwrap_or_default()
1743}
1744"#
1745    .to_string()
1746}
1747
1748fn render_i18n_bundle() -> String {
1749    r#"{
1750  "qa.install.title": "Install configuration",
1751  "qa.install.description": "Provide values for initial provider setup.",
1752  "qa.update.title": "Update configuration",
1753  "qa.update.description": "Adjust existing provider settings.",
1754  "qa.remove.title": "Remove configuration",
1755  "qa.remove.description": "Confirm provider removal settings.",
1756  "qa.field.api_key.label": "API key",
1757  "qa.field.api_key.help": "Secret key used to authenticate provider requests.",
1758  "qa.field.region.label": "Region",
1759  "qa.field.region.help": "Region identifier for the provider account.",
1760  "qa.field.webhook_base_url.label": "Webhook base URL",
1761  "qa.field.webhook_base_url.help": "Public base URL used for webhook callbacks.",
1762  "qa.field.enabled.label": "Enable provider",
1763  "qa.field.enabled.help": "Enable this provider after setup completes.",
1764  "qa.field.confirm_remove.label": "Confirm removal",
1765  "qa.field.confirm_remove.help": "Set to true to allow provider removal.",
1766  "qa.error.required": "One or more required fields are missing.",
1767  "qa.error.remove_confirmation": "Removal requires explicit confirmation."
1768}
1769"#
1770    .to_string()
1771}
1772
1773fn render_i18n_locales_json() -> String {
1774    r#"["ar","ar-AE","ar-DZ","ar-EG","ar-IQ","ar-MA","ar-SA","ar-SD","ar-SY","ar-TN","ay","bg","bn","cs","da","de","el","en-GB","es","et","fa","fi","fr","fr-FR","gn","gu","hi","hr","ht","hu","id","it","ja","km","kn","ko","lo","lt","lv","ml","mr","ms","my","nah","ne","nl","nl-NL","no","pa","pl","pt","qu","ro","ru","si","sk","sr","sv","ta","te","th","tl","tr","uk","ur","vi","zh"]
1775"#
1776    .to_string()
1777}
1778
1779fn render_i18n_bundle_rs() -> String {
1780    r#"use std::collections::BTreeMap;
1781use std::fs;
1782use std::path::Path;
1783
1784use greentic_types::cbor::canonical;
1785
1786// Locale -> (key -> translated message)
1787pub type LocaleBundle = BTreeMap<String, BTreeMap<String, String>>;
1788
1789// Reads `assets/i18n/*.json` locale maps and returns stable BTreeMap ordering.
1790// Extend here if you need stricter file validation rules.
1791pub fn load_locale_files(dir: &Path) -> Result<LocaleBundle, String> {
1792    let mut locales = LocaleBundle::new();
1793    if !dir.exists() {
1794        return Ok(locales);
1795    }
1796    for entry in fs::read_dir(dir).map_err(|err| err.to_string())? {
1797        let entry = entry.map_err(|err| err.to_string())?;
1798        let path = entry.path();
1799        if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
1800            continue;
1801        }
1802        let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
1803            continue;
1804        };
1805        // locales.json is metadata, not a translation dictionary.
1806        if stem == "locales" {
1807            continue;
1808        }
1809        let raw = fs::read_to_string(&path).map_err(|err| err.to_string())?;
1810        let map: BTreeMap<String, String> = serde_json::from_str(&raw).map_err(|err| err.to_string())?;
1811        locales.insert(stem.to_string(), map);
1812    }
1813    Ok(locales)
1814}
1815
1816pub fn pack_locales_to_cbor(locales: &LocaleBundle) -> Result<Vec<u8>, String> {
1817    canonical::to_canonical_cbor_allow_floats(locales).map_err(|err| err.to_string())
1818}
1819
1820#[allow(dead_code)]
1821// Runtime decode helper used by src/i18n.rs.
1822pub fn unpack_locales_from_cbor(bytes: &[u8]) -> Result<LocaleBundle, String> {
1823    canonical::from_cbor(bytes).map_err(|err| err.to_string())
1824}
1825
1826#[cfg(test)]
1827mod tests {
1828    use super::*;
1829
1830    #[test]
1831    fn pack_roundtrip_contains_en() {
1832        let mut locales = LocaleBundle::new();
1833        let mut en = BTreeMap::new();
1834        en.insert("qa.install.title".to_string(), "Install".to_string());
1835        locales.insert("en".to_string(), en);
1836
1837        let cbor = pack_locales_to_cbor(&locales).expect("pack locales");
1838        let decoded = unpack_locales_from_cbor(&cbor).expect("decode locales");
1839        assert!(decoded.contains_key("en"));
1840    }
1841}
1842"#
1843    .to_string()
1844}
1845
1846fn render_build_rs() -> String {
1847    r#"#[path = "src/i18n_bundle.rs"]
1848mod i18n_bundle;
1849
1850use std::env;
1851use std::fs;
1852use std::path::Path;
1853
1854// Build-time embedding pipeline:
1855// 1) Read assets/i18n/*.json
1856// 2) Pack canonical CBOR bundle
1857// 3) Emit OUT_DIR constants included by src/i18n.rs
1858fn main() {
1859    let i18n_dir = Path::new("assets/i18n");
1860    println!("cargo:rerun-if-changed={}", i18n_dir.display());
1861
1862    let locales = i18n_bundle::load_locale_files(i18n_dir)
1863        .unwrap_or_else(|err| panic!("failed to load locale files: {err}"));
1864    let bundle = i18n_bundle::pack_locales_to_cbor(&locales)
1865        .unwrap_or_else(|err| panic!("failed to pack locale bundle: {err}"));
1866
1867    let out_dir = env::var("OUT_DIR").expect("OUT_DIR must be set by cargo");
1868    let bundle_path = Path::new(&out_dir).join("i18n.bundle.cbor");
1869    fs::write(&bundle_path, bundle).expect("write i18n.bundle.cbor");
1870
1871    let rs_path = Path::new(&out_dir).join("i18n_bundle.rs");
1872    fs::write(
1873        &rs_path,
1874        "pub const I18N_BUNDLE_CBOR: &[u8] = include_bytes!(concat!(env!(\"OUT_DIR\"), \"/i18n.bundle.cbor\"));\n",
1875    )
1876    .expect("write i18n_bundle.rs");
1877}
1878"#
1879    .to_string()
1880}
1881
1882fn render_i18n_sh() -> String {
1883    r#"#!/usr/bin/env bash
1884set -euo pipefail
1885
1886ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
1887LOCALES_FILE="$ROOT_DIR/assets/i18n/locales.json"
1888SOURCE_FILE="$ROOT_DIR/assets/i18n/en.json"
1889
1890log() {
1891  printf '[i18n] %s\n' "$*"
1892}
1893
1894fail() {
1895  printf '[i18n] error: %s\n' "$*" >&2
1896  exit 1
1897}
1898
1899ensure_codex() {
1900  if command -v codex >/dev/null 2>&1 && codex --version >/dev/null 2>&1; then
1901    return
1902  fi
1903  log "Codex CLI missing or broken; attempting install"
1904  if command -v npm >/dev/null 2>&1; then
1905    log "installing Codex CLI via npm"
1906    npm i -g @openai/codex@latest || fail "failed to install Codex CLI via npm"
1907  elif command -v brew >/dev/null 2>&1; then
1908    log "installing Codex CLI via brew"
1909    brew install codex || fail "failed to install Codex CLI via brew"
1910  else
1911    fail "Codex CLI not found and no supported installer available (npm or brew)"
1912  fi
1913  command -v codex >/dev/null 2>&1 || fail "Codex CLI install completed but codex is still not on PATH"
1914  codex --version >/dev/null 2>&1 || fail "Codex CLI is still unusable after install"
1915}
1916
1917ensure_codex_login() {
1918  if codex login status >/dev/null 2>&1; then
1919    return
1920  fi
1921  log "Codex login status unavailable or not logged in; starting login flow"
1922  codex login || fail "Codex login failed"
1923}
1924
1925probe_translator() {
1926  if ! command -v greentic-i18n-translator >/dev/null 2>&1; then
1927    command -v cargo-binstall >/dev/null 2>&1 || fail "greentic-i18n-translator not found and cargo-binstall is unavailable"
1928    log "installing greentic-i18n-translator via cargo-binstall"
1929    cargo binstall -y greentic-i18n-translator || fail "failed to install greentic-i18n-translator via cargo-binstall"
1930  fi
1931  command -v greentic-i18n-translator >/dev/null 2>&1 || fail "greentic-i18n-translator is still not on PATH after cargo-binstall"
1932  local help_output
1933  help_output="$(greentic-i18n-translator --help 2>&1 || true)"
1934  [[ -n "$help_output" ]] || fail "unable to inspect greentic-i18n-translator --help"
1935  if ! greentic-i18n-translator translate --help >/dev/null 2>&1; then
1936    fail "translator subcommand 'translate' is required but unavailable"
1937  fi
1938}
1939
1940setup_codex_wrapper() {
1941  command -v codex >/dev/null 2>&1 || return 0
1942  local real_codex
1943  real_codex="$(command -v codex)"
1944  local wrapper_dir
1945  wrapper_dir="$(mktemp -d)"
1946  cat > "$wrapper_dir/codex" <<EOF
1947#!/usr/bin/env bash
1948set -euo pipefail
1949if [[ "\${1:-}" == "exec" ]]; then
1950  shift
1951  exec "$real_codex" exec --skip-git-repo-check "\$@"
1952fi
1953exec "$real_codex" "\$@"
1954EOF
1955  chmod +x "$wrapper_dir/codex"
1956  export PATH="$wrapper_dir:$PATH"
1957}
1958
1959run_translate() {
1960  while IFS= read -r locale; do
1961    [[ -n "$locale" ]] || continue
1962    log "translating locale: $locale"
1963    greentic-i18n-translator translate \
1964      --langs "$locale" \
1965      --en "$SOURCE_FILE" || fail "translate failed for locale $locale"
1966  done < <(python3 - "$LOCALES_FILE" <<'PY'
1967import json
1968import sys
1969with open(sys.argv[1], 'r', encoding='utf-8') as f:
1970    data = json.load(f)
1971for locale in data:
1972    if locale != "en":
1973        print(locale)
1974PY
1975)
1976}
1977
1978run_validate_per_locale() {
1979  local failed=0
1980  while IFS= read -r locale; do
1981    [[ -n "$locale" ]] || continue
1982    if ! greentic-i18n-translator validate --langs "$locale" --en "$SOURCE_FILE"; then
1983      log "validate failed for locale: $locale"
1984      failed=1
1985    fi
1986  done < <(python3 - "$LOCALES_FILE" <<'PY'
1987import json
1988import sys
1989with open(sys.argv[1], 'r', encoding='utf-8') as f:
1990    data = json.load(f)
1991for locale in data:
1992    if locale != "en":
1993        print(locale)
1994PY
1995)
1996  return "$failed"
1997}
1998
1999run_status_per_locale() {
2000  local failed=0
2001  while IFS= read -r locale; do
2002    [[ -n "$locale" ]] || continue
2003    if ! greentic-i18n-translator status --langs "$locale" --en "$SOURCE_FILE"; then
2004      log "status failed for locale: $locale"
2005      failed=1
2006    fi
2007  done < <(python3 - "$LOCALES_FILE" <<'PY'
2008import json
2009import sys
2010with open(sys.argv[1], 'r', encoding='utf-8') as f:
2011    data = json.load(f)
2012for locale in data:
2013    if locale != "en":
2014        print(locale)
2015PY
2016)
2017  return "$failed"
2018}
2019
2020run_optional_checks() {
2021  if greentic-i18n-translator validate --help >/dev/null 2>&1; then
2022    log "running translator validate"
2023    if ! run_validate_per_locale; then
2024      fail "translator validate failed"
2025    fi
2026  else
2027    log "warning: translator validate command not available; skipping"
2028  fi
2029  if greentic-i18n-translator status --help >/dev/null 2>&1; then
2030    log "running translator status"
2031    run_status_per_locale || fail "translator status failed"
2032  else
2033    log "warning: translator status command not available; skipping"
2034  fi
2035}
2036
2037[[ -f "$LOCALES_FILE" ]] || fail "missing locales file: $LOCALES_FILE"
2038[[ -f "$SOURCE_FILE" ]] || fail "missing source locale file: $SOURCE_FILE"
2039
2040ensure_codex
2041setup_codex_wrapper
2042ensure_codex_login
2043probe_translator
2044run_translate
2045run_optional_checks
2046log "translations updated. Run cargo build to embed translations into WASM"
2047"#
2048    .to_string()
2049}
2050
2051#[allow(dead_code)]
2052fn bytes_literal(bytes: &[u8]) -> String {
2053    if bytes.is_empty() {
2054        return "&[]".to_string();
2055    }
2056    let rendered = bytes
2057        .iter()
2058        .map(|b| format!("0x{b:02x}"))
2059        .collect::<Vec<_>>()
2060        .join(", ");
2061    format!("&[{rendered}]")
2062}
2063
2064#[cfg(test)]
2065mod tests {
2066    use super::*;
2067
2068    #[test]
2069    fn encodes_answers_cbor() {
2070        let json = serde_json::json!({"b": 1, "a": 2});
2071        let cbor = canonical::to_canonical_cbor_allow_floats(&json).unwrap();
2072        assert!(!cbor.is_empty());
2073    }
2074}