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([("enabled".to_string(), SchemaIr::Bool)]),
202        required: Vec::new(),
203        additional: AdditionalProperties::Forbid,
204    }
205}
206
207#[cfg(target_arch = "wasm32")]
208fn component_info() -> ComponentInfo {
209    ComponentInfo {
210        id: format!("{COMPONENT_ORG}.{COMPONENT_NAME}"),
211        version: COMPONENT_VERSION.to_string(),
212        role: "tool".to_string(),
213        display_name: Some(I18nText::new(
214            "component.display_name",
215            Some(COMPONENT_NAME.to_string()),
216        )),
217    }
218}
219
220#[cfg(target_arch = "wasm32")]
221fn component_describe() -> ComponentDescribe {
222    let input = input_schema();
223    let output = output_schema();
224    let config = config_schema();
225    let hash = schema_hash(&input, &output, &config).unwrap_or_default();
226
227    ComponentDescribe {
228        info: component_info(),
229        provided_capabilities: Vec::new(),
230        required_capabilities: Vec::new(),
231        metadata: BTreeMap::new(),
232        operations: vec![ComponentOperation {
233            id: "run".to_string(),
234            display_name: Some(I18nText::new("operation.run", Some("Run".to_string()))),
235            input: ComponentRunInput {
236                schema: input.clone(),
237            },
238            output: ComponentRunOutput {
239                schema: output.clone(),
240            },
241            defaults: BTreeMap::new(),
242            redactions: Vec::new(),
243            constraints: BTreeMap::new(),
244            schema_hash: hash,
245        }],
246        config_schema: config,
247    }
248}
249
250#[cfg(target_arch = "wasm32")]
251fn run_component_cbor(input: Vec<u8>, state: Vec<u8>) -> component_runtime::RunResult {
252    let value = parse_payload(&input);
253    let operation = value
254        .get("operation")
255        .and_then(|v| v.as_str())
256        .unwrap_or("handle_message");
257    let output = match operation {
258        "qa-spec" => {
259            let mode = value
260                .get("mode")
261                .and_then(|v| v.as_str())
262                .and_then(qa::normalize_mode)
263                .unwrap_or(qa::NormalizedMode::Setup);
264            qa::qa_spec_json(mode, &value)
265        }
266        "apply-answers" => {
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::apply_answers(mode, &value)
273        }
274        "i18n-keys" => serde_json::Value::Array(
275            qa::i18n_keys()
276                .into_iter()
277                .map(serde_json::Value::String)
278                .collect(),
279        ),
280        _ => {
281            let input_text = value
282                .get("input")
283                .and_then(|v| v.as_str())
284                .map(ToOwned::to_owned)
285                .unwrap_or_else(|| value.to_string());
286            serde_json::json!({
287                "message": handle_message(operation, &input_text)
288            })
289        }
290    };
291
292    component_runtime::RunResult {
293        output: encode_cbor(&output),
294        new_state: state,
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn describe_payload_is_json() {
304        let payload = describe_payload();
305        let json: serde_json::Value = serde_json::from_str(&payload).expect("valid json");
306        assert_eq!(json["component"]["name"], "component-qa");
307    }
308
309    #[test]
310    fn handle_message_round_trips() {
311        let body = handle_message("handle", "demo");
312        assert!(body.contains("demo"));
313    }
314}