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_with_ctx(
379    form_id: &str,
380    config_json: &str,
381    ctx_json: &str,
382    answers_json: &str,
383) -> String {
384    let result = ensure_form(form_id, config_json).map(|spec| {
385        let ctx = parse_runtime_context(ctx_json);
386        let answers = parse_answers(answers_json);
387        let visibility = resolve_visibility(&spec, &answers, VisibilityMode::Visible);
388        let progress_ctx = ProgressContext::new(answers.clone(), &ctx);
389        let next_q = next_question(&spec, &progress_ctx, &visibility);
390        let answered = progress_ctx.answered_count(&spec, &visibility);
391        let total = visibility.values().filter(|visible| **visible).count();
392        json!({
393            "status": if next_q.is_some() { "need_input" } else { "complete" },
394            "next_question_id": next_q,
395            "progress": {
396                "answered": answered,
397                "total": total
398            }
399        })
400    });
401    respond(result)
402}
403
404pub fn next(form_id: &str, config_json: &str, answers_json: &str) -> String {
405    next_with_ctx(form_id, config_json, "{}", answers_json)
406}
407
408pub fn apply_store(form_id: &str, ctx_json: &str, answers_json: &str) -> String {
409    let result = ensure_form(form_id, ctx_json).and_then(|spec| {
410        let ctx = parse_runtime_context(ctx_json);
411        let answers = parse_answers(answers_json);
412        let mut store_ctx = StoreContext::from_value(&ctx);
413        store_ctx.answers = answers;
414        let host_available = secrets_host_available(&ctx);
415        store_ctx.apply_ops(&spec.store, spec.secrets_policy.as_ref(), host_available)?;
416        Ok(store_ctx.to_value())
417    });
418    respond(result)
419}
420
421fn render_payload(
422    form_id: &str,
423    config_json: &str,
424    ctx_json: &str,
425    answers_json: &str,
426) -> Result<RenderPayload, ComponentError> {
427    let spec = ensure_form(form_id, config_json)?;
428    let ctx = parse_runtime_context(ctx_json);
429    let answers = parse_answers(answers_json);
430    let mut payload = build_render_payload(&spec, &ctx, &answers);
431    let spec_value = load_form_spec_value(config_json)?;
432    apply_i18n_to_payload(&mut payload, &spec_value, &ctx);
433    Ok(payload)
434}
435
436type ResolvedI18nMap = BTreeMap<String, String>;
437
438fn parse_resolved_i18n(ctx: &Value) -> ResolvedI18nMap {
439    ctx.get("i18n_resolved")
440        .and_then(Value::as_object)
441        .map(|value| {
442            value
443                .iter()
444                .filter_map(|(key, val)| val.as_str().map(|text| (key.clone(), text.to_string())))
445                .collect()
446        })
447        .unwrap_or_default()
448}
449
450fn i18n_debug_enabled(ctx: &Value) -> bool {
451    ctx.get("debug_i18n")
452        .and_then(Value::as_bool)
453        .or_else(|| ctx.get("i18n_debug").and_then(Value::as_bool))
454        .unwrap_or(false)
455}
456
457fn attach_i18n_debug_metadata(card: &mut Value, payload: &RenderPayload, spec_value: &Value) {
458    let keys = build_question_i18n_key_map(spec_value);
459    let question_metadata = payload
460        .questions
461        .iter()
462        .filter_map(|question| {
463            let (title_key, description_key) =
464                keys.get(&question.id).cloned().unwrap_or((None, None));
465            if title_key.is_none() && description_key.is_none() {
466                return None;
467            }
468            Some(json!({
469                "id": question.id,
470                "title_key": title_key,
471                "description_key": description_key,
472            }))
473        })
474        .collect::<Vec<_>>();
475    if question_metadata.is_empty() {
476        return;
477    }
478
479    if let Some(map) = card.as_object_mut() {
480        map.insert(
481            "metadata".into(),
482            json!({
483                "qa": {
484                    "i18n_debug": true,
485                    "questions": question_metadata
486                }
487            }),
488        );
489    }
490}
491
492fn build_question_i18n_key_map(
493    spec_value: &Value,
494) -> BTreeMap<String, (Option<String>, Option<String>)> {
495    let mut map = BTreeMap::new();
496    for question in spec_value
497        .get("questions")
498        .and_then(Value::as_array)
499        .cloned()
500        .unwrap_or_default()
501    {
502        if let Some(id) = question.get("id").and_then(Value::as_str) {
503            let title_key = question
504                .get("title_i18n")
505                .and_then(|value| value.get("key"))
506                .and_then(Value::as_str)
507                .map(str::to_string);
508            let description_key = question
509                .get("description_i18n")
510                .and_then(|value| value.get("key"))
511                .and_then(Value::as_str)
512                .map(str::to_string);
513            map.insert(id.to_string(), (title_key, description_key));
514        }
515    }
516    map
517}
518
519fn resolve_i18n_value(
520    resolved: &ResolvedI18nMap,
521    key: &str,
522    requested_locale: Option<&str>,
523    default_locale: Option<&str>,
524) -> Option<String> {
525    for locale in [requested_locale, default_locale].iter().flatten() {
526        if let Some(value) = resolved.get(&format!("{}:{}", locale, key)) {
527            return Some(value.clone());
528        }
529        if let Some(value) = resolved.get(&format!("{}/{}", locale, key)) {
530            return Some(value.clone());
531        }
532    }
533    resolved.get(key).cloned()
534}
535
536fn apply_i18n_to_payload(payload: &mut RenderPayload, spec_value: &Value, ctx: &Value) {
537    let resolved = parse_resolved_i18n(ctx);
538    if resolved.is_empty() {
539        return;
540    }
541    let requested_locale = ctx.get("locale").and_then(Value::as_str);
542    let default_locale = spec_value
543        .get("presentation")
544        .and_then(|value| value.get("default_locale"))
545        .and_then(Value::as_str);
546
547    let mut by_id = BTreeMap::new();
548    for question in spec_value
549        .get("questions")
550        .and_then(Value::as_array)
551        .cloned()
552        .unwrap_or_default()
553    {
554        if let Some(id) = question.get("id").and_then(Value::as_str) {
555            by_id.insert(id.to_string(), question);
556        }
557    }
558
559    for question in &mut payload.questions {
560        let Some(spec_question) = by_id.get(&question.id) else {
561            continue;
562        };
563        if let Some(key) = spec_question
564            .get("title_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.title = value;
571        }
572        if let Some(key) = spec_question
573            .get("description_i18n")
574            .and_then(|value| value.get("key"))
575            .and_then(Value::as_str)
576            && let Some(value) =
577                resolve_i18n_value(&resolved, key, requested_locale, default_locale)
578        {
579            question.description = Some(value);
580        }
581    }
582}
583
584fn respond_string(result: Result<String, ComponentError>) -> String {
585    match result {
586        Ok(value) => value,
587        Err(err) => json!({ "error": err.to_string() }).to_string(),
588    }
589}
590
591pub fn render_text(form_id: &str, config_json: &str, ctx_json: &str, answers_json: &str) -> String {
592    respond_string(
593        render_payload(form_id, config_json, ctx_json, answers_json)
594            .map(|payload| qa_render_text(&payload)),
595    )
596}
597
598pub fn render_json_ui(
599    form_id: &str,
600    config_json: &str,
601    ctx_json: &str,
602    answers_json: &str,
603) -> String {
604    respond(
605        render_payload(form_id, config_json, ctx_json, answers_json)
606            .map(|payload| qa_render_json_ui(&payload)),
607    )
608}
609
610pub fn render_card(form_id: &str, config_json: &str, ctx_json: &str, answers_json: &str) -> String {
611    respond(
612        render_payload(form_id, config_json, ctx_json, answers_json).map(|payload| {
613            let mut card = qa_render_card(&payload);
614            let ctx = parse_runtime_context(ctx_json);
615            if i18n_debug_enabled(&ctx)
616                && let Ok(spec_value) = load_form_spec_value(config_json)
617            {
618                attach_i18n_debug_metadata(&mut card, &payload, &spec_value);
619            }
620            card
621        }),
622    )
623}
624
625fn submission_progress(payload: &RenderPayload) -> Value {
626    json!({
627        "answered": payload.progress.answered,
628        "total": payload.progress.total,
629    })
630}
631
632fn build_error_response(
633    payload: &RenderPayload,
634    answers: Value,
635    validation: &qa_spec::ValidationResult,
636) -> Result<Value, ComponentError> {
637    let validation_value = serde_json::to_value(validation).map_err(ComponentError::JsonEncode)?;
638    Ok(json!({
639        "status": "error",
640        "next_question_id": payload.next_question_id,
641        "progress": submission_progress(payload),
642        "answers": answers,
643        "validation": validation_value,
644    }))
645}
646
647fn build_success_response(
648    payload: &RenderPayload,
649    answers: Value,
650    store_ctx: &StoreContext,
651) -> Value {
652    let status = if payload.next_question_id.is_some() {
653        "need_input"
654    } else {
655        "complete"
656    };
657
658    json!({
659        "status": status,
660        "next_question_id": payload.next_question_id,
661        "progress": submission_progress(payload),
662        "answers": answers,
663        "store": store_ctx.to_value(),
664    })
665}
666
667#[derive(Debug, Clone)]
668struct SubmissionPlan {
669    validated_patch: Value,
670    validation: qa_spec::ValidationResult,
671    payload: RenderPayload,
672    effects: Vec<StoreOp>,
673}
674
675fn build_submission_plan(spec: &FormSpec, ctx: &Value, answers: Value) -> SubmissionPlan {
676    let validation = validate(spec, &answers);
677    let payload = build_render_payload(spec, ctx, &answers);
678    let effects = if validation.valid {
679        spec.store.clone()
680    } else {
681        Vec::new()
682    };
683    SubmissionPlan {
684        validated_patch: answers,
685        validation,
686        payload,
687        effects,
688    }
689}
690
691pub fn submit_patch(
692    form_id: &str,
693    config_json: &str,
694    ctx_json: &str,
695    answers_json: &str,
696    question_id: &str,
697    value_json: &str,
698) -> String {
699    // Compatibility wrapper: this endpoint now follows a deterministic
700    // plan->execute split internally while preserving existing response shape.
701    respond(ensure_form(form_id, config_json).and_then(|spec| {
702        let ctx = parse_runtime_context(ctx_json);
703        let value: Value = serde_json::from_str(value_json).map_err(ComponentError::ConfigParse)?;
704        let mut answers = parse_answers(answers_json)
705            .as_object()
706            .cloned()
707            .unwrap_or_default();
708        answers.insert(question_id.to_string(), value);
709        let plan = build_submission_plan(&spec, &ctx, Value::Object(answers));
710
711        if !plan.validation.valid {
712            return build_error_response(&plan.payload, plan.validated_patch, &plan.validation);
713        }
714
715        let mut store_ctx = StoreContext::from_value(&ctx);
716        store_ctx.answers = plan.validated_patch.clone();
717        let host_available = secrets_host_available(&ctx);
718        store_ctx.apply_ops(&plan.effects, spec.secrets_policy.as_ref(), host_available)?;
719        let response = build_success_response(&plan.payload, plan.validated_patch, &store_ctx);
720        Ok(response)
721    }))
722}
723
724pub fn submit_all(form_id: &str, config_json: &str, ctx_json: &str, answers_json: &str) -> String {
725    // Compatibility wrapper: this endpoint now follows a deterministic
726    // plan->execute split internally while preserving existing response shape.
727    respond(ensure_form(form_id, config_json).and_then(|spec| {
728        let ctx = parse_runtime_context(ctx_json);
729        let answers = parse_answers(answers_json);
730        let plan = build_submission_plan(&spec, &ctx, answers);
731
732        if !plan.validation.valid {
733            return build_error_response(&plan.payload, plan.validated_patch, &plan.validation);
734        }
735
736        let mut store_ctx = StoreContext::from_value(&ctx);
737        store_ctx.answers = plan.validated_patch.clone();
738        let host_available = secrets_host_available(&ctx);
739        store_ctx.apply_ops(&plan.effects, spec.secrets_policy.as_ref(), host_available)?;
740        let response = build_success_response(&plan.payload, plan.validated_patch, &store_ctx);
741        Ok(response)
742    }))
743}
744
745#[cfg(test)]
746mod tests {
747    use super::*;
748    use serde_json::json;
749
750    #[test]
751    fn describe_returns_spec_json() {
752        let payload = describe("example-form", "");
753        let spec: Value = serde_json::from_str(&payload).expect("valid json");
754        assert_eq!(spec["id"], "example-form");
755    }
756
757    #[test]
758    fn describe_accepts_raw_form_spec_as_config_json() {
759        let spec = json!({
760            "id": "raw-form",
761            "title": "Raw",
762            "version": "1.0",
763            "questions": [
764                { "id": "q1", "type": "string", "title": "Q1", "required": true }
765            ]
766        });
767        let payload = describe("raw-form", &spec.to_string());
768        let parsed: Value = serde_json::from_str(&payload).expect("json");
769        assert_eq!(parsed["id"], "raw-form");
770    }
771
772    #[test]
773    fn schema_matches_questions() {
774        let schema = get_answer_schema("example-form", "", "{}");
775        let value: Value = serde_json::from_str(&schema).expect("json");
776        assert!(
777            value
778                .get("properties")
779                .unwrap()
780                .as_object()
781                .unwrap()
782                .contains_key("q1")
783        );
784    }
785
786    #[test]
787    fn example_answers_include_question_values() {
788        let examples = get_example_answers("example-form", "", "{}");
789        let parsed: Value = serde_json::from_str(&examples).expect("json");
790        assert_eq!(parsed["q1"], "example-q1");
791    }
792
793    #[test]
794    fn validate_answers_reports_valid_when_complete() {
795        let answers = json!({ "q1": "tester", "q2": true });
796        let result = validate_answers("example-form", "", &answers.to_string());
797        let parsed: Value = serde_json::from_str(&result).expect("json");
798        assert!(parsed["valid"].as_bool().unwrap_or(false));
799    }
800
801    #[test]
802    fn next_returns_progress_payload() {
803        let spec = json!({
804            "id": "progress-form",
805            "title": "Progress",
806            "version": "1.0",
807            "progress_policy": {
808                "skip_answered": true
809            },
810            "questions": [
811                { "id": "q1", "type": "string", "title": "q1", "required": true },
812                { "id": "q2", "type": "string", "title": "q2", "required": true }
813            ]
814        });
815        let ctx = json!({ "form_spec_json": spec.to_string() });
816        let response = next("progress-form", &ctx.to_string(), r#"{"q1": "test"}"#);
817        let parsed: Value = serde_json::from_str(&response).expect("json");
818        assert_eq!(parsed["status"], "need_input");
819        assert_eq!(parsed["next_question_id"], "q2");
820        assert_eq!(parsed["progress"]["answered"], 1);
821    }
822
823    #[test]
824    fn next_accepts_context_envelope_under_ctx_key() {
825        let spec = json!({
826            "id": "progress-form",
827            "title": "Progress",
828            "version": "1.0",
829            "progress_policy": {
830                "skip_answered": true
831            },
832            "questions": [
833                { "id": "q1", "type": "string", "title": "q1", "required": true },
834                { "id": "q2", "type": "string", "title": "q2", "required": true }
835            ]
836        });
837        let cfg = json!({
838            "form_spec_json": spec.to_string(),
839            "ctx": {
840                "state": {}
841            }
842        });
843        let response = next("progress-form", &cfg.to_string(), r#"{"q1":"done"}"#);
844        let parsed: Value = serde_json::from_str(&response).expect("json");
845        assert_eq!(parsed["status"], "need_input");
846        assert_eq!(parsed["next_question_id"], "q2");
847    }
848
849    #[test]
850    fn apply_store_writes_state_value() {
851        let spec = json!({
852            "id": "store-form",
853            "title": "Store",
854            "version": "1.0",
855            "questions": [
856                { "id": "q1", "type": "string", "title": "q1", "required": true }
857            ],
858            "store": [
859                {
860                    "target": "state",
861                    "path": "/flag",
862                    "value": true
863                }
864            ]
865        });
866        let ctx = json!({
867            "form_spec_json": spec.to_string(),
868            "state": {}
869        });
870        let result = apply_store("store-form", &ctx.to_string(), "{}");
871        let parsed: Value = serde_json::from_str(&result).expect("json");
872        assert_eq!(parsed["state"]["flag"], true);
873    }
874
875    #[test]
876    fn apply_store_writes_secret_when_allowed() {
877        let spec = json!({
878            "id": "store-secret",
879            "title": "Store Secret",
880            "version": "1.0",
881            "questions": [
882                { "id": "q1", "type": "string", "title": "q1", "required": true }
883            ],
884            "store": [
885                {
886                    "target": "secrets",
887                    "path": "/aws/key",
888                    "value": "value"
889                }
890            ],
891            "secrets_policy": {
892                "enabled": true,
893                "read_enabled": true,
894                "write_enabled": true,
895                "allow": ["aws/*"]
896            }
897        });
898        let ctx = json!({
899            "form_spec_json": spec.to_string(),
900            "state": {},
901            "secrets_host_available": true
902        });
903        let result = apply_store("store-secret", &ctx.to_string(), "{}");
904        let parsed: Value = serde_json::from_str(&result).expect("json");
905        assert_eq!(parsed["secrets"]["aws"]["key"], "value");
906    }
907
908    #[test]
909    fn render_text_outputs_summary() {
910        let output = render_text("example-form", "", "{}", "{}");
911        assert!(output.contains("Form:"));
912        assert!(output.contains("Visible questions"));
913    }
914
915    #[test]
916    fn render_json_ui_outputs_json_payload() {
917        let payload = render_json_ui("example-form", "", "{}", r#"{"q1":"value"}"#);
918        let parsed: Value = serde_json::from_str(&payload).expect("json");
919        assert_eq!(parsed["form_id"], "example-form");
920        assert_eq!(parsed["progress"]["total"], 2);
921    }
922
923    #[test]
924    fn render_json_ui_expands_includes_from_registry() {
925        let parent = json!({
926            "id": "parent-form",
927            "title": "Parent",
928            "version": "1.0",
929            "includes": [
930                { "form_ref": "child", "prefix": "child" }
931            ],
932            "questions": [
933                { "id": "root", "type": "string", "title": "Root", "required": true }
934            ]
935        });
936        let child = json!({
937            "id": "child-form",
938            "title": "Child",
939            "version": "1.0",
940            "questions": [
941                { "id": "name", "type": "string", "title": "Name", "required": true }
942            ]
943        });
944        let config = json!({
945            "form_spec_json": parent.to_string(),
946            "include_registry": {
947                "child": child.to_string()
948            }
949        });
950
951        let payload = render_json_ui("parent-form", &config.to_string(), "{}", "{}");
952        let parsed: Value = serde_json::from_str(&payload).expect("json");
953        let questions = parsed["questions"].as_array().expect("questions array");
954        assert!(questions.iter().any(|question| question["id"] == "root"));
955        assert!(
956            questions
957                .iter()
958                .any(|question| question["id"] == "child.name")
959        );
960    }
961
962    #[test]
963    fn render_card_outputs_patch_action() {
964        let payload = render_card("example-form", "", "{}", "{}");
965        let parsed: Value = serde_json::from_str(&payload).expect("json");
966        assert_eq!(parsed["version"], "1.3");
967        let actions = parsed["actions"].as_array().expect("actions");
968        assert_eq!(actions[0]["data"]["qa"]["mode"], "patch");
969    }
970
971    #[test]
972    fn render_card_attaches_i18n_debug_metadata_when_enabled() {
973        let spec = json!({
974            "id": "i18n-card-form",
975            "title": "Card",
976            "version": "1.0",
977            "questions": [
978                {
979                    "id": "name",
980                    "type": "string",
981                    "title": "Name",
982                    "title_i18n": { "key": "name.title" },
983                    "required": true
984                }
985            ]
986        });
987        let config = json!({ "form_spec_json": spec.to_string() });
988        let ctx = json!({
989            "i18n_debug": true,
990            "i18n_resolved": {
991                "name.title": "Localized Name"
992            }
993        });
994        let payload = render_card(
995            "i18n-card-form",
996            &config.to_string(),
997            &ctx.to_string(),
998            "{}",
999        );
1000        let parsed: Value = serde_json::from_str(&payload).expect("json");
1001        assert_eq!(parsed["metadata"]["qa"]["i18n_debug"], true);
1002        let questions = parsed["metadata"]["qa"]["questions"]
1003            .as_array()
1004            .expect("questions metadata");
1005        assert_eq!(questions[0]["id"], "name");
1006        assert_eq!(questions[0]["title_key"], "name.title");
1007    }
1008
1009    #[test]
1010    fn submit_patch_advances_and_updates_store() {
1011        let response = submit_patch("example-form", "", "{}", "{}", "q1", r#""Acme""#);
1012        let parsed: Value = serde_json::from_str(&response).expect("json");
1013        assert_eq!(parsed["status"], "need_input");
1014        assert_eq!(parsed["next_question_id"], "q2");
1015        assert_eq!(parsed["answers"]["q1"], "Acme");
1016        assert_eq!(parsed["store"]["answers"]["q1"], "Acme");
1017    }
1018
1019    #[test]
1020    fn submit_patch_returns_validation_error() {
1021        let response = submit_patch("example-form", "", "{}", "{}", "q1", "true");
1022        let parsed: Value = serde_json::from_str(&response).expect("json");
1023        assert_eq!(parsed["status"], "error");
1024        assert_eq!(parsed["validation"]["errors"][0]["code"], "type_mismatch");
1025    }
1026
1027    #[test]
1028    fn submit_all_completes_with_valid_answers() {
1029        let response = submit_all("example-form", "", "{}", r#"{"q1":"Acme","q2":true}"#);
1030        let parsed: Value = serde_json::from_str(&response).expect("json");
1031        assert_eq!(parsed["status"], "complete");
1032        assert!(parsed["next_question_id"].is_null());
1033        assert_eq!(parsed["answers"]["q2"], true);
1034        assert_eq!(parsed["store"]["answers"]["q2"], true);
1035    }
1036}