Skip to main content

component_qa/
qa.rs

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