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)) => {
170 if d.default_goto.is_none() {
171 d.default_goto = Some(next);
172 }
173 }
174 _ => {}
175 }
176 }
177
178 let entry = step_chain.first().cloned().unwrap_or_else(|| sid("end"));
179
180 QAFlowSpec {
181 id: form_spec.id.clone(),
182 title: form_spec.title.clone(),
183 version: form_spec.version.clone(),
184 entry,
185 steps,
186 policies: None,
187 }
188}
189
190#[derive(Clone, Debug)]
192pub struct FlowSection {
193 pub title: String,
194 pub question_ids: Vec<String>,
195}
196
197pub fn auto_sections(form_spec: &FormSpec) -> Vec<FlowSection> {
200 let mut sections: Vec<FlowSection> = Vec::new();
201
202 for question in &form_spec.questions {
203 if question.id.is_empty() {
204 continue;
205 }
206 let prefix = question
207 .id
208 .split('_')
209 .next()
210 .unwrap_or(&question.id)
211 .to_string();
212
213 if let Some(section) = sections.last_mut()
214 && section.title == prefix
215 {
216 section.question_ids.push(question.id.clone());
217 continue;
218 }
219
220 sections.push(FlowSection {
221 title: prefix,
222 question_ids: vec![question.id.clone()],
223 });
224 }
225
226 sections
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use qa_spec::{Expr, QuestionSpec, QuestionType};
233
234 fn sample_form_spec() -> FormSpec {
235 FormSpec {
236 id: "test".into(),
237 title: "Test Setup".into(),
238 version: "1.0.0".into(),
239 description: None,
240 presentation: None,
241 progress_policy: None,
242 secrets_policy: None,
243 store: vec![],
244 validations: vec![],
245 includes: vec![],
246 questions: vec![
247 QuestionSpec {
248 id: "auth_enabled".into(),
249 kind: QuestionType::Boolean,
250 title: "Enable auth?".into(),
251 title_i18n: None,
252 description: None,
253 description_i18n: None,
254 required: true,
255 choices: None,
256 default_value: None,
257 secret: false,
258 visible_if: None,
259 constraint: None,
260 list: None,
261 computed: None,
262 policy: Default::default(),
263 computed_overridable: false,
264 },
265 QuestionSpec {
266 id: "auth_token".into(),
267 kind: QuestionType::String,
268 title: "Auth token".into(),
269 title_i18n: None,
270 description: None,
271 description_i18n: None,
272 required: true,
273 choices: None,
274 default_value: None,
275 secret: true,
276 visible_if: Some(Expr::Answer {
277 path: "auth_enabled".to_string(),
278 }),
279 constraint: None,
280 list: None,
281 computed: None,
282 policy: Default::default(),
283 computed_overridable: false,
284 },
285 QuestionSpec {
286 id: "url".into(),
287 kind: QuestionType::String,
288 title: "API URL".into(),
289 title_i18n: None,
290 description: None,
291 description_i18n: None,
292 required: true,
293 choices: None,
294 default_value: None,
295 secret: false,
296 visible_if: None,
297 constraint: None,
298 list: None,
299 computed: None,
300 policy: Default::default(),
301 computed_overridable: false,
302 },
303 ],
304 }
305 }
306
307 #[test]
308 fn build_flow_creates_decision_gate_for_visible_if() {
309 let spec = sample_form_spec();
310 let flow = build_qa_flow(&spec);
311
312 assert_eq!(flow.entry, "welcome");
313 assert!(flow.steps.contains_key("decide_auth_token"));
314 assert!(flow.steps.contains_key("q_auth_token"));
315 assert!(flow.steps.contains_key("q_auth_enabled"));
316 assert!(flow.steps.contains_key("q_url"));
317 assert!(flow.steps.contains_key("end"));
318
319 match flow.steps.get("decide_auth_token") {
320 Some(StepSpec::Decision(d)) => {
321 assert_eq!(d.cases.len(), 1);
322 assert_eq!(d.cases[0].goto, "q_auth_token");
323 assert_eq!(d.default_goto, Some("q_url".to_string()));
324 }
325 other => panic!("expected Decision, got {other:?}"),
326 }
327 }
328
329 #[test]
330 fn build_flow_no_decision_for_unconditional() {
331 let spec = sample_form_spec();
332 let flow = build_qa_flow(&spec);
333
334 assert!(!flow.steps.contains_key("decide_auth_enabled"));
335 assert!(!flow.steps.contains_key("decide_url"));
336 }
337
338 #[test]
339 fn auto_sections_groups_by_prefix() {
340 let spec = FormSpec {
341 id: "sec".into(),
342 title: "Sections".into(),
343 version: "1".into(),
344 description: None,
345 presentation: None,
346 progress_policy: None,
347 secrets_policy: None,
348 store: vec![],
349 validations: vec![],
350 includes: vec![],
351 questions: vec![
352 QuestionSpec {
353 id: "redis_host".into(),
354 kind: QuestionType::String,
355 title: "Redis Host".into(),
356 title_i18n: None,
357 description: None,
358 description_i18n: None,
359 required: true,
360 choices: None,
361 default_value: None,
362 secret: false,
363 visible_if: None,
364 constraint: None,
365 list: None,
366 computed: None,
367 policy: Default::default(),
368 computed_overridable: false,
369 },
370 QuestionSpec {
371 id: "redis_port".into(),
372 kind: QuestionType::Integer,
373 title: "Redis Port".into(),
374 title_i18n: None,
375 description: None,
376 description_i18n: None,
377 required: false,
378 choices: None,
379 default_value: Some("6379".into()),
380 secret: false,
381 visible_if: None,
382 constraint: None,
383 list: None,
384 computed: None,
385 policy: Default::default(),
386 computed_overridable: false,
387 },
388 QuestionSpec {
389 id: "api_url".into(),
390 kind: QuestionType::String,
391 title: "API URL".into(),
392 title_i18n: None,
393 description: None,
394 description_i18n: None,
395 required: true,
396 choices: None,
397 default_value: None,
398 secret: false,
399 visible_if: None,
400 constraint: None,
401 list: None,
402 computed: None,
403 policy: Default::default(),
404 computed_overridable: false,
405 },
406 ],
407 };
408
409 let sections = auto_sections(&spec);
410 assert_eq!(sections.len(), 2);
411 assert_eq!(sections[0].title, "redis");
412 assert_eq!(sections[0].question_ids, vec!["redis_host", "redis_port"]);
413 assert_eq!(sections[1].title, "api");
414 assert_eq!(sections[1].question_ids, vec!["api_url"]);
415 }
416
417 #[test]
418 fn sectioned_flow_has_section_headers() {
419 let spec = sample_form_spec();
420 let sections = vec![
421 FlowSection {
422 title: "Authentication".into(),
423 question_ids: vec!["auth_enabled".into(), "auth_token".into()],
424 },
425 FlowSection {
426 title: "Connection".into(),
427 question_ids: vec!["url".into()],
428 },
429 ];
430 let flow = build_sectioned_flow(&spec, §ions);
431
432 assert!(flow.steps.contains_key("section_0"));
433 assert!(flow.steps.contains_key("section_1"));
434 assert!(flow.steps.contains_key("q_auth_enabled"));
435 assert!(flow.steps.contains_key("decide_auth_token"));
436 assert!(flow.steps.contains_key("q_url"));
437 assert!(flow.steps.contains_key("end"));
438 }
439}