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