Skip to main content

component_qa/
lib.rs

1use std::collections::BTreeMap;
2use std::collections::BTreeSet;
3
4use serde::{Deserialize, Serialize};
5use serde_json::{Map, Value, json};
6use thiserror::Error;
7
8use qa_spec::{
9    FormSpec, ProgressContext, RenderPayload, StoreContext, StoreError, StoreOp, VisibilityMode,
10    answers_schema, build_render_payload, example_answers, next_question,
11    render_card as qa_render_card, render_json_ui as qa_render_json_ui,
12    render_text as qa_render_text, resolve_visibility, validate,
13};
14
15const DEFAULT_SPEC: &str = include_str!("../tests/fixtures/simple_form.json");
16
17#[derive(Debug, Error)]
18enum ComponentError {
19    #[error("failed to parse config/{0}")]
20    ConfigParse(#[source] serde_json::Error),
21    #[error("form '{0}' is not available")]
22    FormUnavailable(String),
23    #[error("json encode error: {0}")]
24    JsonEncode(#[source] serde_json::Error),
25    #[error("include expansion failed: {0}")]
26    Include(String),
27    #[error("store apply failed: {0}")]
28    Store(#[from] StoreError),
29}
30
31#[derive(Debug, Deserialize, Serialize, Default)]
32struct ComponentConfig {
33    #[serde(default)]
34    form_spec_json: Option<String>,
35    #[serde(default)]
36    include_registry: BTreeMap<String, String>,
37}
38
39fn load_form_spec(config_json: &str) -> Result<FormSpec, ComponentError> {
40    let spec_value = load_form_spec_value(config_json)?;
41    serde_json::from_value(spec_value).map_err(ComponentError::ConfigParse)
42}
43
44fn load_form_spec_value(config_json: &str) -> Result<Value, ComponentError> {
45    if config_json.trim().is_empty() {
46        return serde_json::from_str(DEFAULT_SPEC).map_err(ComponentError::ConfigParse);
47    }
48
49    let parsed: Value = serde_json::from_str(config_json).map_err(ComponentError::ConfigParse)?;
50
51    // Compatibility: callers may pass raw FormSpec JSON directly.
52    let (mut spec_value, include_registry_values) = if looks_like_form_spec_json(&parsed) {
53        (parsed.clone(), BTreeMap::new())
54    } else {
55        let config: ComponentConfig =
56            serde_json::from_value(parsed.clone()).map_err(ComponentError::ConfigParse)?;
57        let raw_spec = config
58            .form_spec_json
59            .unwrap_or_else(|| DEFAULT_SPEC.to_string());
60        let spec_value = serde_json::from_str(&raw_spec).map_err(ComponentError::ConfigParse)?;
61        let mut registry = BTreeMap::new();
62        for (form_ref, raw_form) in config.include_registry {
63            let value = serde_json::from_str(&raw_form).map_err(ComponentError::ConfigParse)?;
64            registry.insert(form_ref, value);
65        }
66        (spec_value, registry)
67    };
68
69    if !include_registry_values.is_empty() {
70        spec_value = expand_includes_value(&spec_value, &include_registry_values)?;
71    }
72    Ok(spec_value)
73}
74
75fn expand_includes_value(
76    root: &Value,
77    registry: &BTreeMap<String, Value>,
78) -> Result<Value, ComponentError> {
79    let mut chain = Vec::new();
80    let mut seen_ids = BTreeSet::new();
81    expand_form_value(root, "", registry, &mut chain, &mut seen_ids)
82}
83
84fn expand_form_value(
85    form: &Value,
86    prefix: &str,
87    registry: &BTreeMap<String, Value>,
88    chain: &mut Vec<String>,
89    seen_ids: &mut BTreeSet<String>,
90) -> Result<Value, ComponentError> {
91    let form_obj = form
92        .as_object()
93        .ok_or_else(|| ComponentError::Include("form spec must be a JSON object".into()))?;
94    let form_id = form_obj
95        .get("id")
96        .and_then(Value::as_str)
97        .unwrap_or("<unknown>")
98        .to_string();
99    if chain.contains(&form_id) {
100        let pos = chain.iter().position(|id| id == &form_id).unwrap_or(0);
101        let mut cycle = chain[pos..].to_vec();
102        cycle.push(form_id);
103        return Err(ComponentError::Include(format!(
104            "include cycle detected: {:?}",
105            cycle
106        )));
107    }
108    chain.push(form_id);
109
110    let mut out = form_obj.clone();
111    out.insert("includes".into(), Value::Array(Vec::new()));
112    out.insert("questions".into(), Value::Array(Vec::new()));
113    out.insert("validations".into(), Value::Array(Vec::new()));
114
115    let mut out_questions = Vec::new();
116    let mut out_validations = Vec::new();
117
118    for question in form_obj
119        .get("questions")
120        .and_then(Value::as_array)
121        .cloned()
122        .unwrap_or_default()
123    {
124        let mut q = question;
125        prefix_question_value(&mut q, prefix);
126        if let Some(id) = q.get("id").and_then(Value::as_str)
127            && !seen_ids.insert(id.to_string())
128        {
129            return Err(ComponentError::Include(format!(
130                "duplicate question id after include expansion: '{}'",
131                id
132            )));
133        }
134        out_questions.push(q);
135    }
136
137    for validation in form_obj
138        .get("validations")
139        .and_then(Value::as_array)
140        .cloned()
141        .unwrap_or_default()
142    {
143        let mut v = validation;
144        prefix_validation_value(&mut v, prefix);
145        out_validations.push(v);
146    }
147
148    for include in form_obj
149        .get("includes")
150        .and_then(Value::as_array)
151        .cloned()
152        .unwrap_or_default()
153    {
154        let form_ref = include
155            .get("form_ref")
156            .and_then(Value::as_str)
157            .ok_or_else(|| ComponentError::Include("include missing form_ref".into()))?;
158        let include_prefix = include.get("prefix").and_then(Value::as_str);
159        let child_prefix = combine_prefix(prefix, include_prefix);
160        let included = registry.get(form_ref).ok_or_else(|| {
161            ComponentError::Include(format!("missing include target '{}'", form_ref))
162        })?;
163        let expanded = expand_form_value(included, &child_prefix, registry, chain, seen_ids)?;
164        out_questions.extend(
165            expanded
166                .get("questions")
167                .and_then(Value::as_array)
168                .cloned()
169                .unwrap_or_default(),
170        );
171        out_validations.extend(
172            expanded
173                .get("validations")
174                .and_then(Value::as_array)
175                .cloned()
176                .unwrap_or_default(),
177        );
178    }
179
180    out.insert("questions".into(), Value::Array(out_questions));
181    out.insert("validations".into(), Value::Array(out_validations));
182    chain.pop();
183
184    Ok(Value::Object(out))
185}
186
187fn parse_context(ctx_json: &str) -> Value {
188    serde_json::from_str(ctx_json).unwrap_or_else(|_| Value::Object(Map::new()))
189}
190
191fn parse_runtime_context(ctx_json: &str) -> Value {
192    let parsed = parse_context(ctx_json);
193    parsed
194        .get("ctx")
195        .and_then(Value::as_object)
196        .map(|ctx| Value::Object(ctx.clone()))
197        .unwrap_or(parsed)
198}
199
200fn looks_like_form_spec_json(value: &Value) -> bool {
201    value.get("id").and_then(Value::as_str).is_some()
202        && value.get("title").and_then(Value::as_str).is_some()
203        && value.get("version").and_then(Value::as_str).is_some()
204        && value.get("questions").and_then(Value::as_array).is_some()
205}
206
207fn combine_prefix(parent: &str, child: Option<&str>) -> String {
208    match (parent.is_empty(), child.unwrap_or("").is_empty()) {
209        (true, true) => String::new(),
210        (false, true) => parent.to_string(),
211        (true, false) => child.unwrap_or_default().to_string(),
212        (false, false) => format!("{}.{}", parent, child.unwrap_or_default()),
213    }
214}
215
216fn prefix_key(prefix: &str, key: &str) -> String {
217    if prefix.is_empty() {
218        key.to_string()
219    } else {
220        format!("{}.{}", prefix, key)
221    }
222}
223
224fn prefix_path(prefix: &str, path: &str) -> String {
225    if path.is_empty() || path.starts_with('/') || prefix.is_empty() {
226        return path.to_string();
227    }
228    format!("{}.{}", prefix, path)
229}
230
231fn prefix_validation_value(validation: &mut Value, prefix: &str) {
232    if prefix.is_empty() {
233        return;
234    }
235    if let Some(fields) = validation.get_mut("fields").and_then(Value::as_array_mut) {
236        for field in fields {
237            if let Some(raw) = field.as_str() {
238                *field = Value::String(prefix_key(prefix, raw));
239            }
240        }
241    }
242    if let Some(condition) = validation.get_mut("condition") {
243        prefix_expr_value(condition, prefix);
244    }
245}
246
247fn prefix_question_value(question: &mut Value, prefix: &str) {
248    if prefix.is_empty() {
249        return;
250    }
251    if let Some(id) = question.get_mut("id")
252        && let Some(raw) = id.as_str()
253    {
254        *id = Value::String(prefix_key(prefix, raw));
255    }
256    if let Some(visible_if) = question.get_mut("visible_if") {
257        prefix_expr_value(visible_if, prefix);
258    }
259    if let Some(computed) = question.get_mut("computed") {
260        prefix_expr_value(computed, prefix);
261    }
262    if let Some(fields) = question
263        .get_mut("list")
264        .and_then(|list| list.get_mut("fields"))
265        .and_then(Value::as_array_mut)
266    {
267        for field in fields {
268            prefix_question_value(field, prefix);
269        }
270    }
271}
272
273fn prefix_expr_value(expr: &mut Value, prefix: &str) {
274    if let Some(obj) = expr.as_object_mut() {
275        if matches!(
276            obj.get("op").and_then(Value::as_str),
277            Some("answer") | Some("is_set")
278        ) && let Some(path) = obj.get_mut("path")
279            && let Some(raw) = path.as_str()
280        {
281            *path = Value::String(prefix_path(prefix, raw));
282        }
283        if let Some(inner) = obj.get_mut("expression") {
284            prefix_expr_value(inner, prefix);
285        }
286        if let Some(left) = obj.get_mut("left") {
287            prefix_expr_value(left, prefix);
288        }
289        if let Some(right) = obj.get_mut("right") {
290            prefix_expr_value(right, prefix);
291        }
292        if let Some(items) = obj.get_mut("expressions").and_then(Value::as_array_mut) {
293            for item in items {
294                prefix_expr_value(item, prefix);
295            }
296        }
297    }
298}
299
300fn resolve_context_answers(ctx: &Value) -> Value {
301    ctx.get("answers")
302        .cloned()
303        .unwrap_or_else(|| Value::Object(Map::new()))
304}
305
306fn parse_answers(answers_json: &str) -> Value {
307    serde_json::from_str(answers_json).unwrap_or_else(|_| Value::Object(Map::new()))
308}
309
310fn secrets_host_available(ctx: &Value) -> bool {
311    ctx.get("secrets_host_available")
312        .and_then(Value::as_bool)
313        .or_else(|| {
314            ctx.get("config")
315                .and_then(Value::as_object)
316                .and_then(|config| config.get("secrets_host_available"))
317                .and_then(Value::as_bool)
318        })
319        .unwrap_or(false)
320}
321
322fn respond(result: Result<Value, ComponentError>) -> String {
323    match result {
324        Ok(value) => serde_json::to_string(&value).unwrap_or_else(|error| {
325            json!({"error": format!("json encode: {}", error)}).to_string()
326        }),
327        Err(err) => json!({ "error": err.to_string() }).to_string(),
328    }
329}
330
331pub fn describe(form_id: &str, config_json: &str) -> String {
332    respond(load_form_spec(config_json).and_then(|spec| {
333        if spec.id != form_id {
334            Err(ComponentError::FormUnavailable(form_id.to_string()))
335        } else {
336            serde_json::to_value(spec).map_err(ComponentError::JsonEncode)
337        }
338    }))
339}
340
341fn ensure_form(form_id: &str, config_json: &str) -> Result<FormSpec, ComponentError> {
342    let spec = load_form_spec(config_json)?;
343    if spec.id != form_id {
344        Err(ComponentError::FormUnavailable(form_id.to_string()))
345    } else {
346        Ok(spec)
347    }
348}
349
350pub fn get_answer_schema(form_id: &str, config_json: &str, ctx_json: &str) -> String {
351    let schema = ensure_form(form_id, config_json).map(|spec| {
352        let ctx = parse_runtime_context(ctx_json);
353        let answers = resolve_context_answers(&ctx);
354        let visibility = resolve_visibility(&spec, &answers, VisibilityMode::Visible);
355        answers_schema(&spec, &visibility)
356    });
357    respond(schema)
358}
359
360pub fn get_example_answers(form_id: &str, config_json: &str, ctx_json: &str) -> String {
361    let result = ensure_form(form_id, config_json).map(|spec| {
362        let ctx = parse_runtime_context(ctx_json);
363        let answers = resolve_context_answers(&ctx);
364        let visibility = resolve_visibility(&spec, &answers, VisibilityMode::Visible);
365        example_answers(&spec, &visibility)
366    });
367    respond(result)
368}
369
370pub fn validate_answers(form_id: &str, config_json: &str, answers_json: &str) -> String {
371    let validation = ensure_form(form_id, config_json).and_then(|spec| {
372        let answers = serde_json::from_str(answers_json).map_err(ComponentError::ConfigParse)?;
373        serde_json::to_value(validate(&spec, &answers)).map_err(ComponentError::JsonEncode)
374    });
375    respond(validation)
376}
377
378pub fn next(form_id: &str, config_json: &str, answers_json: &str) -> String {
379    let result = ensure_form(form_id, config_json).map(|spec| {
380        let ctx = parse_runtime_context(config_json);
381        let answers = parse_answers(answers_json);
382        let visibility = resolve_visibility(&spec, &answers, VisibilityMode::Visible);
383        let progress_ctx = ProgressContext::new(answers.clone(), &ctx);
384        let next_q = next_question(&spec, &progress_ctx, &visibility);
385        let answered = progress_ctx.answered_count(&spec, &visibility);
386        let total = visibility.values().filter(|visible| **visible).count();
387        json!({
388            "status": if next_q.is_some() { "need_input" } else { "complete" },
389            "next_question_id": next_q,
390            "progress": {
391                "answered": answered,
392                "total": total
393            }
394        })
395    });
396    respond(result)
397}
398
399pub fn apply_store(form_id: &str, ctx_json: &str, answers_json: &str) -> String {
400    let result = ensure_form(form_id, ctx_json).and_then(|spec| {
401        let ctx = parse_runtime_context(ctx_json);
402        let answers = parse_answers(answers_json);
403        let mut store_ctx = StoreContext::from_value(&ctx);
404        store_ctx.answers = answers;
405        let host_available = secrets_host_available(&ctx);
406        store_ctx.apply_ops(&spec.store, spec.secrets_policy.as_ref(), host_available)?;
407        Ok(store_ctx.to_value())
408    });
409    respond(result)
410}
411
412fn render_payload(
413    form_id: &str,
414    config_json: &str,
415    ctx_json: &str,
416    answers_json: &str,
417) -> Result<RenderPayload, ComponentError> {
418    let spec = ensure_form(form_id, config_json)?;
419    let ctx = parse_runtime_context(ctx_json);
420    let answers = parse_answers(answers_json);
421    let mut payload = build_render_payload(&spec, &ctx, &answers);
422    let spec_value = load_form_spec_value(config_json)?;
423    apply_i18n_to_payload(&mut payload, &spec_value, &ctx);
424    Ok(payload)
425}
426
427type ResolvedI18nMap = BTreeMap<String, String>;
428
429fn parse_resolved_i18n(ctx: &Value) -> ResolvedI18nMap {
430    ctx.get("i18n_resolved")
431        .and_then(Value::as_object)
432        .map(|value| {
433            value
434                .iter()
435                .filter_map(|(key, val)| val.as_str().map(|text| (key.clone(), text.to_string())))
436                .collect()
437        })
438        .unwrap_or_default()
439}
440
441fn i18n_debug_enabled(ctx: &Value) -> bool {
442    ctx.get("debug_i18n")
443        .and_then(Value::as_bool)
444        .or_else(|| ctx.get("i18n_debug").and_then(Value::as_bool))
445        .unwrap_or(false)
446}
447
448fn attach_i18n_debug_metadata(card: &mut Value, payload: &RenderPayload, spec_value: &Value) {
449    let keys = build_question_i18n_key_map(spec_value);
450    let question_metadata = payload
451        .questions
452        .iter()
453        .filter_map(|question| {
454            let (title_key, description_key) =
455                keys.get(&question.id).cloned().unwrap_or((None, None));
456            if title_key.is_none() && description_key.is_none() {
457                return None;
458            }
459            Some(json!({
460                "id": question.id,
461                "title_key": title_key,
462                "description_key": description_key,
463            }))
464        })
465        .collect::<Vec<_>>();
466    if question_metadata.is_empty() {
467        return;
468    }
469
470    if let Some(map) = card.as_object_mut() {
471        map.insert(
472            "metadata".into(),
473            json!({
474                "qa": {
475                    "i18n_debug": true,
476                    "questions": question_metadata
477                }
478            }),
479        );
480    }
481}
482
483fn build_question_i18n_key_map(
484    spec_value: &Value,
485) -> BTreeMap<String, (Option<String>, Option<String>)> {
486    let mut map = BTreeMap::new();
487    for question in spec_value
488        .get("questions")
489        .and_then(Value::as_array)
490        .cloned()
491        .unwrap_or_default()
492    {
493        if let Some(id) = question.get("id").and_then(Value::as_str) {
494            let title_key = question
495                .get("title_i18n")
496                .and_then(|value| value.get("key"))
497                .and_then(Value::as_str)
498                .map(str::to_string);
499            let description_key = question
500                .get("description_i18n")
501                .and_then(|value| value.get("key"))
502                .and_then(Value::as_str)
503                .map(str::to_string);
504            map.insert(id.to_string(), (title_key, description_key));
505        }
506    }
507    map
508}
509
510fn resolve_i18n_value(
511    resolved: &ResolvedI18nMap,
512    key: &str,
513    requested_locale: Option<&str>,
514    default_locale: Option<&str>,
515) -> Option<String> {
516    for locale in [requested_locale, default_locale].iter().flatten() {
517        if let Some(value) = resolved.get(&format!("{}:{}", locale, key)) {
518            return Some(value.clone());
519        }
520        if let Some(value) = resolved.get(&format!("{}/{}", locale, key)) {
521            return Some(value.clone());
522        }
523    }
524    resolved.get(key).cloned()
525}
526
527fn apply_i18n_to_payload(payload: &mut RenderPayload, spec_value: &Value, ctx: &Value) {
528    let resolved = parse_resolved_i18n(ctx);
529    if resolved.is_empty() {
530        return;
531    }
532    let requested_locale = ctx.get("locale").and_then(Value::as_str);
533    let default_locale = spec_value
534        .get("presentation")
535        .and_then(|value| value.get("default_locale"))
536        .and_then(Value::as_str);
537
538    let mut by_id = BTreeMap::new();
539    for question in spec_value
540        .get("questions")
541        .and_then(Value::as_array)
542        .cloned()
543        .unwrap_or_default()
544    {
545        if let Some(id) = question.get("id").and_then(Value::as_str) {
546            by_id.insert(id.to_string(), question);
547        }
548    }
549
550    for question in &mut payload.questions {
551        let Some(spec_question) = by_id.get(&question.id) else {
552            continue;
553        };
554        if let Some(key) = spec_question
555            .get("title_i18n")
556            .and_then(|value| value.get("key"))
557            .and_then(Value::as_str)
558            && let Some(value) =
559                resolve_i18n_value(&resolved, key, requested_locale, default_locale)
560        {
561            question.title = value;
562        }
563        if let Some(key) = spec_question
564            .get("description_i18n")
565            .and_then(|value| value.get("key"))
566            .and_then(Value::as_str)
567            && let Some(value) =
568                resolve_i18n_value(&resolved, key, requested_locale, default_locale)
569        {
570            question.description = Some(value);
571        }
572    }
573}
574
575fn respond_string(result: Result<String, ComponentError>) -> String {
576    match result {
577        Ok(value) => value,
578        Err(err) => json!({ "error": err.to_string() }).to_string(),
579    }
580}
581
582pub fn render_text(form_id: &str, config_json: &str, ctx_json: &str, answers_json: &str) -> String {
583    respond_string(
584        render_payload(form_id, config_json, ctx_json, answers_json)
585            .map(|payload| qa_render_text(&payload)),
586    )
587}
588
589pub fn render_json_ui(
590    form_id: &str,
591    config_json: &str,
592    ctx_json: &str,
593    answers_json: &str,
594) -> String {
595    respond(
596        render_payload(form_id, config_json, ctx_json, answers_json)
597            .map(|payload| qa_render_json_ui(&payload)),
598    )
599}
600
601pub fn render_card(form_id: &str, config_json: &str, ctx_json: &str, answers_json: &str) -> String {
602    respond(
603        render_payload(form_id, config_json, ctx_json, answers_json).map(|payload| {
604            let mut card = qa_render_card(&payload);
605            let ctx = parse_runtime_context(ctx_json);
606            if i18n_debug_enabled(&ctx)
607                && let Ok(spec_value) = load_form_spec_value(config_json)
608            {
609                attach_i18n_debug_metadata(&mut card, &payload, &spec_value);
610            }
611            card
612        }),
613    )
614}
615
616fn submission_progress(payload: &RenderPayload) -> Value {
617    json!({
618        "answered": payload.progress.answered,
619        "total": payload.progress.total,
620    })
621}
622
623fn build_error_response(
624    payload: &RenderPayload,
625    answers: Value,
626    validation: &qa_spec::ValidationResult,
627) -> Result<Value, ComponentError> {
628    let validation_value = serde_json::to_value(validation).map_err(ComponentError::JsonEncode)?;
629    Ok(json!({
630        "status": "error",
631        "next_question_id": payload.next_question_id,
632        "progress": submission_progress(payload),
633        "answers": answers,
634        "validation": validation_value,
635    }))
636}
637
638fn build_success_response(
639    payload: &RenderPayload,
640    answers: Value,
641    store_ctx: &StoreContext,
642) -> Value {
643    let status = if payload.next_question_id.is_some() {
644        "need_input"
645    } else {
646        "complete"
647    };
648
649    json!({
650        "status": status,
651        "next_question_id": payload.next_question_id,
652        "progress": submission_progress(payload),
653        "answers": answers,
654        "store": store_ctx.to_value(),
655    })
656}
657
658#[derive(Debug, Clone)]
659struct SubmissionPlan {
660    validated_patch: Value,
661    validation: qa_spec::ValidationResult,
662    payload: RenderPayload,
663    effects: Vec<StoreOp>,
664}
665
666fn build_submission_plan(spec: &FormSpec, ctx: &Value, answers: Value) -> SubmissionPlan {
667    let validation = validate(spec, &answers);
668    let payload = build_render_payload(spec, ctx, &answers);
669    let effects = if validation.valid {
670        spec.store.clone()
671    } else {
672        Vec::new()
673    };
674    SubmissionPlan {
675        validated_patch: answers,
676        validation,
677        payload,
678        effects,
679    }
680}
681
682pub fn submit_patch(
683    form_id: &str,
684    config_json: &str,
685    ctx_json: &str,
686    answers_json: &str,
687    question_id: &str,
688    value_json: &str,
689) -> String {
690    // Compatibility wrapper: this endpoint now follows a deterministic
691    // plan->execute split internally while preserving existing response shape.
692    respond(ensure_form(form_id, config_json).and_then(|spec| {
693        let ctx = parse_runtime_context(ctx_json);
694        let value: Value = serde_json::from_str(value_json).map_err(ComponentError::ConfigParse)?;
695        let mut answers = parse_answers(answers_json)
696            .as_object()
697            .cloned()
698            .unwrap_or_default();
699        answers.insert(question_id.to_string(), value);
700        let plan = build_submission_plan(&spec, &ctx, Value::Object(answers));
701
702        if !plan.validation.valid {
703            return build_error_response(&plan.payload, plan.validated_patch, &plan.validation);
704        }
705
706        let mut store_ctx = StoreContext::from_value(&ctx);
707        store_ctx.answers = plan.validated_patch.clone();
708        let host_available = secrets_host_available(&ctx);
709        store_ctx.apply_ops(&plan.effects, spec.secrets_policy.as_ref(), host_available)?;
710        let response = build_success_response(&plan.payload, plan.validated_patch, &store_ctx);
711        Ok(response)
712    }))
713}
714
715pub fn submit_all(form_id: &str, config_json: &str, ctx_json: &str, answers_json: &str) -> String {
716    // Compatibility wrapper: this endpoint now follows a deterministic
717    // plan->execute split internally while preserving existing response shape.
718    respond(ensure_form(form_id, config_json).and_then(|spec| {
719        let ctx = parse_runtime_context(ctx_json);
720        let answers = parse_answers(answers_json);
721        let plan = build_submission_plan(&spec, &ctx, answers);
722
723        if !plan.validation.valid {
724            return build_error_response(&plan.payload, plan.validated_patch, &plan.validation);
725        }
726
727        let mut store_ctx = StoreContext::from_value(&ctx);
728        store_ctx.answers = plan.validated_patch.clone();
729        let host_available = secrets_host_available(&ctx);
730        store_ctx.apply_ops(&plan.effects, spec.secrets_policy.as_ref(), host_available)?;
731        let response = build_success_response(&plan.payload, plan.validated_patch, &store_ctx);
732        Ok(response)
733    }))
734}
735
736#[cfg(test)]
737mod tests {
738    use super::*;
739    use serde_json::json;
740
741    #[test]
742    fn describe_returns_spec_json() {
743        let payload = describe("example-form", "");
744        let spec: Value = serde_json::from_str(&payload).expect("valid json");
745        assert_eq!(spec["id"], "example-form");
746    }
747
748    #[test]
749    fn describe_accepts_raw_form_spec_as_config_json() {
750        let spec = json!({
751            "id": "raw-form",
752            "title": "Raw",
753            "version": "1.0",
754            "questions": [
755                { "id": "q1", "type": "string", "title": "Q1", "required": true }
756            ]
757        });
758        let payload = describe("raw-form", &spec.to_string());
759        let parsed: Value = serde_json::from_str(&payload).expect("json");
760        assert_eq!(parsed["id"], "raw-form");
761    }
762
763    #[test]
764    fn schema_matches_questions() {
765        let schema = get_answer_schema("example-form", "", "{}");
766        let value: Value = serde_json::from_str(&schema).expect("json");
767        assert!(
768            value
769                .get("properties")
770                .unwrap()
771                .as_object()
772                .unwrap()
773                .contains_key("q1")
774        );
775    }
776
777    #[test]
778    fn example_answers_include_question_values() {
779        let examples = get_example_answers("example-form", "", "{}");
780        let parsed: Value = serde_json::from_str(&examples).expect("json");
781        assert_eq!(parsed["q1"], "example-q1");
782    }
783
784    #[test]
785    fn validate_answers_reports_valid_when_complete() {
786        let answers = json!({ "q1": "tester", "q2": true });
787        let result = validate_answers("example-form", "", &answers.to_string());
788        let parsed: Value = serde_json::from_str(&result).expect("json");
789        assert!(parsed["valid"].as_bool().unwrap_or(false));
790    }
791
792    #[test]
793    fn next_returns_progress_payload() {
794        let spec = json!({
795            "id": "progress-form",
796            "title": "Progress",
797            "version": "1.0",
798            "progress_policy": {
799                "skip_answered": true
800            },
801            "questions": [
802                { "id": "q1", "type": "string", "title": "q1", "required": true },
803                { "id": "q2", "type": "string", "title": "q2", "required": true }
804            ]
805        });
806        let ctx = json!({ "form_spec_json": spec.to_string() });
807        let response = next("progress-form", &ctx.to_string(), r#"{"q1": "test"}"#);
808        let parsed: Value = serde_json::from_str(&response).expect("json");
809        assert_eq!(parsed["status"], "need_input");
810        assert_eq!(parsed["next_question_id"], "q2");
811        assert_eq!(parsed["progress"]["answered"], 1);
812    }
813
814    #[test]
815    fn next_accepts_context_envelope_under_ctx_key() {
816        let spec = json!({
817            "id": "progress-form",
818            "title": "Progress",
819            "version": "1.0",
820            "progress_policy": {
821                "skip_answered": true
822            },
823            "questions": [
824                { "id": "q1", "type": "string", "title": "q1", "required": true },
825                { "id": "q2", "type": "string", "title": "q2", "required": true }
826            ]
827        });
828        let cfg = json!({
829            "form_spec_json": spec.to_string(),
830            "ctx": {
831                "state": {}
832            }
833        });
834        let response = next("progress-form", &cfg.to_string(), r#"{"q1":"done"}"#);
835        let parsed: Value = serde_json::from_str(&response).expect("json");
836        assert_eq!(parsed["status"], "need_input");
837        assert_eq!(parsed["next_question_id"], "q2");
838    }
839
840    #[test]
841    fn apply_store_writes_state_value() {
842        let spec = json!({
843            "id": "store-form",
844            "title": "Store",
845            "version": "1.0",
846            "questions": [
847                { "id": "q1", "type": "string", "title": "q1", "required": true }
848            ],
849            "store": [
850                {
851                    "target": "state",
852                    "path": "/flag",
853                    "value": true
854                }
855            ]
856        });
857        let ctx = json!({
858            "form_spec_json": spec.to_string(),
859            "state": {}
860        });
861        let result = apply_store("store-form", &ctx.to_string(), "{}");
862        let parsed: Value = serde_json::from_str(&result).expect("json");
863        assert_eq!(parsed["state"]["flag"], true);
864    }
865
866    #[test]
867    fn apply_store_writes_secret_when_allowed() {
868        let spec = json!({
869            "id": "store-secret",
870            "title": "Store Secret",
871            "version": "1.0",
872            "questions": [
873                { "id": "q1", "type": "string", "title": "q1", "required": true }
874            ],
875            "store": [
876                {
877                    "target": "secrets",
878                    "path": "/aws/key",
879                    "value": "value"
880                }
881            ],
882            "secrets_policy": {
883                "enabled": true,
884                "read_enabled": true,
885                "write_enabled": true,
886                "allow": ["aws/*"]
887            }
888        });
889        let ctx = json!({
890            "form_spec_json": spec.to_string(),
891            "state": {},
892            "secrets_host_available": true
893        });
894        let result = apply_store("store-secret", &ctx.to_string(), "{}");
895        let parsed: Value = serde_json::from_str(&result).expect("json");
896        assert_eq!(parsed["secrets"]["aws"]["key"], "value");
897    }
898
899    #[test]
900    fn render_text_outputs_summary() {
901        let output = render_text("example-form", "", "{}", "{}");
902        assert!(output.contains("Form:"));
903        assert!(output.contains("Visible questions"));
904    }
905
906    #[test]
907    fn render_json_ui_outputs_json_payload() {
908        let payload = render_json_ui("example-form", "", "{}", r#"{"q1":"value"}"#);
909        let parsed: Value = serde_json::from_str(&payload).expect("json");
910        assert_eq!(parsed["form_id"], "example-form");
911        assert_eq!(parsed["progress"]["total"], 2);
912    }
913
914    #[test]
915    fn render_json_ui_expands_includes_from_registry() {
916        let parent = json!({
917            "id": "parent-form",
918            "title": "Parent",
919            "version": "1.0",
920            "includes": [
921                { "form_ref": "child", "prefix": "child" }
922            ],
923            "questions": [
924                { "id": "root", "type": "string", "title": "Root", "required": true }
925            ]
926        });
927        let child = json!({
928            "id": "child-form",
929            "title": "Child",
930            "version": "1.0",
931            "questions": [
932                { "id": "name", "type": "string", "title": "Name", "required": true }
933            ]
934        });
935        let config = json!({
936            "form_spec_json": parent.to_string(),
937            "include_registry": {
938                "child": child.to_string()
939            }
940        });
941
942        let payload = render_json_ui("parent-form", &config.to_string(), "{}", "{}");
943        let parsed: Value = serde_json::from_str(&payload).expect("json");
944        let questions = parsed["questions"].as_array().expect("questions array");
945        assert!(questions.iter().any(|question| question["id"] == "root"));
946        assert!(
947            questions
948                .iter()
949                .any(|question| question["id"] == "child.name")
950        );
951    }
952
953    #[test]
954    fn render_card_outputs_patch_action() {
955        let payload = render_card("example-form", "", "{}", "{}");
956        let parsed: Value = serde_json::from_str(&payload).expect("json");
957        assert_eq!(parsed["version"], "1.3");
958        let actions = parsed["actions"].as_array().expect("actions");
959        assert_eq!(actions[0]["data"]["qa"]["mode"], "patch");
960    }
961
962    #[test]
963    fn render_card_attaches_i18n_debug_metadata_when_enabled() {
964        let spec = json!({
965            "id": "i18n-card-form",
966            "title": "Card",
967            "version": "1.0",
968            "questions": [
969                {
970                    "id": "name",
971                    "type": "string",
972                    "title": "Name",
973                    "title_i18n": { "key": "name.title" },
974                    "required": true
975                }
976            ]
977        });
978        let config = json!({ "form_spec_json": spec.to_string() });
979        let ctx = json!({
980            "i18n_debug": true,
981            "i18n_resolved": {
982                "name.title": "Localized Name"
983            }
984        });
985        let payload = render_card(
986            "i18n-card-form",
987            &config.to_string(),
988            &ctx.to_string(),
989            "{}",
990        );
991        let parsed: Value = serde_json::from_str(&payload).expect("json");
992        assert_eq!(parsed["metadata"]["qa"]["i18n_debug"], true);
993        let questions = parsed["metadata"]["qa"]["questions"]
994            .as_array()
995            .expect("questions metadata");
996        assert_eq!(questions[0]["id"], "name");
997        assert_eq!(questions[0]["title_key"], "name.title");
998    }
999
1000    #[test]
1001    fn submit_patch_advances_and_updates_store() {
1002        let response = submit_patch("example-form", "", "{}", "{}", "q1", r#""Acme""#);
1003        let parsed: Value = serde_json::from_str(&response).expect("json");
1004        assert_eq!(parsed["status"], "need_input");
1005        assert_eq!(parsed["next_question_id"], "q2");
1006        assert_eq!(parsed["answers"]["q1"], "Acme");
1007        assert_eq!(parsed["store"]["answers"]["q1"], "Acme");
1008    }
1009
1010    #[test]
1011    fn submit_patch_returns_validation_error() {
1012        let response = submit_patch("example-form", "", "{}", "{}", "q1", "true");
1013        let parsed: Value = serde_json::from_str(&response).expect("json");
1014        assert_eq!(parsed["status"], "error");
1015        assert_eq!(parsed["validation"]["errors"][0]["code"], "type_mismatch");
1016    }
1017
1018    #[test]
1019    fn submit_all_completes_with_valid_answers() {
1020        let response = submit_all("example-form", "", "{}", r#"{"q1":"Acme","q2":true}"#);
1021        let parsed: Value = serde_json::from_str(&response).expect("json");
1022        assert_eq!(parsed["status"], "complete");
1023        assert!(parsed["next_question_id"].is_null());
1024        assert_eq!(parsed["answers"]["q2"], true);
1025        assert_eq!(parsed["store"]["answers"]["q2"], true);
1026    }
1027}