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