1#[cfg(target_arch = "wasm32")]
2use std::collections::BTreeMap;
3
4#[cfg(target_arch = "wasm32")]
5use greentic_interfaces_guest::component_v0_6::node;
6#[cfg(target_arch = "wasm32")]
7use greentic_interfaces_guest::component_v0_6::{component_i18n, component_qa};
8#[cfg(target_arch = "wasm32")]
9use greentic_types::cbor::canonical;
10#[cfg(target_arch = "wasm32")]
11use greentic_types::schemas::common::schema_ir::{AdditionalProperties, SchemaIr};
12#[cfg(target_arch = "wasm32")]
13use serde_cbor::Value as CborValue;
14#[cfg(target_arch = "wasm32")]
15use serde_json::json;
16
17pub mod i18n;
18pub mod i18n_bundle;
19pub mod qa;
20pub use qa::{
21 apply_store, describe, get_answer_schema, get_example_answers, next, next_with_ctx,
22 render_card, render_json_ui, render_text, submit_all, submit_patch, validate_answers,
23};
24
25const COMPONENT_NAME: &str = "component-qa";
26const COMPONENT_ORG: &str = "ai.greentic";
27const COMPONENT_VERSION: &str = env!("CARGO_PKG_VERSION");
28
29#[cfg(target_arch = "wasm32")]
30#[used]
31#[unsafe(link_section = ".greentic.wasi")]
32static WASI_TARGET_MARKER: [u8; 13] = *b"wasm32-wasip2";
33
34#[cfg(target_arch = "wasm32")]
35struct Component;
36
37#[cfg(target_arch = "wasm32")]
38impl node::Guest for Component {
39 fn describe() -> node::ComponentDescriptor {
40 let input_schema_cbor = message_input_schema_cbor();
41 let output_schema_cbor = message_output_schema_cbor();
42 let setup_apply_input_schema_cbor = setup_apply_input_schema_cbor();
43 let setup_apply_output_schema_cbor = setup_apply_output_schema_cbor();
44 node::ComponentDescriptor {
45 name: COMPONENT_NAME.to_string(),
46 version: COMPONENT_VERSION.to_string(),
47 summary: Some("Greentic QA component".to_string()),
48 capabilities: Vec::new(),
49 ops: vec![
50 node::Op {
51 name: "run".to_string(),
52 summary: Some("Compatibility alias for handle_message".to_string()),
53 input: node::IoSchema {
54 schema: node::SchemaSource::InlineCbor(input_schema_cbor.clone()),
55 content_type: "application/cbor".to_string(),
56 schema_version: None,
57 },
58 output: node::IoSchema {
59 schema: node::SchemaSource::InlineCbor(output_schema_cbor.clone()),
60 content_type: "application/cbor".to_string(),
61 schema_version: None,
62 },
63 examples: Vec::new(),
64 },
65 node::Op {
66 name: "handle_message".to_string(),
67 summary: Some("Handle a single message input".to_string()),
68 input: node::IoSchema {
69 schema: node::SchemaSource::InlineCbor(input_schema_cbor.clone()),
70 content_type: "application/cbor".to_string(),
71 schema_version: None,
72 },
73 output: node::IoSchema {
74 schema: node::SchemaSource::InlineCbor(output_schema_cbor.clone()),
75 content_type: "application/cbor".to_string(),
76 schema_version: None,
77 },
78 examples: Vec::new(),
79 },
80 node::Op {
81 name: "qa-spec".to_string(),
82 summary: Some("Return QA spec (CBOR) for a requested mode".to_string()),
83 input: node::IoSchema {
84 schema: node::SchemaSource::InlineCbor(
85 setup_apply_input_schema_cbor.clone(),
86 ),
87 content_type: "application/cbor".to_string(),
88 schema_version: None,
89 },
90 output: node::IoSchema {
91 schema: node::SchemaSource::InlineCbor(bootstrap_qa_spec_schema_cbor()),
92 content_type: "application/cbor".to_string(),
93 schema_version: None,
94 },
95 examples: Vec::new(),
96 },
97 node::Op {
98 name: "apply-answers".to_string(),
99 summary: Some(
100 "Apply QA answers and optionally return config override".to_string(),
101 ),
102 input: node::IoSchema {
103 schema: node::SchemaSource::InlineCbor(
104 setup_apply_input_schema_cbor.clone(),
105 ),
106 content_type: "application/cbor".to_string(),
107 schema_version: None,
108 },
109 output: node::IoSchema {
110 schema: node::SchemaSource::InlineCbor(
111 setup_apply_output_schema_cbor.clone(),
112 ),
113 content_type: "application/cbor".to_string(),
114 schema_version: None,
115 },
116 examples: Vec::new(),
117 },
118 node::Op {
119 name: "setup.apply_answers".to_string(),
120 summary: Some("Apply setup wizard answers and return config CBOR".to_string()),
121 input: node::IoSchema {
122 schema: node::SchemaSource::InlineCbor(setup_apply_input_schema_cbor),
123 content_type: "application/cbor".to_string(),
124 schema_version: None,
125 },
126 output: node::IoSchema {
127 schema: node::SchemaSource::InlineCbor(setup_apply_output_schema_cbor),
128 content_type: "application/cbor".to_string(),
129 schema_version: None,
130 },
131 examples: Vec::new(),
132 },
133 node::Op {
134 name: "i18n-keys".to_string(),
135 summary: Some("Return i18n keys referenced by QA/setup".to_string()),
136 input: node::IoSchema {
137 schema: node::SchemaSource::InlineCbor(input_schema_cbor),
138 content_type: "application/cbor".to_string(),
139 schema_version: None,
140 },
141 output: node::IoSchema {
142 schema: node::SchemaSource::InlineCbor(output_schema_cbor),
143 content_type: "application/cbor".to_string(),
144 schema_version: None,
145 },
146 examples: Vec::new(),
147 },
148 ],
149 schemas: Vec::new(),
150 setup: Some(node::SetupContract {
151 qa_spec: node::SchemaSource::InlineCbor(bootstrap_qa_spec_cbor()),
152 answers_schema: node::SchemaSource::InlineCbor(bootstrap_answers_schema_cbor()),
153 examples: Vec::new(),
154 outputs: vec![node::SetupOutput::ConfigOnly],
155 }),
156 }
157 }
158
159 fn invoke(
160 operation: String,
161 envelope: node::InvocationEnvelope,
162 ) -> Result<node::InvocationResult, node::NodeError> {
163 Ok(node::InvocationResult {
164 ok: true,
165 output_cbor: run_component_cbor(&operation, envelope.payload_cbor),
166 output_metadata_cbor: None,
167 })
168 }
169}
170
171#[cfg(target_arch = "wasm32")]
172impl component_qa::Guest for Component {
173 fn qa_spec(mode: component_qa::QaMode) -> Vec<u8> {
174 let mode = normalized_mode_from_qa_mode(mode);
175 let payload = qa::qa_spec_json(mode, &json!({ "form_id": "component-qa" }));
176 encode_cbor(&payload)
177 }
178
179 fn apply_answers(
180 mode: component_qa::QaMode,
181 current_config: Vec<u8>,
182 answers: Vec<u8>,
183 ) -> Vec<u8> {
184 let payload = json!({
185 "mode": normalized_mode_from_qa_mode(mode).as_str(),
186 "current_config": canonical::from_cbor(¤t_config).unwrap_or_else(|_| json!({})),
187 "answers": canonical::from_cbor(&answers).unwrap_or_else(|_| json!({})),
188 });
189 let result = qa::apply_answers(normalized_mode_from_qa_mode(mode), &payload);
190 let config = result
191 .get("config")
192 .cloned()
193 .unwrap_or_else(|| payload["current_config"].clone());
194 encode_cbor(&config)
195 }
196}
197
198#[cfg(target_arch = "wasm32")]
199impl component_i18n::Guest for Component {
200 fn i18n_keys() -> Vec<String> {
201 qa::i18n_keys()
202 }
203}
204
205#[cfg(target_arch = "wasm32")]
206greentic_interfaces_guest::export_component_v060!(
207 Component,
208 component_qa: Component,
209 component_i18n: Component,
210);
211
212pub fn describe_payload() -> String {
213 serde_json::json!({
214 "component": {
215 "name": COMPONENT_NAME,
216 "org": COMPONENT_ORG,
217 "version": COMPONENT_VERSION,
218 "world": "greentic:component/component@0.6.0",
219 "self_describing": true
220 }
221 })
222 .to_string()
223}
224
225pub fn handle_message(operation: &str, input: &str) -> String {
226 format!("{COMPONENT_NAME}::{operation} => {}", input.trim())
227}
228
229#[cfg(target_arch = "wasm32")]
230fn encode_cbor<T: serde::Serialize>(value: &T) -> Vec<u8> {
231 canonical::to_canonical_cbor_allow_floats(value).expect("encode cbor")
232}
233
234#[cfg(target_arch = "wasm32")]
235fn parse_payload(input: &[u8]) -> serde_json::Value {
236 if let Ok(value) = canonical::from_cbor(input) {
237 return value;
238 }
239 serde_json::from_slice(input).unwrap_or_else(|_| serde_json::json!({}))
240}
241
242#[cfg(target_arch = "wasm32")]
243fn message_input_schema() -> SchemaIr {
244 SchemaIr::Object {
245 properties: BTreeMap::from([(
246 "input".to_string(),
247 SchemaIr::String {
248 min_len: Some(0),
249 max_len: None,
250 regex: None,
251 format: None,
252 },
253 )]),
254 required: vec!["input".to_string()],
255 additional: AdditionalProperties::Allow,
256 }
257}
258
259#[cfg(target_arch = "wasm32")]
260fn message_output_schema() -> SchemaIr {
261 SchemaIr::Object {
262 properties: BTreeMap::from([(
263 "message".to_string(),
264 SchemaIr::String {
265 min_len: Some(0),
266 max_len: None,
267 regex: None,
268 format: None,
269 },
270 )]),
271 required: vec!["message".to_string()],
272 additional: AdditionalProperties::Allow,
273 }
274}
275
276#[cfg(target_arch = "wasm32")]
277fn bootstrap_answers_schema() -> SchemaIr {
278 SchemaIr::Object {
279 properties: BTreeMap::from([(
280 "qa_form_asset_path".to_string(),
281 SchemaIr::String {
282 min_len: Some(1),
283 max_len: None,
284 regex: None,
285 format: None,
286 },
287 )]),
288 required: vec!["qa_form_asset_path".to_string()],
289 additional: AdditionalProperties::Allow,
290 }
291}
292
293#[cfg(target_arch = "wasm32")]
294fn setup_apply_input_schema() -> SchemaIr {
295 SchemaIr::Object {
296 properties: BTreeMap::from([
297 (
298 "mode".to_string(),
299 SchemaIr::String {
300 min_len: Some(0),
301 max_len: None,
302 regex: None,
303 format: None,
304 },
305 ),
306 (
307 "current_config_cbor".to_string(),
308 SchemaIr::String {
309 min_len: Some(0),
310 max_len: None,
311 regex: None,
312 format: None,
313 },
314 ),
315 (
316 "answers_cbor".to_string(),
317 SchemaIr::String {
318 min_len: Some(0),
319 max_len: None,
320 regex: None,
321 format: None,
322 },
323 ),
324 ]),
325 required: Vec::new(),
326 additional: AdditionalProperties::Allow,
327 }
328}
329
330#[cfg(target_arch = "wasm32")]
331fn setup_apply_output_schema() -> SchemaIr {
332 SchemaIr::Object {
333 properties: BTreeMap::from([(
334 "qa_form_asset_path".to_string(),
335 SchemaIr::String {
336 min_len: Some(1),
337 max_len: None,
338 regex: None,
339 format: None,
340 },
341 )]),
342 required: Vec::new(),
343 additional: AdditionalProperties::Allow,
344 }
345}
346
347#[cfg(target_arch = "wasm32")]
348fn message_input_schema_cbor() -> Vec<u8> {
349 encode_cbor(&message_input_schema())
350}
351
352#[cfg(target_arch = "wasm32")]
353fn message_output_schema_cbor() -> Vec<u8> {
354 encode_cbor(&message_output_schema())
355}
356
357#[cfg(target_arch = "wasm32")]
358fn bootstrap_answers_schema_cbor() -> Vec<u8> {
359 encode_cbor(&bootstrap_answers_schema())
360}
361
362#[cfg(target_arch = "wasm32")]
363fn setup_apply_input_schema_cbor() -> Vec<u8> {
364 encode_cbor(&setup_apply_input_schema())
365}
366
367#[cfg(target_arch = "wasm32")]
368fn setup_apply_output_schema_cbor() -> Vec<u8> {
369 encode_cbor(&setup_apply_output_schema())
370}
371
372#[cfg(target_arch = "wasm32")]
373fn bootstrap_qa_spec_value() -> serde_json::Value {
374 qa::qa_spec_json(
375 qa::NormalizedMode::Setup,
376 &json!({ "form_id": "component-qa" }),
377 )
378}
379
380#[cfg(target_arch = "wasm32")]
381fn bootstrap_qa_spec_cbor() -> Vec<u8> {
382 encode_cbor(&bootstrap_qa_spec_value())
383}
384
385#[cfg(target_arch = "wasm32")]
386fn bootstrap_qa_spec_schema_cbor() -> Vec<u8> {
387 encode_cbor(&SchemaIr::Object {
388 properties: BTreeMap::new(),
389 required: Vec::new(),
390 additional: AdditionalProperties::Allow,
391 })
392}
393
394#[cfg(target_arch = "wasm32")]
395fn normalized_mode(payload: &serde_json::Value) -> qa::NormalizedMode {
396 let mode = payload
397 .get("mode")
398 .and_then(|v| v.as_str())
399 .or_else(|| payload.get("operation").and_then(|v| v.as_str()))
400 .unwrap_or("setup");
401 qa::normalize_mode(mode).unwrap_or(qa::NormalizedMode::Setup)
402}
403
404#[cfg(target_arch = "wasm32")]
405fn normalized_mode_from_qa_mode(mode: component_qa::QaMode) -> qa::NormalizedMode {
406 match mode {
407 component_qa::QaMode::Default | component_qa::QaMode::Setup => qa::NormalizedMode::Setup,
408 component_qa::QaMode::Update => qa::NormalizedMode::Update,
409 component_qa::QaMode::Remove => qa::NormalizedMode::Remove,
410 }
411}
412
413#[cfg(target_arch = "wasm32")]
414fn cbor_map_get<'a>(map: &'a BTreeMap<CborValue, CborValue>, key: &str) -> Option<&'a CborValue> {
415 map.get(&CborValue::Text(key.to_string()))
416}
417
418#[cfg(target_arch = "wasm32")]
419fn decode_nested_cbor_json(value: Option<&CborValue>) -> serde_json::Value {
420 match value {
421 Some(CborValue::Bytes(bytes)) => canonical::from_cbor(bytes).unwrap_or_else(|_| json!({})),
422 _ => json!({}),
423 }
424}
425
426#[cfg(target_arch = "wasm32")]
427fn parse_setup_apply_payload(input: &[u8]) -> serde_json::Value {
428 let decoded = serde_cbor::from_slice::<CborValue>(input)
429 .unwrap_or_else(|_| CborValue::Map(BTreeMap::new()));
430 let CborValue::Map(entries) = decoded else {
431 return json!({});
432 };
433
434 let mode = match cbor_map_get(&entries, "mode") {
435 Some(CborValue::Text(mode)) => mode.as_str(),
436 _ => "setup",
437 };
438
439 json!({
440 "mode": mode,
441 "current_config": decode_nested_cbor_json(cbor_map_get(&entries, "current_config_cbor")),
442 "answers": decode_nested_cbor_json(cbor_map_get(&entries, "answers_cbor")),
443 })
444}
445
446#[cfg(target_arch = "wasm32")]
447fn run_setup_apply_cbor(input: &[u8]) -> Vec<u8> {
448 let payload = parse_setup_apply_payload(input);
449 let mode = normalized_mode(&payload);
450 let result = qa::apply_answers(mode, &payload);
451 let config = result.get("config").cloned().unwrap_or_else(|| {
452 payload
453 .get("current_config")
454 .cloned()
455 .unwrap_or_else(|| json!({}))
456 });
457 encode_cbor(&config)
458}
459
460#[cfg(target_arch = "wasm32")]
461fn run_component_cbor(operation: &str, input: Vec<u8>) -> Vec<u8> {
462 if operation == "setup.apply_answers" {
463 return run_setup_apply_cbor(&input);
464 }
465
466 let value = parse_payload(&input);
467 let output = match operation {
468 "qa-spec" => {
469 let mode = normalized_mode(&value);
470 qa::qa_spec_json(mode, &value)
471 }
472 "apply-answers" => {
473 let mode = normalized_mode(&value);
474 qa::apply_answers(mode, &value)
475 }
476 "i18n-keys" => serde_json::Value::Array(
477 qa::i18n_keys()
478 .into_iter()
479 .map(serde_json::Value::String)
480 .collect(),
481 ),
482 _ => {
483 let op_name = value
484 .get("operation")
485 .and_then(|v| v.as_str())
486 .unwrap_or(operation);
487 let input_text = value
488 .get("input")
489 .and_then(|v| v.as_str())
490 .map(ToOwned::to_owned)
491 .unwrap_or_else(|| value.to_string());
492 serde_json::json!({
493 "message": handle_message(op_name, &input_text)
494 })
495 }
496 };
497
498 encode_cbor(&output)
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504
505 #[test]
506 fn describe_payload_is_json() {
507 let payload = describe_payload();
508 let json: serde_json::Value = serde_json::from_str(&payload).expect("valid json");
509 assert_eq!(json["component"]["name"], "component-qa");
510 }
511
512 #[test]
513 fn handle_message_round_trips() {
514 let body = handle_message("handle", "demo");
515 assert!(body.contains("demo"));
516 }
517}