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: "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}