1use qa_spec::FormSpec;
7use qa_spec::spec::flow::{
8 CardMode, DecisionCase, DecisionStep, MessageStep, QAFlowSpec, QuestionStep, StepId, StepSpec,
9};
10use std::collections::BTreeMap;
11
12fn sid(s: &str) -> StepId {
13 s.to_string()
14}
15
16pub fn build_qa_flow(form_spec: &FormSpec) -> QAFlowSpec {
25 let mut steps = BTreeMap::new();
26 let mut step_order: Vec<StepId> = Vec::new();
27
28 let welcome_id = sid("welcome");
30 steps.insert(
31 welcome_id.clone(),
32 StepSpec::Message(MessageStep {
33 mode: CardMode::Text,
34 template: form_spec.title.clone(),
35 next: None,
36 }),
37 );
38 step_order.push(welcome_id.clone());
39
40 for (idx, question) in form_spec.questions.iter().enumerate() {
41 if question.id.is_empty() {
42 continue;
43 }
44
45 let q_step_id = sid(&format!("q_{}", question.id));
46
47 if let Some(ref expr) = question.visible_if {
48 let decision_id = sid(&format!("decide_{}", question.id));
49 let skip_target = next_step_id(form_spec, idx + 1);
50
51 steps.insert(
52 decision_id.clone(),
53 StepSpec::Decision(DecisionStep {
54 cases: vec![DecisionCase {
55 if_expr: expr.clone(),
56 goto: q_step_id.clone(),
57 }],
58 default_goto: Some(skip_target),
59 }),
60 );
61 step_order.push(decision_id);
62 }
63
64 let next = next_step_id(form_spec, idx + 1);
65 steps.insert(
66 q_step_id.clone(),
67 StepSpec::Question(QuestionStep {
68 question_id: question.id.clone(),
69 next: Some(next),
70 }),
71 );
72 step_order.push(q_step_id);
73 }
74
75 let end_id = sid("end");
76 steps.insert(end_id.clone(), StepSpec::End);
77 step_order.push(end_id);
78
79 if step_order.len() > 2
81 && let Some(StepSpec::Message(msg)) = steps.get_mut(&welcome_id)
82 {
83 msg.next = Some(step_order[1].clone());
84 }
85
86 QAFlowSpec {
87 id: form_spec.id.clone(),
88 title: form_spec.title.clone(),
89 version: form_spec.version.clone(),
90 entry: welcome_id,
91 steps,
92 policies: None,
93 }
94}
95
96fn next_step_id(form_spec: &FormSpec, after_idx: usize) -> StepId {
97 for question in form_spec.questions.iter().skip(after_idx) {
98 if question.id.is_empty() {
99 continue;
100 }
101 if question.visible_if.is_some() {
102 return sid(&format!("decide_{}", question.id));
103 }
104 return sid(&format!("q_{}", question.id));
105 }
106 sid("end")
107}
108
109pub fn build_sectioned_flow(form_spec: &FormSpec, sections: &[FlowSection]) -> QAFlowSpec {
112 let mut steps = BTreeMap::new();
113 let mut step_chain: Vec<StepId> = Vec::new();
114
115 for (sec_idx, section) in sections.iter().enumerate() {
116 let header_id = sid(&format!("section_{sec_idx}"));
117 steps.insert(
118 header_id.clone(),
119 StepSpec::Message(MessageStep {
120 mode: CardMode::Text,
121 template: section.title.clone(),
122 next: None,
123 }),
124 );
125 step_chain.push(header_id);
126
127 for qid in §ion.question_ids {
128 let Some(question) = form_spec.questions.iter().find(|q| &q.id == qid) else {
129 continue;
130 };
131 let q_step_id = sid(&format!("q_{qid}"));
132
133 if let Some(ref expr) = question.visible_if {
134 let decision_id = sid(&format!("decide_{qid}"));
135 steps.insert(
136 decision_id.clone(),
137 StepSpec::Decision(DecisionStep {
138 cases: vec![DecisionCase {
139 if_expr: expr.clone(),
140 goto: q_step_id.clone(),
141 }],
142 default_goto: None,
143 }),
144 );
145 step_chain.push(decision_id);
146 }
147
148 steps.insert(
149 q_step_id.clone(),
150 StepSpec::Question(QuestionStep {
151 question_id: question.id.clone(),
152 next: None,
153 }),
154 );
155 step_chain.push(q_step_id);
156 }
157 }
158
159 let end_id = sid("end");
160 steps.insert(end_id.clone(), StepSpec::End);
161 step_chain.push(end_id);
162
163 for i in 0..step_chain.len().saturating_sub(1) {
165 let next = step_chain[i + 1].clone();
166 match steps.get_mut(&step_chain[i]) {
167 Some(StepSpec::Message(msg)) => msg.next = Some(next),
168 Some(StepSpec::Question(q)) => q.next = Some(next),
169 Some(StepSpec::Decision(d)) if d.default_goto.is_none() => {
170 d.default_goto = Some(next);
171 }
172 _ => {}
173 }
174 }
175
176 let entry = step_chain.first().cloned().unwrap_or_else(|| sid("end"));
177
178 QAFlowSpec {
179 id: form_spec.id.clone(),
180 title: form_spec.title.clone(),
181 version: form_spec.version.clone(),
182 entry,
183 steps,
184 policies: None,
185 }
186}
187
188#[derive(Clone, Debug)]
190pub struct FlowSection {
191 pub title: String,
192 pub question_ids: Vec<String>,
193}
194
195pub fn auto_sections(form_spec: &FormSpec) -> Vec<FlowSection> {
198 let mut sections: Vec<FlowSection> = Vec::new();
199
200 for question in &form_spec.questions {
201 if question.id.is_empty() {
202 continue;
203 }
204 let prefix = question
205 .id
206 .split('_')
207 .next()
208 .unwrap_or(&question.id)
209 .to_string();
210
211 if let Some(section) = sections.last_mut()
212 && section.title == prefix
213 {
214 section.question_ids.push(question.id.clone());
215 continue;
216 }
217
218 sections.push(FlowSection {
219 title: prefix,
220 question_ids: vec![question.id.clone()],
221 });
222 }
223
224 sections
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230 use qa_spec::{Expr, QuestionSpec, QuestionType};
231
232 fn sample_form_spec() -> FormSpec {
233 FormSpec {
234 id: "test".into(),
235 title: "Test Setup".into(),
236 version: "1.0.0".into(),
237 description: None,
238 presentation: None,
239 progress_policy: None,
240 secrets_policy: None,
241 store: vec![],
242 validations: vec![],
243 includes: vec![],
244 questions: vec![
245 QuestionSpec {
246 id: "auth_enabled".into(),
247 kind: QuestionType::Boolean,
248 title: "Enable auth?".into(),
249 title_i18n: None,
250 description: None,
251 description_i18n: None,
252 required: true,
253 choices: None,
254 default_value: None,
255 secret: false,
256 visible_if: None,
257 constraint: None,
258 list: None,
259 computed: None,
260 policy: Default::default(),
261 computed_overridable: false,
262 },
263 QuestionSpec {
264 id: "auth_token".into(),
265 kind: QuestionType::String,
266 title: "Auth token".into(),
267 title_i18n: None,
268 description: None,
269 description_i18n: None,
270 required: true,
271 choices: None,
272 default_value: None,
273 secret: true,
274 visible_if: Some(Expr::Answer {
275 path: "auth_enabled".to_string(),
276 }),
277 constraint: None,
278 list: None,
279 computed: None,
280 policy: Default::default(),
281 computed_overridable: false,
282 },
283 QuestionSpec {
284 id: "url".into(),
285 kind: QuestionType::String,
286 title: "API URL".into(),
287 title_i18n: None,
288 description: None,
289 description_i18n: None,
290 required: true,
291 choices: None,
292 default_value: None,
293 secret: false,
294 visible_if: None,
295 constraint: None,
296 list: None,
297 computed: None,
298 policy: Default::default(),
299 computed_overridable: false,
300 },
301 ],
302 }
303 }
304
305 #[test]
306 fn build_flow_creates_decision_gate_for_visible_if() {
307 let spec = sample_form_spec();
308 let flow = build_qa_flow(&spec);
309
310 assert_eq!(flow.entry, "welcome");
311 assert!(flow.steps.contains_key("decide_auth_token"));
312 assert!(flow.steps.contains_key("q_auth_token"));
313 assert!(flow.steps.contains_key("q_auth_enabled"));
314 assert!(flow.steps.contains_key("q_url"));
315 assert!(flow.steps.contains_key("end"));
316
317 match flow.steps.get("decide_auth_token") {
318 Some(StepSpec::Decision(d)) => {
319 assert_eq!(d.cases.len(), 1);
320 assert_eq!(d.cases[0].goto, "q_auth_token");
321 assert_eq!(d.default_goto, Some("q_url".to_string()));
322 }
323 other => panic!("expected Decision, got {other:?}"),
324 }
325 }
326
327 #[test]
328 fn build_flow_no_decision_for_unconditional() {
329 let spec = sample_form_spec();
330 let flow = build_qa_flow(&spec);
331
332 assert!(!flow.steps.contains_key("decide_auth_enabled"));
333 assert!(!flow.steps.contains_key("decide_url"));
334 }
335
336 #[test]
337 fn auto_sections_groups_by_prefix() {
338 let spec = FormSpec {
339 id: "sec".into(),
340 title: "Sections".into(),
341 version: "1".into(),
342 description: None,
343 presentation: None,
344 progress_policy: None,
345 secrets_policy: None,
346 store: vec![],
347 validations: vec![],
348 includes: vec![],
349 questions: vec![
350 QuestionSpec {
351 id: "redis_host".into(),
352 kind: QuestionType::String,
353 title: "Redis Host".into(),
354 title_i18n: None,
355 description: None,
356 description_i18n: None,
357 required: true,
358 choices: None,
359 default_value: None,
360 secret: false,
361 visible_if: None,
362 constraint: None,
363 list: None,
364 computed: None,
365 policy: Default::default(),
366 computed_overridable: false,
367 },
368 QuestionSpec {
369 id: "redis_port".into(),
370 kind: QuestionType::Integer,
371 title: "Redis Port".into(),
372 title_i18n: None,
373 description: None,
374 description_i18n: None,
375 required: false,
376 choices: None,
377 default_value: Some("6379".into()),
378 secret: false,
379 visible_if: None,
380 constraint: None,
381 list: None,
382 computed: None,
383 policy: Default::default(),
384 computed_overridable: false,
385 },
386 QuestionSpec {
387 id: "api_url".into(),
388 kind: QuestionType::String,
389 title: "API URL".into(),
390 title_i18n: None,
391 description: None,
392 description_i18n: None,
393 required: true,
394 choices: None,
395 default_value: None,
396 secret: false,
397 visible_if: None,
398 constraint: None,
399 list: None,
400 computed: None,
401 policy: Default::default(),
402 computed_overridable: false,
403 },
404 ],
405 };
406
407 let sections = auto_sections(&spec);
408 assert_eq!(sections.len(), 2);
409 assert_eq!(sections[0].title, "redis");
410 assert_eq!(sections[0].question_ids, vec!["redis_host", "redis_port"]);
411 assert_eq!(sections[1].title, "api");
412 assert_eq!(sections[1].question_ids, vec!["api_url"]);
413 }
414
415 #[test]
416 fn sectioned_flow_has_section_headers() {
417 let spec = sample_form_spec();
418 let sections = vec![
419 FlowSection {
420 title: "Authentication".into(),
421 question_ids: vec!["auth_enabled".into(), "auth_token".into()],
422 },
423 FlowSection {
424 title: "Connection".into(),
425 question_ids: vec!["url".into()],
426 },
427 ];
428 let flow = build_sectioned_flow(&spec, §ions);
429
430 assert!(flow.steps.contains_key("section_0"));
431 assert!(flow.steps.contains_key("section_1"));
432 assert!(flow.steps.contains_key("q_auth_enabled"));
433 assert!(flow.steps.contains_key("decide_auth_token"));
434 assert!(flow.steps.contains_key("q_url"));
435 assert!(flow.steps.contains_key("end"));
436 }
437}