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