Skip to main content

qa_spec/
render.rs

1use serde_json::{Map, Value, json};
2
3use crate::{
4    answers_schema,
5    computed::apply_computed_answers,
6    progress::{ProgressContext, next_question},
7    spec::{
8        form::FormSpec,
9        question::{ListSpec, QuestionType},
10    },
11    visibility::{VisibilityMode, resolve_visibility},
12};
13
14/// Status labels returned by the renderers.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum RenderStatus {
17    /// More input is required.
18    NeedInput,
19    /// All visible questions are filled.
20    Complete,
21    /// Something unexpected occurred.
22    Error,
23}
24
25impl RenderStatus {
26    /// Human-friendly label that matches the renderer requirements.
27    pub fn as_str(&self) -> &'static str {
28        match self {
29            RenderStatus::NeedInput => "need_input",
30            RenderStatus::Complete => "complete",
31            RenderStatus::Error => "error",
32        }
33    }
34}
35
36/// Progress counters exposed to renderers.
37#[derive(Debug, Clone)]
38pub struct RenderProgress {
39    pub answered: usize,
40    pub total: usize,
41}
42
43/// Describes a single question for render outputs.
44#[derive(Debug, Clone)]
45pub struct RenderQuestion {
46    pub id: String,
47    pub title: String,
48    pub description: Option<String>,
49    pub kind: QuestionType,
50    pub required: bool,
51    pub default: Option<String>,
52    pub secret: bool,
53    pub visible: bool,
54    pub current_value: Option<Value>,
55    pub choices: Option<Vec<String>>,
56    pub list: Option<ListSpec>,
57}
58
59/// Collected payload used by both text and JSON renderers.
60#[derive(Debug, Clone)]
61pub struct RenderPayload {
62    pub form_id: String,
63    pub form_title: String,
64    pub form_version: String,
65    pub status: RenderStatus,
66    pub next_question_id: Option<String>,
67    pub progress: RenderProgress,
68    pub help: Option<String>,
69    pub questions: Vec<RenderQuestion>,
70    pub schema: Value,
71}
72
73/// Build the renderer payload from the specification, context, and answers.
74pub fn build_render_payload(spec: &FormSpec, ctx: &Value, answers: &Value) -> RenderPayload {
75    let computed_answers = apply_computed_answers(spec, answers);
76    let visibility = resolve_visibility(spec, &computed_answers, VisibilityMode::Visible);
77    let progress_ctx = ProgressContext::new(computed_answers.clone(), ctx);
78    let next_question_id = next_question(spec, &progress_ctx, &visibility);
79
80    let answered = progress_ctx.answered_count(spec, &visibility);
81    let total = visibility.values().filter(|visible| **visible).count();
82
83    let questions = spec
84        .questions
85        .iter()
86        .map(|question| RenderQuestion {
87            id: question.id.clone(),
88            title: question.title.clone(),
89            description: question.description.clone(),
90            kind: question.kind,
91            required: question.required,
92            default: question.default_value.clone(),
93            secret: question.secret,
94            visible: visibility.get(&question.id).copied().unwrap_or(true),
95            current_value: computed_answers.get(&question.id).cloned(),
96            choices: question.choices.clone(),
97            list: question.list.clone(),
98        })
99        .collect::<Vec<_>>();
100
101    let help = spec
102        .presentation
103        .as_ref()
104        .and_then(|presentation| presentation.intro.clone())
105        .or_else(|| spec.description.clone());
106
107    let schema = answers_schema::generate(spec, &visibility);
108
109    let status = if next_question_id.is_some() {
110        RenderStatus::NeedInput
111    } else {
112        RenderStatus::Complete
113    };
114
115    RenderPayload {
116        form_id: spec.id.clone(),
117        form_title: spec.title.clone(),
118        form_version: spec.version.clone(),
119        status,
120        next_question_id,
121        progress: RenderProgress { answered, total },
122        help,
123        questions,
124        schema,
125    }
126}
127
128/// Render the payload as a structured JSON-friendly value.
129pub fn render_json_ui(payload: &RenderPayload) -> Value {
130    let questions = payload
131        .questions
132        .iter()
133        .map(|question| {
134            let mut map = Map::new();
135            map.insert("id".into(), Value::String(question.id.clone()));
136            map.insert("title".into(), Value::String(question.title.clone()));
137            map.insert(
138                "description".into(),
139                question
140                    .description
141                    .clone()
142                    .map(Value::String)
143                    .unwrap_or(Value::Null),
144            );
145            map.insert(
146                "type".into(),
147                Value::String(question_type_label(question.kind).to_string()),
148            );
149            map.insert("required".into(), Value::Bool(question.required));
150            if let Some(default) = &question.default {
151                map.insert("default".into(), Value::String(default.clone()));
152            }
153            if let Some(current_value) = &question.current_value {
154                map.insert("current_value".into(), current_value.clone());
155            }
156            if let Some(choices) = &question.choices {
157                map.insert(
158                    "choices".into(),
159                    Value::Array(
160                        choices
161                            .iter()
162                            .map(|choice| Value::String(choice.clone()))
163                            .collect(),
164                    ),
165                );
166            }
167            map.insert("visible".into(), Value::Bool(question.visible));
168            map.insert("secret".into(), Value::Bool(question.secret));
169            if let Some(list) = &question.list
170                && let Ok(list_value) = serde_json::to_value(list)
171            {
172                map.insert("list".into(), list_value);
173            }
174            Value::Object(map)
175        })
176        .collect::<Vec<_>>();
177
178    json!({
179        "form_id": payload.form_id,
180        "form_title": payload.form_title,
181        "form_version": payload.form_version,
182        "status": payload.status.as_str(),
183        "next_question_id": payload.next_question_id,
184        "progress": {
185            "answered": payload.progress.answered,
186            "total": payload.progress.total,
187        },
188        "help": payload.help,
189        "questions": questions,
190        "schema": payload.schema,
191    })
192}
193
194/// Render the payload as human-friendly text.
195pub fn render_text(payload: &RenderPayload) -> String {
196    let mut lines = Vec::new();
197    lines.push(format!(
198        "Form: {} ({})",
199        payload.form_title, payload.form_id
200    ));
201    lines.push(format!(
202        "Status: {} ({}/{})",
203        payload.status.as_str(),
204        payload.progress.answered,
205        payload.progress.total
206    ));
207    if let Some(help) = &payload.help {
208        lines.push(format!("Help: {}", help));
209    }
210
211    if let Some(next_question) = &payload.next_question_id {
212        lines.push(format!("Next question: {}", next_question));
213        if let Some(question) = payload
214            .questions
215            .iter()
216            .find(|question| &question.id == next_question)
217        {
218            lines.push(format!("  Title: {}", question.title));
219            if let Some(description) = &question.description {
220                lines.push(format!("  Description: {}", description));
221            }
222            if question.required {
223                lines.push("  Required: yes".to_string());
224            }
225            if let Some(default) = &question.default {
226                lines.push(format!("  Default: {}", default));
227            }
228            if let Some(value) = &question.current_value {
229                lines.push(format!("  Current value: {}", value_to_display(value)));
230            }
231        }
232    } else {
233        lines.push("All visible questions are answered.".to_string());
234    }
235
236    lines.push("Visible questions:".to_string());
237    for question in payload.questions.iter().filter(|question| question.visible) {
238        let mut entry = format!(" - {} ({})", question.id, question.title);
239        if question.required {
240            entry.push_str(" [required]");
241        }
242        if let Some(current_value) = &question.current_value {
243            entry.push_str(&format!(" = {}", value_to_display(current_value)));
244        }
245        lines.push(entry);
246    }
247
248    lines.join("\n")
249}
250
251/// Render the payload as an Adaptive Card v1.3 transport.
252pub fn render_card(payload: &RenderPayload) -> Value {
253    let mut body = Vec::new();
254
255    body.push(json!({
256        "type": "TextBlock",
257        "text": payload.form_title,
258        "weight": "Bolder",
259        "size": "Large",
260        "wrap": true,
261    }));
262
263    if let Some(help) = &payload.help {
264        body.push(json!({
265            "type": "TextBlock",
266            "text": help,
267            "wrap": true,
268        }));
269    }
270
271    body.push(json!({
272        "type": "FactSet",
273        "facts": [
274            { "title": "Answered", "value": payload.progress.answered.to_string() },
275            { "title": "Total", "value": payload.progress.total.to_string() }
276        ]
277    }));
278
279    let mut actions = Vec::new();
280
281    if let Some(question_id) = &payload.next_question_id {
282        if let Some(question) = payload
283            .questions
284            .iter()
285            .find(|question| &question.id == question_id)
286        {
287            let mut items = Vec::new();
288            items.push(json!({
289                "type": "TextBlock",
290                "text": question.title,
291                "weight": "Bolder",
292                "wrap": true,
293            }));
294            if let Some(description) = &question.description {
295                items.push(json!({
296                    "type": "TextBlock",
297                    "text": description,
298                    "wrap": true,
299                    "spacing": "Small",
300                }));
301            }
302            items.push(question_input(question));
303
304            body.push(json!({
305                "type": "Container",
306                "items": items,
307            }));
308
309            actions.push(json!({
310                "type": "Action.Submit",
311                "title": "Next ➡️",
312                "data": {
313                    "qa": {
314                        "formId": payload.form_id,
315                        "mode": "patch",
316                        "questionId": question.id,
317                        "field": "answer"
318                    }
319                }
320            }));
321        }
322    } else {
323        body.push(json!({
324            "type": "TextBlock",
325            "text": "All visible questions are answered.",
326            "wrap": true,
327        }));
328    }
329
330    json!({
331        "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
332        "type": "AdaptiveCard",
333        "version": "1.3",
334        "body": body,
335        "actions": actions,
336    })
337}
338
339fn question_input(question: &RenderQuestion) -> Value {
340    match question.kind {
341        QuestionType::String | QuestionType::Integer | QuestionType::Number => {
342            let mut map = Map::new();
343            map.insert("type".into(), Value::String("Input.Text".into()));
344            map.insert("id".into(), Value::String(question.id.clone()));
345            map.insert("isRequired".into(), Value::Bool(question.required));
346            if let Some(value) = &question.current_value {
347                map.insert("value".into(), Value::String(value_to_display(value)));
348            }
349            Value::Object(map)
350        }
351        QuestionType::Boolean => {
352            let mut map = Map::new();
353            map.insert("type".into(), Value::String("Input.Toggle".into()));
354            map.insert("id".into(), Value::String(question.id.clone()));
355            map.insert("title".into(), Value::String(question.title.clone()));
356            map.insert("isRequired".into(), Value::Bool(question.required));
357            map.insert("valueOn".into(), Value::String("true".into()));
358            map.insert("valueOff".into(), Value::String("false".into()));
359            if let Some(value) = &question.current_value {
360                if value.as_bool() == Some(true) {
361                    map.insert("value".into(), Value::String("true".into()));
362                } else {
363                    map.insert("value".into(), Value::String("false".into()));
364                }
365            }
366            Value::Object(map)
367        }
368        QuestionType::Enum => {
369            let mut map = Map::new();
370            map.insert("type".into(), Value::String("Input.ChoiceSet".into()));
371            map.insert("id".into(), Value::String(question.id.clone()));
372            map.insert("style".into(), Value::String("compact".into()));
373            map.insert("isRequired".into(), Value::Bool(question.required));
374            let choices = question
375                .choices
376                .clone()
377                .unwrap_or_default()
378                .into_iter()
379                .map(|choice| {
380                    json!({
381                        "title": choice,
382                        "value": choice,
383                    })
384                })
385                .collect::<Vec<_>>();
386            map.insert("choices".into(), Value::Array(choices));
387            if let Some(value) = &question.current_value {
388                map.insert("value".into(), Value::String(value_to_display(value)));
389            }
390            Value::Object(map)
391        }
392        QuestionType::List => {
393            let mut map = Map::new();
394            map.insert("type".into(), Value::String("TextBlock".into()));
395            map.insert(
396                "text".into(),
397                Value::String(format!(
398                    "List group '{}' ({} entries)",
399                    question.title,
400                    question
401                        .current_value
402                        .as_ref()
403                        .and_then(Value::as_array)
404                        .map(|entries| entries.len())
405                        .unwrap_or_default()
406                )),
407            );
408            map.insert("wrap".into(), Value::Bool(true));
409            Value::Object(map)
410        }
411    }
412}
413
414fn question_type_label(kind: QuestionType) -> &'static str {
415    match kind {
416        QuestionType::String => "string",
417        QuestionType::Boolean => "boolean",
418        QuestionType::Integer => "integer",
419        QuestionType::Number => "number",
420        QuestionType::Enum => "enum",
421        QuestionType::List => "list",
422    }
423}
424
425fn value_to_display(value: &Value) -> String {
426    match value {
427        Value::String(text) => text.clone(),
428        Value::Bool(flag) => flag.to_string(),
429        Value::Number(num) => num.to_string(),
430        other => other.to_string(),
431    }
432}