Skip to main content

component_qa/
qa.rs

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