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