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 }
1084}
1085
1086fn bootstrap_spec(mode: NormalizedMode) -> ComponentQaSpec {
1087 let (title_key, description_key) = mode_title(mode);
1088 let mut questions = Vec::new();
1089 if mode != NormalizedMode::Remove {
1090 questions.push(bootstrap_path_question());
1091 }
1092
1093 ComponentQaSpec {
1094 mode: mode.to_qa_mode(),
1095 title: I18nText::new(title_key, Some(crate::i18n::t("en", title_key))),
1096 description: Some(I18nText::new(
1097 description_key,
1098 Some(crate::i18n::t("en", description_key)),
1099 )),
1100 questions,
1101 defaults: BTreeMap::new(),
1102 }
1103}
1104
1105fn should_fallback_to_bootstrap(mode: NormalizedMode, err: &ComponentError) -> bool {
1106 if mode == NormalizedMode::Remove {
1107 return false;
1108 }
1109 matches!(
1110 err,
1111 ComponentError::MissingQaFormAssetPath | ComponentError::QaFormRead { .. }
1112 )
1113}
1114
1115fn answered_bootstrap_path(answers: &Value) -> Option<String> {
1116 let raw = answers.get("qa_form_asset_path")?.as_str()?;
1117 let trimmed = raw.trim();
1118 if trimmed.is_empty() {
1119 return None;
1120 }
1121 Some(trimmed.to_string())
1122}
1123
1124fn question_kind(question: &qa_spec::QuestionSpec) -> QuestionKind {
1125 match question.kind {
1126 QuestionType::Boolean => QuestionKind::Bool,
1127 QuestionType::Integer | QuestionType::Number => QuestionKind::Number,
1128 QuestionType::Enum => {
1129 let options = question
1130 .choices
1131 .clone()
1132 .unwrap_or_default()
1133 .into_iter()
1134 .map(|choice| ChoiceOption {
1135 value: choice.clone(),
1136 label: I18nText::new(
1137 format!("qa.field.{}.option.{}", question.id, choice),
1138 Some(choice),
1139 ),
1140 })
1141 .collect();
1142 QuestionKind::Choice { options }
1143 }
1144 QuestionType::List | QuestionType::String => QuestionKind::Text,
1145 }
1146}
1147
1148fn normalize_locale_chain(locale: Option<&str>) -> Vec<String> {
1149 let Some(raw) = locale else {
1150 return vec!["en".to_string()];
1151 };
1152 let normalized = raw.replace('_', "-");
1153 let mut chain = vec![normalized.clone()];
1154 if let Some((base, _)) = normalized.split_once('-') {
1155 chain.push(base.to_string());
1156 }
1157 chain.push("en".to_string());
1158 chain
1159}
1160
1161fn resolve_pack_i18n_text(
1162 form_asset_path: &str,
1163 locale: Option<&str>,
1164 key: &str,
1165 fallback: Option<&str>,
1166) -> Option<String> {
1167 let i18n_dir = infer_i18n_dir_from_form_path(form_asset_path);
1168 for candidate in normalize_locale_chain(locale) {
1169 if let Ok(Some(locale_map)) = load_locale_map(&i18n_dir, &candidate)
1170 && let Some(value) = locale_map.get(key)
1171 {
1172 return Some(value.clone());
1173 }
1174 }
1175 fallback.map(str::to_string)
1176}
1177
1178fn component_qa_spec(
1179 mode: NormalizedMode,
1180 form_id: &str,
1181 config_json: &str,
1182 ctx_json: &str,
1183 answers: &Value,
1184) -> Result<ComponentQaSpec, ComponentError> {
1185 let spec = ensure_form(form_id, config_json)?;
1186 let loaded = load_form_spec_value(config_json)?;
1187 let ctx = parse_runtime_context(ctx_json);
1188 let locale = ctx.get("locale").and_then(Value::as_str);
1189 let visibility = resolve_visibility(&spec, answers, VisibilityMode::Visible);
1190 let (title_key, description_key) = mode_title(mode);
1191 let questions = spec
1192 .questions
1193 .iter()
1194 .filter(|question| visibility.get(&question.id).copied().unwrap_or(false))
1195 .map(|question| {
1196 let label = question
1197 .title_i18n
1198 .as_ref()
1199 .map(|text| {
1200 I18nText::new(
1201 text.key.clone(),
1202 resolve_pack_i18n_text(
1203 &loaded.form_asset_path,
1204 locale,
1205 &text.key,
1206 Some(&question.title),
1207 ),
1208 )
1209 })
1210 .unwrap_or_else(|| {
1211 I18nText::new(
1212 format!("qa.field.{}.label", question.id),
1213 Some(question.title.clone()),
1214 )
1215 });
1216 let help = match (&question.description_i18n, &question.description) {
1217 (Some(text), description) => Some(I18nText::new(
1218 text.key.clone(),
1219 resolve_pack_i18n_text(
1220 &loaded.form_asset_path,
1221 locale,
1222 &text.key,
1223 description.as_deref(),
1224 ),
1225 )),
1226 (None, Some(description)) => Some(I18nText::new(
1227 format!("qa.field.{}.help", question.id),
1228 Some(description.clone()),
1229 )),
1230 (None, None) => None,
1231 };
1232 Question {
1233 id: question.id.clone(),
1234 label,
1235 help,
1236 error: None,
1237 kind: question_kind(question),
1238 required: question.required,
1239 default: None,
1240 }
1241 })
1242 .collect();
1243
1244 Ok(ComponentQaSpec {
1245 mode: mode.to_qa_mode(),
1246 title: I18nText::new(title_key, Some(spec.title)),
1247 description: spec
1248 .description
1249 .map(|description| I18nText::new(description_key, Some(description))),
1250 questions,
1251 defaults: BTreeMap::new(),
1252 })
1253}
1254
1255pub fn qa_spec_json(mode: NormalizedMode, payload: &Value) -> Value {
1256 let form_id = payload_form_id(payload);
1257 let config_json = payload_config_json(payload);
1258 let ctx_json = payload_ctx_json(payload);
1259 let answers = payload_answers(payload);
1260 match component_qa_spec(mode, &form_id, &config_json, &ctx_json, &answers) {
1261 Ok(spec) => serde_json::to_value(spec).unwrap_or_else(|_| json!({})),
1262 Err(err) if should_fallback_to_bootstrap(mode, &err) => {
1263 serde_json::to_value(bootstrap_spec(mode)).unwrap_or_else(|_| json!({}))
1264 }
1265 Err(err) => json!({
1266 "mode": mode.as_str(),
1267 "title": {"key": "qa.error.spec_unavailable", "default": "QA unavailable"},
1268 "description": {"key": "qa.error.spec_unavailable.description", "default": err.to_string()},
1269 "questions": [],
1270 "defaults": {}
1271 }),
1272 }
1273}
1274
1275pub fn i18n_keys() -> Vec<String> {
1276 let mut keys = BTreeSet::new();
1277 for key in crate::i18n::all_keys() {
1278 keys.insert(key);
1279 }
1280 for mode in [
1281 NormalizedMode::Setup,
1282 NormalizedMode::Update,
1283 NormalizedMode::Remove,
1284 ] {
1285 let spec = component_qa_spec(mode, "example-form", "", "{}", &json!({}));
1286 if let Ok(spec) = spec {
1287 for key in spec.i18n_keys() {
1288 keys.insert(key);
1289 }
1290 }
1291 }
1292 keys.into_iter().collect()
1293}
1294
1295pub fn apply_answers(mode: NormalizedMode, payload: &Value) -> Value {
1296 let form_id = payload_form_id(payload);
1297 let config_json = payload_config_json(payload);
1298 let answers = payload_answers(payload);
1299 let current_config = payload
1300 .get("current_config")
1301 .cloned()
1302 .unwrap_or_else(|| json!({}));
1303 let bootstrap_path = answered_bootstrap_path(&answers);
1304
1305 if let Some(qa_form_asset_path) = bootstrap_path {
1306 let mut config = match current_config {
1307 Value::Object(map) => map,
1308 _ => Map::new(),
1309 };
1310 config.insert(
1311 "qa_form_asset_path".to_string(),
1312 Value::String(qa_form_asset_path),
1313 );
1314 return json!({
1315 "ok": true,
1316 "config": config,
1317 "warnings": [],
1318 "errors": [],
1319 "meta": {
1320 "mode": mode.as_str(),
1321 "version": "v1"
1322 },
1323 "audit": {
1324 "reasons": ["qa.apply_answers"],
1325 "timings_ms": {}
1326 }
1327 });
1328 }
1329
1330 match ensure_form(&form_id, &config_json) {
1331 Ok(spec) => {
1332 let validation = validate(&spec, &answers);
1333 if !validation.valid {
1334 return json!({
1335 "ok": false,
1336 "warnings": [],
1337 "errors": validation.errors,
1338 "meta": {
1339 "mode": mode.as_str(),
1340 "version": "v1"
1341 }
1342 });
1343 }
1344
1345 let mut config = match current_config {
1346 Value::Object(map) => map,
1347 _ => Map::new(),
1348 };
1349 if let Value::Object(answers) = answers {
1350 for (key, value) in answers {
1351 config.insert(key, value);
1352 }
1353 }
1354 if mode == NormalizedMode::Remove {
1355 config.insert("enabled".to_string(), Value::Bool(false));
1356 }
1357
1358 json!({
1359 "ok": true,
1360 "config": config,
1361 "warnings": [],
1362 "errors": [],
1363 "meta": {
1364 "mode": mode.as_str(),
1365 "version": "v1"
1366 },
1367 "audit": {
1368 "reasons": ["qa.apply_answers"],
1369 "timings_ms": {}
1370 }
1371 })
1372 }
1373 Err(err) => json!({
1374 "ok": false,
1375 "warnings": [],
1376 "errors": [{"key":"qa.error.spec_unavailable","message": err.to_string()}],
1377 "meta": {
1378 "mode": mode.as_str(),
1379 "version": "v1"
1380 }
1381 }),
1382 }
1383}