Skip to main content

component_qa/
lib.rs

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(&current_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}