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#[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::{{component_i18n, component_qa, 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")]
1001impl component_qa::Guest for Component {{
1002 fn qa_spec(mode: component_qa::QaMode) -> Vec<u8> {{
1003 let mode = match mode {{
1004 component_qa::QaMode::Default | component_qa::QaMode::Setup => {{
1005 qa::NormalizedMode::Setup
1006 }}
1007 component_qa::QaMode::Update => qa::NormalizedMode::Update,
1008 component_qa::QaMode::Remove => qa::NormalizedMode::Remove,
1009 }};
1010 qa::qa_spec_cbor(mode)
1011 }}
1012
1013 fn apply_answers(
1014 mode: component_qa::QaMode,
1015 current_config: Vec<u8>,
1016 answers: Vec<u8>,
1017 ) -> Vec<u8> {{
1018 let mode = match mode {{
1019 component_qa::QaMode::Default | component_qa::QaMode::Setup => {{
1020 qa::NormalizedMode::Setup
1021 }}
1022 component_qa::QaMode::Update => qa::NormalizedMode::Update,
1023 component_qa::QaMode::Remove => qa::NormalizedMode::Remove,
1024 }};
1025 qa::apply_answers_cbor(mode, ¤t_config, &answers)
1026 }}
1027}}
1028
1029#[cfg(target_arch = "wasm32")]
1030impl component_i18n::Guest for Component {{
1031 fn i18n_keys() -> Vec<String> {{
1032 qa::i18n_keys()
1033 }}
1034}}
1035
1036#[cfg(target_arch = "wasm32")]
1037greentic_interfaces_guest::export_component_v060!(
1038 Component,
1039 component_qa: Component,
1040 component_i18n: Component,
1041);
1042
1043// Default user-operation implementation.
1044// Replace this with domain logic for your component.
1045pub fn handle_message(operation: &str, input: &str) -> String {{
1046 format!("{{COMPONENT_NAME}}::{{operation}} => {{}}", input.trim())
1047}}
1048
1049#[cfg(target_arch = "wasm32")]
1050fn encode_cbor<T: serde::Serialize>(value: &T) -> Vec<u8> {{
1051 canonical::to_canonical_cbor_allow_floats(value).expect("encode cbor")
1052}}
1053
1054#[cfg(target_arch = "wasm32")]
1055// Accept canonical CBOR first, then fall back to JSON for local debugging.
1056fn parse_payload(input: &[u8]) -> serde_json::Value {{
1057 if let Ok(value) = canonical::from_cbor(input) {{
1058 return value;
1059 }}
1060 serde_json::from_slice(input).unwrap_or_else(|_| serde_json::json!({{}}))
1061}}
1062
1063#[cfg(target_arch = "wasm32")]
1064// Keep ingress compatibility: default/setup/install -> setup, update/upgrade -> update.
1065fn normalized_mode(payload: &serde_json::Value) -> qa::NormalizedMode {{
1066 let mode = payload
1067 .get("mode")
1068 .and_then(|v| v.as_str())
1069 .or_else(|| payload.get("operation").and_then(|v| v.as_str()))
1070 .unwrap_or("setup");
1071 qa::normalize_mode(mode).unwrap_or(qa::NormalizedMode::Setup)
1072}}
1073
1074#[cfg(target_arch = "wasm32")]
1075// Minimal schema for generic operation input.
1076// Extend these schemas when you harden operation contracts.
1077fn input_schema() -> SchemaIr {{
1078 SchemaIr::Object {{
1079 properties: BTreeMap::from([(
1080 "input".to_string(),
1081 SchemaIr::String {{
1082 min_len: Some(0),
1083 max_len: None,
1084 regex: None,
1085 format: None,
1086 }},
1087 )]),
1088 required: vec!["input".to_string()],
1089 additional: AdditionalProperties::Allow,
1090 }}
1091}}
1092
1093#[cfg(target_arch = "wasm32")]
1094fn output_schema() -> SchemaIr {{
1095 SchemaIr::Object {{
1096 properties: BTreeMap::from([(
1097 "message".to_string(),
1098 SchemaIr::String {{
1099 min_len: Some(0),
1100 max_len: None,
1101 regex: None,
1102 format: None,
1103 }},
1104 )]),
1105 required: vec!["message".to_string()],
1106 additional: AdditionalProperties::Allow,
1107 }}
1108}}
1109
1110#[cfg(target_arch = "wasm32")]
1111#[allow(dead_code)]
1112fn config_schema() -> SchemaIr {{
1113 {config_schema_rust}
1114}}
1115
1116#[cfg(target_arch = "wasm32")]
1117#[allow(dead_code)]
1118fn component_info() -> ComponentInfo {{
1119 ComponentInfo {{
1120 id: format!("{{COMPONENT_ORG}}.{{COMPONENT_NAME}}"),
1121 version: COMPONENT_VERSION.to_string(),
1122 role: "tool".to_string(),
1123 display_name: Some(I18nText::new("component.display_name", Some(COMPONENT_NAME.to_string()))),
1124 }}
1125}}
1126
1127#[cfg(target_arch = "wasm32")]
1128fn input_schema_cbor() -> Vec<u8> {{
1129 encode_cbor(&input_schema())
1130}}
1131
1132#[cfg(target_arch = "wasm32")]
1133fn output_schema_cbor() -> Vec<u8> {{
1134 encode_cbor(&output_schema())
1135}}
1136
1137#[cfg(target_arch = "wasm32")]
1138// Central operation dispatcher.
1139// This is the primary extension point for new operations.
1140fn run_component_cbor(operation: &str, input: Vec<u8>) -> Vec<u8> {{
1141 let value = parse_payload(&input);
1142 let output = match operation {{
1143 "qa-spec" => {{
1144 let mode = normalized_mode(&value);
1145 qa::qa_spec_json(mode)
1146 }}
1147 "apply-answers" => {{
1148 let mode = normalized_mode(&value);
1149 qa::apply_answers(mode, &value)
1150 }}
1151 "i18n-keys" => serde_json::Value::Array(
1152 qa::i18n_keys()
1153 .into_iter()
1154 .map(serde_json::Value::String)
1155 .collect(),
1156 ),
1157 _ => {{
1158 let op_name = value
1159 .get("operation")
1160 .and_then(|v| v.as_str())
1161 .unwrap_or(operation);
1162 let input_text = value
1163 .get("input")
1164 .and_then(|v| v.as_str())
1165 .map(ToOwned::to_owned)
1166 .unwrap_or_else(|| value.to_string());
1167 serde_json::json!({{
1168 "message": handle_message(op_name, &input_text)
1169 }})
1170 }}
1171 }};
1172 encode_cbor(&output)
1173}}
1174"#,
1175 name = context.name,
1176 user_describe_ops = user_describe_ops
1177 )
1178}
1179
1180fn render_lib_user_describe_ops(context: &WizardContext) -> String {
1181 context
1182 .user_operations
1183 .iter()
1184 .map(|name| {
1185 format!(
1186 r#" node::Op {{
1187 name: "{name}".to_string(),
1188 summary: Some("Handle a single message input".to_string()),
1189 input: node::IoSchema {{
1190 schema: node::SchemaSource::InlineCbor(input_schema_cbor.clone()),
1191 content_type: "application/cbor".to_string(),
1192 schema_version: None,
1193 }},
1194 output: node::IoSchema {{
1195 schema: node::SchemaSource::InlineCbor(output_schema_cbor.clone()),
1196 content_type: "application/cbor".to_string(),
1197 schema_version: None,
1198 }},
1199 examples: Vec::new(),
1200 }}"#,
1201 name = name
1202 )
1203 })
1204 .collect::<Vec<_>>()
1205 .join(",\n")
1206}
1207
1208fn render_qa_rs() -> String {
1209 r#"use greentic_types::cbor::canonical;
1210use greentic_types::i18n_text::I18nText;
1211use greentic_types::schemas::component::v0_6_0::{QaMode, Question};
1212use serde_json::{json, Value as JsonValue};
1213
1214// Internal normalized lifecycle semantics used by scaffolded QA operations.
1215// Input compatibility accepts legacy/provision aliases via `normalize_mode`.
1216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1217pub enum NormalizedMode {
1218 Setup,
1219 Update,
1220 Remove,
1221}
1222
1223impl NormalizedMode {
1224 pub fn as_str(self) -> &'static str {
1225 match self {
1226 Self::Setup => "setup",
1227 Self::Update => "update",
1228 Self::Remove => "remove",
1229 }
1230 }
1231}
1232
1233// Compatibility mapping for mode strings from operator/flow payloads.
1234pub fn normalize_mode(raw: &str) -> Option<NormalizedMode> {
1235 match raw {
1236 "default" | "setup" | "install" => Some(NormalizedMode::Setup),
1237 "update" | "upgrade" => Some(NormalizedMode::Update),
1238 "remove" => Some(NormalizedMode::Remove),
1239 _ => None,
1240 }
1241}
1242
1243// Primary QA authoring entrypoint.
1244// Extend question sets here for your real setup/update/remove requirements.
1245pub fn qa_spec_cbor(mode: NormalizedMode) -> Vec<u8> {
1246 canonical::to_canonical_cbor_allow_floats(&qa_spec_json(mode)).unwrap_or_default()
1247}
1248
1249pub fn qa_spec_json(mode: NormalizedMode) -> JsonValue {
1250 let (title_key, description_key, questions) = match mode {
1251 NormalizedMode::Setup => (
1252 "qa.install.title",
1253 Some("qa.install.description"),
1254 vec![
1255 question("api_key", "qa.field.api_key.label", "qa.field.api_key.help", true),
1256 question("region", "qa.field.region.label", "qa.field.region.help", true),
1257 question(
1258 "webhook_base_url",
1259 "qa.field.webhook_base_url.label",
1260 "qa.field.webhook_base_url.help",
1261 true,
1262 ),
1263 question("enabled", "qa.field.enabled.label", "qa.field.enabled.help", false),
1264 ],
1265 ),
1266 NormalizedMode::Update => (
1267 "qa.update.title",
1268 Some("qa.update.description"),
1269 vec![
1270 question("api_key", "qa.field.api_key.label", "qa.field.api_key.help", false),
1271 question("region", "qa.field.region.label", "qa.field.region.help", false),
1272 question(
1273 "webhook_base_url",
1274 "qa.field.webhook_base_url.label",
1275 "qa.field.webhook_base_url.help",
1276 false,
1277 ),
1278 question("enabled", "qa.field.enabled.label", "qa.field.enabled.help", false),
1279 ],
1280 ),
1281 NormalizedMode::Remove => (
1282 "qa.remove.title",
1283 Some("qa.remove.description"),
1284 vec![question(
1285 "confirm_remove",
1286 "qa.field.confirm_remove.label",
1287 "qa.field.confirm_remove.help",
1288 true,
1289 )],
1290 ),
1291 };
1292
1293 json!({
1294 "mode": match mode {
1295 NormalizedMode::Setup => QaMode::Setup,
1296 NormalizedMode::Update => QaMode::Update,
1297 NormalizedMode::Remove => QaMode::Remove,
1298 },
1299 "title": I18nText::new(title_key, None),
1300 "description": description_key.map(|key| I18nText::new(key, None)),
1301 "questions": questions,
1302 "defaults": {}
1303 })
1304}
1305
1306pub fn apply_answers_cbor(
1307 mode: NormalizedMode,
1308 current_config: &[u8],
1309 answers: &[u8],
1310) -> Vec<u8> {
1311 let payload = json!({
1312 "current_config": decode_json_or_empty(current_config),
1313 "answers": decode_json_or_empty(answers),
1314 });
1315 canonical::to_canonical_cbor_allow_floats(&apply_answers(mode, &payload)).unwrap_or_default()
1316}
1317
1318fn question(id: &str, label_key: &str, help_key: &str, required: bool) -> Question {
1319 serde_json::from_value(json!({
1320 "id": id,
1321 "label": I18nText::new(label_key, None),
1322 "help": I18nText::new(help_key, None),
1323 "error": null,
1324 "kind": { "type": "text" },
1325 "required": required,
1326 "default": null
1327 }))
1328 .expect("question should deserialize")
1329}
1330
1331// Used by `i18n-keys` operation and contract checks in operator.
1332pub fn i18n_keys() -> Vec<String> {
1333 crate::i18n::all_keys()
1334}
1335
1336// Apply answers and return operator-friendly base shape:
1337// { ok, config?, warnings, errors, ...optional metadata }
1338// Extend this method for domain validation rules and config patching.
1339pub fn apply_answers(mode: NormalizedMode, payload: &JsonValue) -> JsonValue {
1340 let answers = payload.get("answers").cloned().unwrap_or_else(|| json!({}));
1341 let current_config = payload
1342 .get("current_config")
1343 .cloned()
1344 .unwrap_or_else(|| json!({}));
1345
1346 let mut errors = Vec::new();
1347 match mode {
1348 NormalizedMode::Setup => {
1349 for key in ["api_key", "region", "webhook_base_url"] {
1350 if answers.get(key).and_then(|v| v.as_str()).is_none() {
1351 errors.push(json!({
1352 "key": "qa.error.required",
1353 "msg_key": "qa.error.required",
1354 "fields": [key]
1355 }));
1356 }
1357 }
1358 }
1359 NormalizedMode::Remove => {
1360 if answers
1361 .get("confirm_remove")
1362 .and_then(|v| v.as_str())
1363 .map(|v| v != "true")
1364 .unwrap_or(true)
1365 {
1366 errors.push(json!({
1367 "key": "qa.error.remove_confirmation",
1368 "msg_key": "qa.error.remove_confirmation",
1369 "fields": ["confirm_remove"]
1370 }));
1371 }
1372 }
1373 NormalizedMode::Update => {}
1374 }
1375
1376 if !errors.is_empty() {
1377 return json!({
1378 "ok": false,
1379 "warnings": [],
1380 "errors": errors,
1381 "meta": {
1382 "mode": mode.as_str(),
1383 "version": "v1"
1384 }
1385 });
1386 }
1387
1388 let mut config = match current_config {
1389 JsonValue::Object(map) => map,
1390 _ => serde_json::Map::new(),
1391 };
1392 if let JsonValue::Object(map) = answers {
1393 for (key, value) in map {
1394 config.insert(key, value);
1395 }
1396 }
1397 if mode == NormalizedMode::Remove {
1398 config.insert("enabled".to_string(), JsonValue::Bool(false));
1399 }
1400
1401 json!({
1402 "ok": true,
1403 "config": config,
1404 "warnings": [],
1405 "errors": [],
1406 "meta": {
1407 "mode": mode.as_str(),
1408 "version": "v1"
1409 },
1410 "audit": {
1411 "reasons": ["qa.apply_answers"],
1412 "timings_ms": {}
1413 }
1414 })
1415}
1416
1417fn decode_json_or_empty(bytes: &[u8]) -> JsonValue {
1418 if let Ok(value) = canonical::from_cbor(bytes) {
1419 return value;
1420 }
1421 serde_json::from_slice(bytes).unwrap_or_else(|_| json!({}))
1422}
1423"#
1424 .to_string()
1425}
1426
1427#[allow(dead_code)]
1428fn render_descriptor_rs(context: &WizardContext) -> String {
1429 let _ = context;
1430 String::new()
1431}
1432
1433#[allow(dead_code)]
1434fn render_capability_list(capabilities: &[String]) -> String {
1435 let _ = capabilities;
1436 "&[]".to_string()
1437}
1438
1439#[allow(dead_code)]
1440fn render_schema_rs() -> String {
1441 r#"use std::collections::BTreeMap;
1442
1443use greentic_types::cbor::canonical;
1444use greentic_types::schemas::common::schema_ir::{AdditionalProperties, SchemaIr};
1445
1446pub fn input_schema() -> SchemaIr {
1447 object_schema(vec![(
1448 "message",
1449 SchemaIr::String {
1450 min_len: Some(1),
1451 max_len: Some(1024),
1452 regex: None,
1453 format: None,
1454 },
1455 )])
1456}
1457
1458pub fn output_schema() -> SchemaIr {
1459 object_schema(vec![(
1460 "result",
1461 SchemaIr::String {
1462 min_len: Some(1),
1463 max_len: Some(1024),
1464 regex: None,
1465 format: None,
1466 },
1467 )])
1468}
1469
1470pub fn config_schema() -> SchemaIr {
1471 object_schema(vec![("enabled", SchemaIr::Bool)])
1472}
1473
1474pub fn input_schema_cbor() -> Vec<u8> {
1475 canonical::to_canonical_cbor_allow_floats(&input_schema()).unwrap_or_default()
1476}
1477
1478pub fn output_schema_cbor() -> Vec<u8> {
1479 canonical::to_canonical_cbor_allow_floats(&output_schema()).unwrap_or_default()
1480}
1481
1482pub fn config_schema_cbor() -> Vec<u8> {
1483 canonical::to_canonical_cbor_allow_floats(&config_schema()).unwrap_or_default()
1484}
1485
1486fn object_schema(props: Vec<(&str, SchemaIr)>) -> SchemaIr {
1487 let mut properties = BTreeMap::new();
1488 let mut required = Vec::new();
1489 for (name, schema) in props {
1490 properties.insert(name.to_string(), schema);
1491 required.push(name.to_string());
1492 }
1493 SchemaIr::Object {
1494 properties,
1495 required,
1496 additional: AdditionalProperties::Forbid,
1497 }
1498}
1499"#
1500 .to_string()
1501}
1502
1503#[allow(dead_code)]
1504fn render_runtime_rs() -> String {
1505 r#"use std::collections::BTreeMap;
1506
1507use greentic_types::cbor::canonical;
1508use serde_json::Value as JsonValue;
1509
1510pub fn run(input: Vec<u8>, state: Vec<u8>) -> (Vec<u8>, Vec<u8>) {
1511 let input_map = decode_map(&input);
1512 let message = input_map
1513 .get("message")
1514 .and_then(|value| value.as_str())
1515 .unwrap_or("ok");
1516 let mut output = BTreeMap::new();
1517 output.insert(
1518 "result".to_string(),
1519 JsonValue::String(format!("processed: {message}")),
1520 );
1521 let output_cbor = canonical::to_canonical_cbor_allow_floats(&output).unwrap_or_default();
1522 let state_cbor = canonicalize_or_empty(&state);
1523 (output_cbor, state_cbor)
1524}
1525
1526fn canonicalize_or_empty(bytes: &[u8]) -> Vec<u8> {
1527 let empty = || {
1528 canonical::to_canonical_cbor_allow_floats(&BTreeMap::<String, JsonValue>::new())
1529 .unwrap_or_default()
1530 };
1531 if bytes.is_empty() {
1532 return empty();
1533 }
1534 let value: JsonValue = match canonical::from_cbor(bytes) {
1535 Ok(value) => value,
1536 Err(_) => return empty(),
1537 };
1538 canonical::to_canonical_cbor_allow_floats(&value).unwrap_or_default()
1539}
1540
1541fn decode_map(bytes: &[u8]) -> BTreeMap<String, JsonValue> {
1542 if bytes.is_empty() {
1543 return BTreeMap::new();
1544 }
1545 let value: JsonValue = match canonical::from_cbor(bytes) {
1546 Ok(value) => value,
1547 Err(_) => return BTreeMap::new(),
1548 };
1549 let JsonValue::Object(map) = value else {
1550 return BTreeMap::new();
1551 };
1552 map.into_iter().collect()
1553}
1554"#
1555 .to_string()
1556}
1557
1558fn render_i18n_rs() -> String {
1559 r#"use std::collections::BTreeMap;
1560use std::sync::OnceLock;
1561
1562use crate::i18n_bundle::{unpack_locales_from_cbor, LocaleBundle};
1563
1564// Generated by build.rs: static embedded CBOR translation bundle.
1565include!(concat!(env!("OUT_DIR"), "/i18n_bundle.rs"));
1566
1567// Decode once for process lifetime.
1568static I18N_BUNDLE: OnceLock<LocaleBundle> = OnceLock::new();
1569
1570fn bundle() -> &'static LocaleBundle {
1571 I18N_BUNDLE.get_or_init(|| unpack_locales_from_cbor(I18N_BUNDLE_CBOR).unwrap_or_default())
1572}
1573
1574// Fallback precedence is deterministic:
1575// exact locale -> base language -> en
1576fn locale_chain(locale: &str) -> Vec<String> {
1577 let normalized = locale.replace('_', "-");
1578 let mut chain = vec![normalized.clone()];
1579 if let Some((base, _)) = normalized.split_once('-') {
1580 chain.push(base.to_string());
1581 }
1582 chain.push("en".to_string());
1583 chain
1584}
1585
1586// Translation lookup function used throughout generated QA/setup code.
1587// Extend by adding pluralization/context handling if your component needs it.
1588pub fn t(locale: &str, key: &str) -> String {
1589 for candidate in locale_chain(locale) {
1590 if let Some(map) = bundle().get(&candidate)
1591 && let Some(value) = map.get(key)
1592 {
1593 return value.clone();
1594 }
1595 }
1596 key.to_string()
1597}
1598
1599// Returns canonical source key list (from `en`).
1600pub fn all_keys() -> Vec<String> {
1601 let Some(en) = bundle().get("en") else {
1602 return Vec::new();
1603 };
1604 en.keys().cloned().collect()
1605}
1606
1607// Returns English dictionary for diagnostics/tests/tools.
1608pub fn en_messages() -> BTreeMap<String, String> {
1609 bundle().get("en").cloned().unwrap_or_default()
1610}
1611"#
1612 .to_string()
1613}
1614
1615fn render_i18n_bundle() -> String {
1616 r#"{
1617 "qa.install.title": "Install configuration",
1618 "qa.install.description": "Provide values for initial provider setup.",
1619 "qa.update.title": "Update configuration",
1620 "qa.update.description": "Adjust existing provider settings.",
1621 "qa.remove.title": "Remove configuration",
1622 "qa.remove.description": "Confirm provider removal settings.",
1623 "qa.field.api_key.label": "API key",
1624 "qa.field.api_key.help": "Secret key used to authenticate provider requests.",
1625 "qa.field.region.label": "Region",
1626 "qa.field.region.help": "Region identifier for the provider account.",
1627 "qa.field.webhook_base_url.label": "Webhook base URL",
1628 "qa.field.webhook_base_url.help": "Public base URL used for webhook callbacks.",
1629 "qa.field.enabled.label": "Enable provider",
1630 "qa.field.enabled.help": "Enable this provider after setup completes.",
1631 "qa.field.confirm_remove.label": "Confirm removal",
1632 "qa.field.confirm_remove.help": "Set to true to allow provider removal.",
1633 "qa.error.required": "One or more required fields are missing.",
1634 "qa.error.remove_confirmation": "Removal requires explicit confirmation."
1635}
1636"#
1637 .to_string()
1638}
1639
1640fn render_i18n_locales_json() -> String {
1641 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"]
1642"#
1643 .to_string()
1644}
1645
1646fn render_i18n_bundle_rs() -> String {
1647 r#"use std::collections::BTreeMap;
1648use std::fs;
1649use std::path::Path;
1650
1651use greentic_types::cbor::canonical;
1652
1653// Locale -> (key -> translated message)
1654pub type LocaleBundle = BTreeMap<String, BTreeMap<String, String>>;
1655
1656// Reads `assets/i18n/*.json` locale maps and returns stable BTreeMap ordering.
1657// Extend here if you need stricter file validation rules.
1658pub fn load_locale_files(dir: &Path) -> Result<LocaleBundle, String> {
1659 let mut locales = LocaleBundle::new();
1660 if !dir.exists() {
1661 return Ok(locales);
1662 }
1663 for entry in fs::read_dir(dir).map_err(|err| err.to_string())? {
1664 let entry = entry.map_err(|err| err.to_string())?;
1665 let path = entry.path();
1666 if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
1667 continue;
1668 }
1669 let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
1670 continue;
1671 };
1672 // locales.json is metadata, not a translation dictionary.
1673 if stem == "locales" {
1674 continue;
1675 }
1676 let raw = fs::read_to_string(&path).map_err(|err| err.to_string())?;
1677 let map: BTreeMap<String, String> = serde_json::from_str(&raw).map_err(|err| err.to_string())?;
1678 locales.insert(stem.to_string(), map);
1679 }
1680 Ok(locales)
1681}
1682
1683pub fn pack_locales_to_cbor(locales: &LocaleBundle) -> Result<Vec<u8>, String> {
1684 canonical::to_canonical_cbor_allow_floats(locales).map_err(|err| err.to_string())
1685}
1686
1687#[allow(dead_code)]
1688// Runtime decode helper used by src/i18n.rs.
1689pub fn unpack_locales_from_cbor(bytes: &[u8]) -> Result<LocaleBundle, String> {
1690 canonical::from_cbor(bytes).map_err(|err| err.to_string())
1691}
1692
1693#[cfg(test)]
1694mod tests {
1695 use super::*;
1696
1697 #[test]
1698 fn pack_roundtrip_contains_en() {
1699 let mut locales = LocaleBundle::new();
1700 let mut en = BTreeMap::new();
1701 en.insert("qa.install.title".to_string(), "Install".to_string());
1702 locales.insert("en".to_string(), en);
1703
1704 let cbor = pack_locales_to_cbor(&locales).expect("pack locales");
1705 let decoded = unpack_locales_from_cbor(&cbor).expect("decode locales");
1706 assert!(decoded.contains_key("en"));
1707 }
1708}
1709"#
1710 .to_string()
1711}
1712
1713fn render_build_rs() -> String {
1714 r#"#[path = "src/i18n_bundle.rs"]
1715mod i18n_bundle;
1716
1717use std::env;
1718use std::fs;
1719use std::path::Path;
1720
1721// Build-time embedding pipeline:
1722// 1) Read assets/i18n/*.json
1723// 2) Pack canonical CBOR bundle
1724// 3) Emit OUT_DIR constants included by src/i18n.rs
1725fn main() {
1726 let i18n_dir = Path::new("assets/i18n");
1727 println!("cargo:rerun-if-changed={}", i18n_dir.display());
1728
1729 let locales = i18n_bundle::load_locale_files(i18n_dir)
1730 .unwrap_or_else(|err| panic!("failed to load locale files: {err}"));
1731 let bundle = i18n_bundle::pack_locales_to_cbor(&locales)
1732 .unwrap_or_else(|err| panic!("failed to pack locale bundle: {err}"));
1733
1734 let out_dir = env::var("OUT_DIR").expect("OUT_DIR must be set by cargo");
1735 let bundle_path = Path::new(&out_dir).join("i18n.bundle.cbor");
1736 fs::write(&bundle_path, bundle).expect("write i18n.bundle.cbor");
1737
1738 let rs_path = Path::new(&out_dir).join("i18n_bundle.rs");
1739 fs::write(
1740 &rs_path,
1741 "pub const I18N_BUNDLE_CBOR: &[u8] = include_bytes!(concat!(env!(\"OUT_DIR\"), \"/i18n.bundle.cbor\"));\n",
1742 )
1743 .expect("write i18n_bundle.rs");
1744}
1745"#
1746 .to_string()
1747}
1748
1749fn render_i18n_sh() -> String {
1750 r#"#!/usr/bin/env bash
1751set -euo pipefail
1752
1753ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
1754LOCALES_FILE="$ROOT_DIR/assets/i18n/locales.json"
1755SOURCE_FILE="$ROOT_DIR/assets/i18n/en.json"
1756
1757log() {
1758 printf '[i18n] %s\n' "$*"
1759}
1760
1761fail() {
1762 printf '[i18n] error: %s\n' "$*" >&2
1763 exit 1
1764}
1765
1766ensure_codex() {
1767 if command -v codex >/dev/null 2>&1; then
1768 return
1769 fi
1770 if command -v npm >/dev/null 2>&1; then
1771 log "installing Codex CLI via npm"
1772 npm i -g @openai/codex || fail "failed to install Codex CLI via npm"
1773 elif command -v brew >/dev/null 2>&1; then
1774 log "installing Codex CLI via brew"
1775 brew install codex || fail "failed to install Codex CLI via brew"
1776 else
1777 fail "Codex CLI not found and no supported installer available (npm or brew)"
1778 fi
1779}
1780
1781ensure_codex_login() {
1782 if codex login status >/dev/null 2>&1; then
1783 return
1784 fi
1785 log "Codex login status unavailable or not logged in; starting login flow"
1786 codex login || fail "Codex login failed"
1787}
1788
1789probe_translator() {
1790 command -v greentic-i18n-translator >/dev/null 2>&1 || fail "greentic-i18n-translator not found. Install it and rerun this script."
1791 local help_output
1792 help_output="$(greentic-i18n-translator --help 2>&1 || true)"
1793 [[ -n "$help_output" ]] || fail "unable to inspect greentic-i18n-translator --help"
1794 if ! greentic-i18n-translator translate --help >/dev/null 2>&1; then
1795 fail "translator subcommand 'translate' is required but unavailable"
1796 fi
1797}
1798
1799run_translate() {
1800 while IFS= read -r locale; do
1801 [[ -n "$locale" ]] || continue
1802 log "translating locale: $locale"
1803 greentic-i18n-translator translate \
1804 --langs "$locale" \
1805 --en "$SOURCE_FILE" || fail "translate failed for locale $locale"
1806 done < <(python3 - "$LOCALES_FILE" <<'PY'
1807import json
1808import sys
1809with open(sys.argv[1], 'r', encoding='utf-8') as f:
1810 data = json.load(f)
1811for locale in data:
1812 if locale != "en":
1813 print(locale)
1814PY
1815)
1816}
1817
1818run_validate_per_locale() {
1819 local failed=0
1820 while IFS= read -r locale; do
1821 [[ -n "$locale" ]] || continue
1822 if ! greentic-i18n-translator validate --langs "$locale" --en "$SOURCE_FILE"; then
1823 log "validate failed for locale: $locale"
1824 failed=1
1825 fi
1826 done < <(python3 - "$LOCALES_FILE" <<'PY'
1827import json
1828import sys
1829with open(sys.argv[1], 'r', encoding='utf-8') as f:
1830 data = json.load(f)
1831for locale in data:
1832 if locale != "en":
1833 print(locale)
1834PY
1835)
1836 return "$failed"
1837}
1838
1839run_status_per_locale() {
1840 local failed=0
1841 while IFS= read -r locale; do
1842 [[ -n "$locale" ]] || continue
1843 if ! greentic-i18n-translator status --langs "$locale" --en "$SOURCE_FILE"; then
1844 log "status failed for locale: $locale"
1845 failed=1
1846 fi
1847 done < <(python3 - "$LOCALES_FILE" <<'PY'
1848import json
1849import sys
1850with open(sys.argv[1], 'r', encoding='utf-8') as f:
1851 data = json.load(f)
1852for locale in data:
1853 if locale != "en":
1854 print(locale)
1855PY
1856)
1857 return "$failed"
1858}
1859
1860run_optional_checks() {
1861 if greentic-i18n-translator validate --help >/dev/null 2>&1; then
1862 log "running translator validate"
1863 if ! run_validate_per_locale; then
1864 fail "translator validate failed"
1865 fi
1866 else
1867 log "warning: translator validate command not available; skipping"
1868 fi
1869 if greentic-i18n-translator status --help >/dev/null 2>&1; then
1870 log "running translator status"
1871 run_status_per_locale || fail "translator status failed"
1872 else
1873 log "warning: translator status command not available; skipping"
1874 fi
1875}
1876
1877[[ -f "$LOCALES_FILE" ]] || fail "missing locales file: $LOCALES_FILE"
1878[[ -f "$SOURCE_FILE" ]] || fail "missing source locale file: $SOURCE_FILE"
1879
1880ensure_codex
1881ensure_codex_login
1882probe_translator
1883run_translate
1884run_optional_checks
1885log "translations updated. Run cargo build to embed translations into WASM"
1886"#
1887 .to_string()
1888}
1889
1890#[allow(dead_code)]
1891fn bytes_literal(bytes: &[u8]) -> String {
1892 if bytes.is_empty() {
1893 return "&[]".to_string();
1894 }
1895 let rendered = bytes
1896 .iter()
1897 .map(|b| format!("0x{b:02x}"))
1898 .collect::<Vec<_>>()
1899 .join(", ");
1900 format!("&[{rendered}]")
1901}
1902
1903#[cfg(test)]
1904mod tests {
1905 use super::*;
1906
1907 #[test]
1908 fn encodes_answers_cbor() {
1909 let json = serde_json::json!({"b": 1, "a": 2});
1910 let cbor = canonical::to_canonical_cbor_allow_floats(&json).unwrap();
1911 assert!(!cbor.is_empty());
1912 }
1913}