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