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#[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), ¤t_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}