Skip to main content

component_qa/
lib.rs

1use serde::{Deserialize, Serialize};
2use serde_json::{Map, Value, json};
3use thiserror::Error;
4
5use qa_spec::{
6    FormSpec, ProgressContext, RenderPayload, StoreContext, StoreError, VisibilityMode,
7    answers_schema, build_render_payload, example_answers, next_question,
8    render_card as qa_render_card, render_json_ui as qa_render_json_ui,
9    render_text as qa_render_text, resolve_visibility, validate,
10};
11
12const DEFAULT_SPEC: &str = include_str!("../tests/fixtures/simple_form.json");
13
14#[derive(Debug, Error)]
15enum ComponentError {
16    #[error("failed to parse config/{0}")]
17    ConfigParse(#[source] serde_json::Error),
18    #[error("form '{0}' is not available")]
19    FormUnavailable(String),
20    #[error("json encode error: {0}")]
21    JsonEncode(#[source] serde_json::Error),
22    #[error("store apply failed: {0}")]
23    Store(#[from] StoreError),
24}
25
26#[derive(Debug, Deserialize, Serialize, Default)]
27struct ComponentConfig {
28    #[serde(default)]
29    form_spec_json: Option<String>,
30}
31
32fn load_form_spec(config_json: &str) -> Result<FormSpec, ComponentError> {
33    let config = if config_json.trim().is_empty() {
34        ComponentConfig::default()
35    } else {
36        serde_json::from_str(config_json).map_err(ComponentError::ConfigParse)?
37    };
38
39    let spec_json = config.form_spec_json.as_deref().unwrap_or(DEFAULT_SPEC);
40
41    serde_json::from_str(spec_json).map_err(ComponentError::ConfigParse)
42}
43
44fn parse_context(ctx_json: &str) -> Value {
45    serde_json::from_str(ctx_json).unwrap_or_else(|_| Value::Object(Map::new()))
46}
47
48fn resolve_context_answers(ctx: &Value) -> Value {
49    ctx.get("answers")
50        .cloned()
51        .unwrap_or_else(|| Value::Object(Map::new()))
52}
53
54fn parse_answers(answers_json: &str) -> Value {
55    serde_json::from_str(answers_json).unwrap_or_else(|_| Value::Object(Map::new()))
56}
57
58fn secrets_host_available(ctx: &Value) -> bool {
59    ctx.get("secrets_host_available")
60        .and_then(Value::as_bool)
61        .or_else(|| {
62            ctx.get("config")
63                .and_then(Value::as_object)
64                .and_then(|config| config.get("secrets_host_available"))
65                .and_then(Value::as_bool)
66        })
67        .unwrap_or(false)
68}
69
70fn respond(result: Result<Value, ComponentError>) -> String {
71    match result {
72        Ok(value) => serde_json::to_string(&value).unwrap_or_else(|error| {
73            json!({"error": format!("json encode: {}", error)}).to_string()
74        }),
75        Err(err) => json!({ "error": err.to_string() }).to_string(),
76    }
77}
78
79pub fn describe(form_id: &str, config_json: &str) -> String {
80    respond(load_form_spec(config_json).and_then(|spec| {
81        if spec.id != form_id {
82            Err(ComponentError::FormUnavailable(form_id.to_string()))
83        } else {
84            serde_json::to_value(spec).map_err(ComponentError::JsonEncode)
85        }
86    }))
87}
88
89fn ensure_form(form_id: &str, config_json: &str) -> Result<FormSpec, ComponentError> {
90    let spec = load_form_spec(config_json)?;
91    if spec.id != form_id {
92        Err(ComponentError::FormUnavailable(form_id.to_string()))
93    } else {
94        Ok(spec)
95    }
96}
97
98pub fn get_answer_schema(form_id: &str, config_json: &str, ctx_json: &str) -> String {
99    let schema = ensure_form(form_id, config_json).map(|spec| {
100        let ctx = parse_context(ctx_json);
101        let answers = resolve_context_answers(&ctx);
102        let visibility = resolve_visibility(&spec, &answers, VisibilityMode::Visible);
103        answers_schema(&spec, &visibility)
104    });
105    respond(schema)
106}
107
108pub fn get_example_answers(form_id: &str, config_json: &str, ctx_json: &str) -> String {
109    let result = ensure_form(form_id, config_json).map(|spec| {
110        let ctx = parse_context(ctx_json);
111        let answers = resolve_context_answers(&ctx);
112        let visibility = resolve_visibility(&spec, &answers, VisibilityMode::Visible);
113        example_answers(&spec, &visibility)
114    });
115    respond(result)
116}
117
118pub fn validate_answers(form_id: &str, config_json: &str, answers_json: &str) -> String {
119    let validation = ensure_form(form_id, config_json).and_then(|spec| {
120        let answers = serde_json::from_str(answers_json).map_err(ComponentError::ConfigParse)?;
121        serde_json::to_value(validate(&spec, &answers)).map_err(ComponentError::JsonEncode)
122    });
123    respond(validation)
124}
125
126pub fn next(form_id: &str, ctx_json: &str, answers_json: &str) -> String {
127    let result = ensure_form(form_id, ctx_json).map(|spec| {
128        let ctx = parse_context(ctx_json);
129        let answers = parse_answers(answers_json);
130        let visibility = resolve_visibility(&spec, &answers, VisibilityMode::Visible);
131        let progress_ctx = ProgressContext::new(answers.clone(), &ctx);
132        let next_q = next_question(&spec, &progress_ctx, &visibility);
133        let answered = progress_ctx.answered_count(&spec, &visibility);
134        let total = visibility.values().filter(|visible| **visible).count();
135        json!({
136            "status": if next_q.is_some() { "need_input" } else { "complete" },
137            "next_question_id": next_q,
138            "progress": {
139                "answered": answered,
140                "total": total
141            }
142        })
143    });
144    respond(result)
145}
146
147pub fn apply_store(form_id: &str, ctx_json: &str, answers_json: &str) -> String {
148    let result = ensure_form(form_id, ctx_json).and_then(|spec| {
149        let ctx = parse_context(ctx_json);
150        let answers = parse_answers(answers_json);
151        let mut store_ctx = StoreContext::from_value(&ctx);
152        store_ctx.answers = answers;
153        let host_available = secrets_host_available(&ctx);
154        store_ctx.apply_ops(&spec.store, spec.secrets_policy.as_ref(), host_available)?;
155        Ok(store_ctx.to_value())
156    });
157    respond(result)
158}
159
160fn render_payload(
161    form_id: &str,
162    config_json: &str,
163    ctx_json: &str,
164    answers_json: &str,
165) -> Result<RenderPayload, ComponentError> {
166    let spec = ensure_form(form_id, config_json)?;
167    let ctx = parse_context(ctx_json);
168    let answers = parse_answers(answers_json);
169    Ok(build_render_payload(&spec, &ctx, &answers))
170}
171
172fn respond_string(result: Result<String, ComponentError>) -> String {
173    match result {
174        Ok(value) => value,
175        Err(err) => json!({ "error": err.to_string() }).to_string(),
176    }
177}
178
179pub fn render_text(form_id: &str, config_json: &str, ctx_json: &str, answers_json: &str) -> String {
180    respond_string(
181        render_payload(form_id, config_json, ctx_json, answers_json)
182            .map(|payload| qa_render_text(&payload)),
183    )
184}
185
186pub fn render_json_ui(
187    form_id: &str,
188    config_json: &str,
189    ctx_json: &str,
190    answers_json: &str,
191) -> String {
192    respond(
193        render_payload(form_id, config_json, ctx_json, answers_json)
194            .map(|payload| qa_render_json_ui(&payload)),
195    )
196}
197
198pub fn render_card(form_id: &str, config_json: &str, ctx_json: &str, answers_json: &str) -> String {
199    respond(
200        render_payload(form_id, config_json, ctx_json, answers_json)
201            .map(|payload| qa_render_card(&payload)),
202    )
203}
204
205fn submission_progress(payload: &RenderPayload) -> Value {
206    json!({
207        "answered": payload.progress.answered,
208        "total": payload.progress.total,
209    })
210}
211
212fn build_error_response(
213    payload: &RenderPayload,
214    answers: Value,
215    validation: &qa_spec::ValidationResult,
216) -> Result<Value, ComponentError> {
217    let validation_value = serde_json::to_value(validation).map_err(ComponentError::JsonEncode)?;
218    Ok(json!({
219        "status": "error",
220        "next_question_id": payload.next_question_id,
221        "progress": submission_progress(payload),
222        "answers": answers,
223        "validation": validation_value,
224    }))
225}
226
227fn build_success_response(
228    payload: &RenderPayload,
229    answers: Value,
230    store_ctx: &StoreContext,
231) -> Value {
232    let status = if payload.next_question_id.is_some() {
233        "need_input"
234    } else {
235        "complete"
236    };
237
238    json!({
239        "status": status,
240        "next_question_id": payload.next_question_id,
241        "progress": submission_progress(payload),
242        "answers": answers,
243        "store": store_ctx.to_value(),
244    })
245}
246
247fn with_answers_mutated(answers_json: &str, question_id: &str, value: Value) -> Value {
248    let mut map = parse_answers(answers_json)
249        .as_object()
250        .cloned()
251        .unwrap_or_default();
252    map.insert(question_id.to_string(), value);
253    Value::Object(map)
254}
255
256pub fn submit_patch(
257    form_id: &str,
258    config_json: &str,
259    ctx_json: &str,
260    answers_json: &str,
261    question_id: &str,
262    value_json: &str,
263) -> String {
264    respond(ensure_form(form_id, config_json).and_then(|spec| {
265        let ctx = parse_context(ctx_json);
266        let value: Value = serde_json::from_str(value_json).map_err(ComponentError::ConfigParse)?;
267        let answers = with_answers_mutated(answers_json, question_id, value);
268        let validation = validate(&spec, &answers);
269        let payload = build_render_payload(&spec, &ctx, &answers);
270
271        if !validation.valid {
272            return build_error_response(&payload, answers, &validation);
273        }
274
275        let mut store_ctx = StoreContext::from_value(&ctx);
276        store_ctx.answers = answers.clone();
277        let host_available = secrets_host_available(&ctx);
278        store_ctx.apply_ops(&spec.store, spec.secrets_policy.as_ref(), host_available)?;
279        let response = build_success_response(&payload, answers, &store_ctx);
280        Ok(response)
281    }))
282}
283
284pub fn submit_all(form_id: &str, config_json: &str, ctx_json: &str, answers_json: &str) -> String {
285    respond(ensure_form(form_id, config_json).and_then(|spec| {
286        let ctx = parse_context(ctx_json);
287        let answers = parse_answers(answers_json);
288        let validation = validate(&spec, &answers);
289        let payload = build_render_payload(&spec, &ctx, &answers);
290
291        if !validation.valid {
292            return build_error_response(&payload, answers, &validation);
293        }
294
295        let mut store_ctx = StoreContext::from_value(&ctx);
296        store_ctx.answers = answers.clone();
297        let host_available = secrets_host_available(&ctx);
298        store_ctx.apply_ops(&spec.store, spec.secrets_policy.as_ref(), host_available)?;
299        let response = build_success_response(&payload, answers, &store_ctx);
300        Ok(response)
301    }))
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use serde_json::json;
308
309    #[test]
310    fn describe_returns_spec_json() {
311        let payload = describe("example-form", "");
312        let spec: Value = serde_json::from_str(&payload).expect("valid json");
313        assert_eq!(spec["id"], "example-form");
314    }
315
316    #[test]
317    fn schema_matches_questions() {
318        let schema = get_answer_schema("example-form", "", "{}");
319        let value: Value = serde_json::from_str(&schema).expect("json");
320        assert!(
321            value
322                .get("properties")
323                .unwrap()
324                .as_object()
325                .unwrap()
326                .contains_key("q1")
327        );
328    }
329
330    #[test]
331    fn example_answers_include_question_values() {
332        let examples = get_example_answers("example-form", "", "{}");
333        let parsed: Value = serde_json::from_str(&examples).expect("json");
334        assert_eq!(parsed["q1"], "example-q1");
335    }
336
337    #[test]
338    fn validate_answers_reports_valid_when_complete() {
339        let answers = json!({ "q1": "tester", "q2": true });
340        let result = validate_answers("example-form", "", &answers.to_string());
341        let parsed: Value = serde_json::from_str(&result).expect("json");
342        assert!(parsed["valid"].as_bool().unwrap_or(false));
343    }
344
345    #[test]
346    fn next_returns_progress_payload() {
347        let spec = json!({
348            "id": "progress-form",
349            "title": "Progress",
350            "version": "1.0",
351            "progress_policy": {
352                "skip_answered": true
353            },
354            "questions": [
355                { "id": "q1", "type": "string", "title": "q1", "required": true },
356                { "id": "q2", "type": "string", "title": "q2", "required": true }
357            ]
358        });
359        let ctx = json!({ "form_spec_json": spec.to_string() });
360        let response = next("progress-form", &ctx.to_string(), r#"{"q1": "test"}"#);
361        let parsed: Value = serde_json::from_str(&response).expect("json");
362        assert_eq!(parsed["status"], "need_input");
363        assert_eq!(parsed["next_question_id"], "q2");
364        assert_eq!(parsed["progress"]["answered"], 1);
365    }
366
367    #[test]
368    fn apply_store_writes_state_value() {
369        let spec = json!({
370            "id": "store-form",
371            "title": "Store",
372            "version": "1.0",
373            "questions": [
374                { "id": "q1", "type": "string", "title": "q1", "required": true }
375            ],
376            "store": [
377                {
378                    "target": "state",
379                    "path": "/flag",
380                    "value": true
381                }
382            ]
383        });
384        let ctx = json!({
385            "form_spec_json": spec.to_string(),
386            "state": {}
387        });
388        let result = apply_store("store-form", &ctx.to_string(), "{}");
389        let parsed: Value = serde_json::from_str(&result).expect("json");
390        assert_eq!(parsed["state"]["flag"], true);
391    }
392
393    #[test]
394    fn apply_store_writes_secret_when_allowed() {
395        let spec = json!({
396            "id": "store-secret",
397            "title": "Store Secret",
398            "version": "1.0",
399            "questions": [
400                { "id": "q1", "type": "string", "title": "q1", "required": true }
401            ],
402            "store": [
403                {
404                    "target": "secrets",
405                    "path": "/aws/key",
406                    "value": "value"
407                }
408            ],
409            "secrets_policy": {
410                "enabled": true,
411                "read_enabled": true,
412                "write_enabled": true,
413                "allow": ["aws/*"]
414            }
415        });
416        let ctx = json!({
417            "form_spec_json": spec.to_string(),
418            "state": {},
419            "secrets_host_available": true
420        });
421        let result = apply_store("store-secret", &ctx.to_string(), "{}");
422        let parsed: Value = serde_json::from_str(&result).expect("json");
423        assert_eq!(parsed["secrets"]["aws"]["key"], "value");
424    }
425
426    #[test]
427    fn render_text_outputs_summary() {
428        let output = render_text("example-form", "", "{}", "{}");
429        assert!(output.contains("Form:"));
430        assert!(output.contains("Visible questions"));
431    }
432
433    #[test]
434    fn render_json_ui_outputs_json_payload() {
435        let payload = render_json_ui("example-form", "", "{}", r#"{"q1":"value"}"#);
436        let parsed: Value = serde_json::from_str(&payload).expect("json");
437        assert_eq!(parsed["form_id"], "example-form");
438        assert_eq!(parsed["progress"]["total"], 2);
439    }
440
441    #[test]
442    fn render_card_outputs_patch_action() {
443        let payload = render_card("example-form", "", "{}", "{}");
444        let parsed: Value = serde_json::from_str(&payload).expect("json");
445        assert_eq!(parsed["version"], "1.3");
446        let actions = parsed["actions"].as_array().expect("actions");
447        assert_eq!(actions[0]["data"]["qa"]["mode"], "patch");
448    }
449
450    #[test]
451    fn submit_patch_advances_and_updates_store() {
452        let response = submit_patch("example-form", "", "{}", "{}", "q1", r#""Acme""#);
453        let parsed: Value = serde_json::from_str(&response).expect("json");
454        assert_eq!(parsed["status"], "need_input");
455        assert_eq!(parsed["next_question_id"], "q2");
456        assert_eq!(parsed["answers"]["q1"], "Acme");
457        assert_eq!(parsed["store"]["answers"]["q1"], "Acme");
458    }
459
460    #[test]
461    fn submit_patch_returns_validation_error() {
462        let response = submit_patch("example-form", "", "{}", "{}", "q1", "true");
463        let parsed: Value = serde_json::from_str(&response).expect("json");
464        assert_eq!(parsed["status"], "error");
465        assert_eq!(parsed["validation"]["errors"][0]["code"], "type_mismatch");
466    }
467
468    #[test]
469    fn submit_all_completes_with_valid_answers() {
470        let response = submit_all("example-form", "", "{}", r#"{"q1":"Acme","q2":true}"#);
471        let parsed: Value = serde_json::from_str(&response).expect("json");
472        assert_eq!(parsed["status"], "complete");
473        assert!(parsed["next_question_id"].is_null());
474        assert_eq!(parsed["answers"]["q2"], true);
475        assert_eq!(parsed["store"]["answers"]["q2"], true);
476    }
477}