1use std::collections::BTreeMap;
2
3use component_qa::{render_card, render_json_ui, render_text, submit_patch};
4use qa_spec::AnswerSet;
5use serde_json::{Map, Value, json};
6use tempfile::TempDir;
7use thiserror::Error;
8
9pub use qa_spec::i18n::ResolvedI18nMap;
10
11#[derive(Clone, Debug)]
12pub enum WizardFrontend {
13 Text,
14 JsonUi,
15 Card,
16}
17
18#[derive(Clone, Debug, Default)]
19pub struct I18nConfig {
20 pub locale: Option<String>,
21 pub resolved: Option<ResolvedI18nMap>,
22 pub debug: bool,
23}
24
25#[derive(Clone, Debug)]
26pub struct WizardRunConfig {
27 pub spec_json: String,
28 pub initial_answers_json: Option<String>,
29 pub frontend: WizardFrontend,
30 pub i18n: I18nConfig,
31 pub verbose: bool,
32}
33
34#[derive(Clone, Debug)]
35pub struct WizardRunResult {
36 pub answer_set: AnswerSet,
37 pub answer_set_cbor_hex: String,
38}
39
40#[derive(Clone, Debug)]
41pub struct ValidationOrProgress {
42 pub status: String,
43 pub response_json: String,
44}
45
46#[derive(Debug, Error)]
47pub enum QaLibError {
48 #[error("json parse error: {0}")]
49 Json(#[from] serde_json::Error),
50 #[error("wizard needs interaction")]
51 NeedsInteraction,
52 #[error("component error: {0}")]
53 Component(String),
54 #[error("invalid patch: {0}")]
55 InvalidPatch(String),
56 #[error("wizard payload missing field '{0}'")]
57 MissingField(String),
58 #[error("validation failed: {0}")]
59 Validation(String),
60}
61
62pub struct QaRunner;
63
64pub type AnswerProvider = dyn FnMut(&str, &Value) -> Result<Value, QaLibError>;
65
66impl QaRunner {
67 pub fn run_wizard(
68 config: WizardRunConfig,
69 mut answer_provider: Option<&mut AnswerProvider>,
70 ) -> Result<WizardRunResult, QaLibError> {
71 let mut driver = WizardDriver::new(config)?;
72
73 loop {
74 let _payload = driver.next_payload_json()?;
75 if driver.is_complete() {
76 break;
77 }
78
79 let ui_raw = driver
80 .last_ui_json()
81 .ok_or_else(|| QaLibError::MissingField("last_ui_json".into()))?
82 .to_string();
83 let ui: Value = serde_json::from_str(&ui_raw)?;
84 let question_id = ui
85 .get("next_question_id")
86 .and_then(Value::as_str)
87 .ok_or_else(|| QaLibError::MissingField("next_question_id".into()))?
88 .to_string();
89 let question = find_question(&ui, &question_id)?;
90
91 let provider = answer_provider
92 .as_mut()
93 .ok_or(QaLibError::NeedsInteraction)?;
94 let answer = provider(&question_id, &question)?;
95 let patch = json!({ question_id: answer }).to_string();
96 let submit = driver.submit_patch_json(&patch)?;
97 if submit.status == "error" {
98 return Err(QaLibError::Validation(submit.response_json));
99 }
100 }
101
102 driver.finish()
103 }
104
105 pub fn run_wizard_non_interactive(
106 config: WizardRunConfig,
107 ) -> Result<WizardRunResult, QaLibError> {
108 Self::run_wizard(config, None)
109 }
110}
111
112pub struct WizardDriver {
113 form_id: String,
114 spec_version: String,
115 config_json: String,
116 ctx_json: String,
117 frontend: WizardFrontend,
118 answers: Value,
119 complete: bool,
120 last_ui_json: Option<String>,
121 _asset_dir: TempDir,
122}
123
124impl WizardDriver {
125 pub fn new(config: WizardRunConfig) -> Result<Self, QaLibError> {
126 let spec_value: Value = serde_json::from_str(&config.spec_json)?;
127 let form_id = spec_value
128 .get("id")
129 .and_then(Value::as_str)
130 .ok_or_else(|| QaLibError::MissingField("id".into()))?
131 .to_string();
132 let spec_version = spec_value
133 .get("version")
134 .and_then(Value::as_str)
135 .unwrap_or("0.0.0")
136 .to_string();
137
138 let answers = if let Some(raw) = config.initial_answers_json {
139 let parsed: Value = serde_json::from_str(&raw)?;
140 normalize_answers(parsed)
141 } else {
142 Value::Object(Map::new())
143 };
144 let (asset_dir, form_asset_path) = materialize_spec_assets(&spec_value)?;
145
146 Ok(Self {
147 form_id,
148 spec_version,
149 config_json: json!({ "qa_form_asset_path": form_asset_path }).to_string(),
150 ctx_json: build_ctx_json(&config.i18n),
151 frontend: config.frontend,
152 answers,
153 complete: false,
154 last_ui_json: None,
155 _asset_dir: asset_dir,
156 })
157 }
158
159 pub fn next_payload_json(&mut self) -> Result<String, QaLibError> {
160 let answers_json = self.answers.to_string();
161
162 let ui_raw = render_json_ui(
163 &self.form_id,
164 &self.config_json,
165 &self.ctx_json,
166 &answers_json,
167 );
168 let ui_value = parse_component_result(&ui_raw)?;
169 self.complete = ui_value
170 .get("status")
171 .and_then(Value::as_str)
172 .is_some_and(|status| status == "complete");
173 self.last_ui_json = Some(ui_raw.clone());
174
175 match self.frontend {
176 WizardFrontend::JsonUi => Ok(ui_raw),
177 WizardFrontend::Card => {
178 let card_raw = render_card(
179 &self.form_id,
180 &self.config_json,
181 &self.ctx_json,
182 &answers_json,
183 );
184 parse_component_result(&card_raw)?;
185 Ok(card_raw)
186 }
187 WizardFrontend::Text => {
188 let text = render_text(
189 &self.form_id,
190 &self.config_json,
191 &self.ctx_json,
192 &answers_json,
193 );
194 let wrapped = json!({
195 "text": text,
196 "status": ui_value.get("status").cloned().unwrap_or(Value::String("need_input".into())),
197 "next_question_id": ui_value.get("next_question_id").cloned().unwrap_or(Value::Null),
198 "progress": ui_value.get("progress").cloned().unwrap_or_else(|| json!({"answered":0,"total":0}))
199 });
200 Ok(wrapped.to_string())
201 }
202 }
203 }
204
205 pub fn submit_patch_json(
206 &mut self,
207 patch_json: &str,
208 ) -> Result<ValidationOrProgress, QaLibError> {
209 let patch_value: Value = serde_json::from_str(patch_json)?;
210 let patch_object = patch_value.as_object().ok_or_else(|| {
211 QaLibError::InvalidPatch(
212 "patch_json must be a JSON object map of question_id -> value".into(),
213 )
214 })?;
215 if patch_object.is_empty() {
216 return Err(QaLibError::InvalidPatch(
217 "patch_json cannot be empty".into(),
218 ));
219 }
220
221 let mut last_value = Value::Null;
222
223 for (question_id, value) in patch_object {
224 let value_json = serde_json::to_string(value)?;
225 let submit_raw = submit_patch(
226 &self.form_id,
227 &self.config_json,
228 &self.ctx_json,
229 &self.answers.to_string(),
230 question_id,
231 &value_json,
232 );
233 let submit_value = parse_component_result(&submit_raw)?;
234 if let Some(answers) = submit_value.get("answers") {
235 self.answers = normalize_answers(answers.clone());
236 }
237 if submit_value
238 .get("status")
239 .and_then(Value::as_str)
240 .is_some_and(|status| status == "complete")
241 {
242 self.complete = true;
243 }
244 last_value = submit_value;
245 }
246
247 let status = last_value
248 .get("status")
249 .and_then(Value::as_str)
250 .unwrap_or("need_input")
251 .to_string();
252
253 Ok(ValidationOrProgress {
254 status,
255 response_json: serde_json::to_string(&last_value)?,
256 })
257 }
258
259 pub fn is_complete(&self) -> bool {
260 self.complete
261 }
262
263 pub fn last_ui_json(&self) -> Option<&str> {
264 self.last_ui_json.as_deref()
265 }
266
267 pub fn finish(self) -> Result<WizardRunResult, QaLibError> {
268 if !self.complete {
269 return Err(QaLibError::NeedsInteraction);
270 }
271
272 let answer_set = AnswerSet {
273 form_id: self.form_id,
274 spec_version: self.spec_version,
275 answers: self.answers,
276 meta: None,
277 };
278
279 let cbor = answer_set
280 .to_cbor()
281 .map_err(|err| QaLibError::Component(err.to_string()))?;
282
283 Ok(WizardRunResult {
284 answer_set,
285 answer_set_cbor_hex: encode_hex(&cbor),
286 })
287 }
288}
289
290fn build_ctx_json(i18n: &I18nConfig) -> String {
291 let mut map = Map::new();
292 if let Some(locale) = &i18n.locale {
293 map.insert("locale".into(), Value::String(locale.clone()));
294 }
295 if let Some(resolved) = &i18n.resolved
296 && let Ok(value) = serde_json::to_value(resolved)
297 {
298 map.insert("i18n_resolved".into(), value);
299 }
300 if i18n.debug {
301 map.insert("i18n_debug".into(), Value::Bool(true));
302 map.insert("debug_i18n".into(), Value::Bool(true));
303 }
304 Value::Object(map).to_string()
305}
306
307fn parse_component_result(raw: &str) -> Result<Value, QaLibError> {
308 let value: Value = serde_json::from_str(raw)?;
309 if let Some(error) = value.get("error").and_then(Value::as_str) {
310 Err(QaLibError::Component(error.to_string()))
311 } else {
312 Ok(value)
313 }
314}
315
316fn normalize_answers(value: Value) -> Value {
317 if value.is_object() {
318 value
319 } else {
320 Value::Object(Map::new())
321 }
322}
323
324fn materialize_spec_assets(spec_value: &Value) -> Result<(TempDir, String), QaLibError> {
325 let temp_dir = TempDir::new().map_err(|err| QaLibError::Component(err.to_string()))?;
326 let forms_dir = temp_dir.path().join("forms");
327 let i18n_dir = temp_dir.path().join("i18n");
328 std::fs::create_dir_all(&forms_dir).map_err(|err| QaLibError::Component(err.to_string()))?;
329 std::fs::create_dir_all(&i18n_dir).map_err(|err| QaLibError::Component(err.to_string()))?;
330
331 let form_file = forms_dir.join("wizard.form.json");
332 let form_contents = serde_json::to_string_pretty(spec_value)?;
333 std::fs::write(&form_file, form_contents)
334 .map_err(|err| QaLibError::Component(err.to_string()))?;
335
336 let mut en_map = BTreeMap::new();
337 collect_i18n_defaults(spec_value, &mut en_map);
338 let en_file = i18n_dir.join("en.json");
339 let en_contents = serde_json::to_string_pretty(&en_map)?;
340 std::fs::write(en_file, en_contents).map_err(|err| QaLibError::Component(err.to_string()))?;
341
342 Ok((temp_dir, form_file.to_string_lossy().to_string()))
343}
344
345fn collect_i18n_defaults(spec_value: &Value, en_map: &mut BTreeMap<String, String>) {
346 for question in spec_value
347 .get("questions")
348 .and_then(Value::as_array)
349 .cloned()
350 .unwrap_or_default()
351 {
352 collect_question_i18n_defaults(&question, en_map);
353 }
354}
355
356fn collect_question_i18n_defaults(question: &Value, en_map: &mut BTreeMap<String, String>) {
357 if let Some(key) = question
358 .get("title_i18n")
359 .and_then(|value| value.get("key"))
360 .and_then(Value::as_str)
361 {
362 let fallback = question
363 .get("title")
364 .and_then(Value::as_str)
365 .unwrap_or(key)
366 .to_string();
367 en_map.entry(key.to_string()).or_insert(fallback);
368 }
369 if let Some(key) = question
370 .get("description_i18n")
371 .and_then(|value| value.get("key"))
372 .and_then(Value::as_str)
373 {
374 let fallback = question
375 .get("description")
376 .and_then(Value::as_str)
377 .unwrap_or(key)
378 .to_string();
379 en_map.entry(key.to_string()).or_insert(fallback);
380 }
381
382 if let Some(fields) = question
383 .get("list")
384 .and_then(|list| list.get("fields"))
385 .and_then(Value::as_array)
386 {
387 for field in fields {
388 collect_question_i18n_defaults(field, en_map);
389 }
390 }
391}
392
393fn find_question(ui: &Value, question_id: &str) -> Result<Value, QaLibError> {
394 let question = ui
395 .get("questions")
396 .and_then(Value::as_array)
397 .and_then(|questions| {
398 questions
399 .iter()
400 .find(|question| question.get("id").and_then(Value::as_str) == Some(question_id))
401 .cloned()
402 })
403 .ok_or_else(|| QaLibError::MissingField(format!("questions[{}]", question_id)))?;
404 Ok(question)
405}
406
407fn encode_hex(bytes: &[u8]) -> String {
408 let mut out = String::with_capacity(bytes.len() * 2);
409 for byte in bytes {
410 use std::fmt::Write as _;
411 let _ = write!(&mut out, "{:02x}", byte);
412 }
413 out
414}