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