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_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}