Skip to main content

greentic_qa_lib/
lib.rs

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}