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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum RenderStatus {
13 NeedInput,
15 Complete,
17 Error,
19}
20
21impl RenderStatus {
22 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#[derive(Debug, Clone)]
34pub struct RenderProgress {
35 pub answered: usize,
36 pub total: usize,
37}
38
39#[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#[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
68pub 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
121pub 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
182pub 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
239pub 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}