Skip to main content

qa_spec/
render.rs

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