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 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 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 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}