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_types::cbor::canonical;
6#[cfg(target_arch = "wasm32")]
7use greentic_types::schemas::common::schema_ir::{AdditionalProperties, SchemaIr};
8#[cfg(target_arch = "wasm32")]
9use greentic_types::schemas::component::v0_6_0::{
10    ComponentDescribe, ComponentInfo, ComponentOperation, ComponentRunInput, ComponentRunOutput,
11    I18nText, schema_hash,
12};
13
14#[cfg(target_arch = "wasm32")]
15mod bindings;
16#[cfg(target_arch = "wasm32")]
17use bindings::exports::greentic::component::{
18    component_descriptor, component_i18n, component_qa, component_runtime, component_schema,
19};
20
21pub mod i18n;
22pub mod i18n_bundle;
23pub mod qa;
24pub use qa::{
25    apply_store, describe, get_answer_schema, get_example_answers, next, next_with_ctx,
26    render_card, render_json_ui, render_text, submit_all, submit_patch, validate_answers,
27};
28
29const COMPONENT_NAME: &str = "component-qa";
30const COMPONENT_ORG: &str = "ai.greentic";
31const COMPONENT_VERSION: &str = env!("CARGO_PKG_VERSION");
32
33#[cfg(target_arch = "wasm32")]
34#[used]
35#[unsafe(link_section = ".greentic.wasi")]
36static WASI_TARGET_MARKER: [u8; 13] = *b"wasm32-wasip2";
37
38#[cfg(target_arch = "wasm32")]
39struct Component;
40
41#[cfg(target_arch = "wasm32")]
42impl component_descriptor::Guest for Component {
43    fn get_component_info() -> Vec<u8> {
44        encode_cbor(&component_info())
45    }
46
47    fn describe() -> Vec<u8> {
48        encode_cbor(&component_describe())
49    }
50}
51
52#[cfg(target_arch = "wasm32")]
53impl component_schema::Guest for Component {
54    fn input_schema() -> Vec<u8> {
55        encode_cbor(&input_schema())
56    }
57
58    fn output_schema() -> Vec<u8> {
59        encode_cbor(&output_schema())
60    }
61
62    fn config_schema() -> Vec<u8> {
63        encode_cbor(&config_schema())
64    }
65}
66
67#[cfg(target_arch = "wasm32")]
68impl component_runtime::Guest for Component {
69    fn run(input: Vec<u8>, state: Vec<u8>) -> component_runtime::RunResult {
70        run_component_cbor(input, state)
71    }
72}
73
74#[cfg(target_arch = "wasm32")]
75impl component_qa::Guest for Component {
76    fn qa_spec(mode: component_qa::QaMode) -> Vec<u8> {
77        let normalized = qa_mode_to_normalized(mode);
78        let mut spec = qa::qa_spec_json(normalized, &serde_json::json!({}));
79        if matches!(mode, component_qa::QaMode::Default)
80            && let Some(spec_obj) = spec.as_object_mut()
81        {
82            spec_obj.insert(
83                "mode".to_string(),
84                serde_json::Value::String("default".to_string()),
85            );
86        }
87        encode_cbor(&spec)
88    }
89
90    fn apply_answers(
91        mode: component_qa::QaMode,
92        current_config: Vec<u8>,
93        answers: Vec<u8>,
94    ) -> Vec<u8> {
95        let normalized = qa_mode_to_normalized(mode);
96        let payload = serde_json::json!({
97            "mode": normalized.as_str(),
98            "current_config": parse_payload(&current_config),
99            "answers": parse_payload(&answers)
100        });
101        let result = qa::apply_answers(normalized, &payload);
102        let config = result
103            .get("config")
104            .cloned()
105            .or_else(|| payload.get("current_config").cloned())
106            .unwrap_or_else(|| serde_json::json!({}));
107        encode_cbor(&config)
108    }
109}
110
111#[cfg(target_arch = "wasm32")]
112impl component_i18n::Guest for Component {
113    fn i18n_keys() -> Vec<String> {
114        qa::i18n_keys()
115    }
116}
117
118#[cfg(target_arch = "wasm32")]
119bindings::export!(Component with_types_in bindings);
120
121pub fn describe_payload() -> String {
122    serde_json::json!({
123        "component": {
124            "name": COMPONENT_NAME,
125            "org": COMPONENT_ORG,
126            "version": COMPONENT_VERSION,
127            "world": "greentic:component/component@0.6.0",
128            "schemas": {
129                "component": "schemas/component.schema.json",
130                "input": "schemas/io/input.schema.json",
131                "output": "schemas/io/output.schema.json"
132            }
133        }
134    })
135    .to_string()
136}
137
138pub fn handle_message(operation: &str, input: &str) -> String {
139    format!("{COMPONENT_NAME}::{operation} => {}", input.trim())
140}
141
142#[cfg(target_arch = "wasm32")]
143fn encode_cbor<T: serde::Serialize>(value: &T) -> Vec<u8> {
144    canonical::to_canonical_cbor_allow_floats(value).expect("encode cbor")
145}
146
147#[cfg(target_arch = "wasm32")]
148fn parse_payload(input: &[u8]) -> serde_json::Value {
149    if let Ok(value) = canonical::from_cbor(input) {
150        return value;
151    }
152    serde_json::from_slice(input).unwrap_or_else(|_| serde_json::json!({}))
153}
154
155#[cfg(target_arch = "wasm32")]
156fn qa_mode_to_normalized(mode: component_qa::QaMode) -> qa::NormalizedMode {
157    match mode {
158        component_qa::QaMode::Default | component_qa::QaMode::Setup => qa::NormalizedMode::Setup,
159        component_qa::QaMode::Update => qa::NormalizedMode::Update,
160        component_qa::QaMode::Remove => qa::NormalizedMode::Remove,
161    }
162}
163
164#[cfg(target_arch = "wasm32")]
165fn input_schema() -> SchemaIr {
166    SchemaIr::Object {
167        properties: BTreeMap::from([(
168            "operation".to_string(),
169            SchemaIr::String {
170                min_len: Some(0),
171                max_len: None,
172                regex: None,
173                format: None,
174            },
175        )]),
176        required: Vec::new(),
177        additional: AdditionalProperties::Allow,
178    }
179}
180
181#[cfg(target_arch = "wasm32")]
182fn output_schema() -> SchemaIr {
183    SchemaIr::Object {
184        properties: BTreeMap::from([(
185            "message".to_string(),
186            SchemaIr::String {
187                min_len: Some(0),
188                max_len: None,
189                regex: None,
190                format: None,
191            },
192        )]),
193        required: Vec::new(),
194        additional: AdditionalProperties::Allow,
195    }
196}
197
198#[cfg(target_arch = "wasm32")]
199fn config_schema() -> SchemaIr {
200    SchemaIr::Object {
201        properties: BTreeMap::from([(
202            "qa_form_asset_path".to_string(),
203            SchemaIr::String {
204                min_len: Some(1),
205                max_len: None,
206                regex: None,
207                format: None,
208            },
209        )]),
210        required: vec!["qa_form_asset_path".to_string()],
211        additional: AdditionalProperties::Forbid,
212    }
213}
214
215#[cfg(target_arch = "wasm32")]
216fn component_info() -> ComponentInfo {
217    ComponentInfo {
218        id: format!("{COMPONENT_ORG}.{COMPONENT_NAME}"),
219        version: COMPONENT_VERSION.to_string(),
220        role: "tool".to_string(),
221        display_name: Some(I18nText::new(
222            "component.display_name",
223            Some(COMPONENT_NAME.to_string()),
224        )),
225    }
226}
227
228#[cfg(target_arch = "wasm32")]
229fn component_describe() -> ComponentDescribe {
230    let input = input_schema();
231    let output = output_schema();
232    let config = config_schema();
233    let hash = schema_hash(&input, &output, &config).unwrap_or_default();
234
235    ComponentDescribe {
236        info: component_info(),
237        provided_capabilities: Vec::new(),
238        required_capabilities: Vec::new(),
239        metadata: BTreeMap::new(),
240        operations: vec![ComponentOperation {
241            id: "run".to_string(),
242            display_name: Some(I18nText::new("operation.run", Some("Run".to_string()))),
243            input: ComponentRunInput {
244                schema: input.clone(),
245            },
246            output: ComponentRunOutput {
247                schema: output.clone(),
248            },
249            defaults: BTreeMap::new(),
250            redactions: Vec::new(),
251            constraints: BTreeMap::new(),
252            schema_hash: hash,
253        }],
254        config_schema: config,
255    }
256}
257
258#[cfg(target_arch = "wasm32")]
259fn run_component_cbor(input: Vec<u8>, state: Vec<u8>) -> component_runtime::RunResult {
260    let value = parse_payload(&input);
261    let operation = value
262        .get("operation")
263        .and_then(|v| v.as_str())
264        .unwrap_or("handle_message");
265    let output = match operation {
266        "qa-spec" => {
267            let mode = value
268                .get("mode")
269                .and_then(|v| v.as_str())
270                .and_then(qa::normalize_mode)
271                .unwrap_or(qa::NormalizedMode::Setup);
272            qa::qa_spec_json(mode, &value)
273        }
274        "apply-answers" => {
275            let mode = value
276                .get("mode")
277                .and_then(|v| v.as_str())
278                .and_then(qa::normalize_mode)
279                .unwrap_or(qa::NormalizedMode::Setup);
280            qa::apply_answers(mode, &value)
281        }
282        "i18n-keys" => serde_json::Value::Array(
283            qa::i18n_keys()
284                .into_iter()
285                .map(serde_json::Value::String)
286                .collect(),
287        ),
288        _ => {
289            let input_text = value
290                .get("input")
291                .and_then(|v| v.as_str())
292                .map(ToOwned::to_owned)
293                .unwrap_or_else(|| value.to_string());
294            serde_json::json!({
295                "message": handle_message(operation, &input_text)
296            })
297        }
298    };
299
300    component_runtime::RunResult {
301        output: encode_cbor(&output),
302        new_state: state,
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    #[test]
311    fn describe_payload_is_json() {
312        let payload = describe_payload();
313        let json: serde_json::Value = serde_json::from_str(&payload).expect("valid json");
314        assert_eq!(json["component"]["name"], "component-qa");
315    }
316
317    #[test]
318    fn handle_message_round_trips() {
319        let body = handle_message("handle", "demo");
320        assert!(body.contains("demo"));
321    }
322}