Skip to main content

greentic_setup/
flow.rs

1//! QAFlowSpec builder for multi-step setup with conditional jumps.
2//!
3//! Converts a flat `FormSpec` into a directed-graph `QAFlowSpec` where
4//! questions with `visible_if` expressions become decision branches.
5
6use 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
16/// Build a `QAFlowSpec` from a `FormSpec`, inserting decision steps for
17/// questions that have `visible_if` conditions.
18///
19/// The resulting flow is a directed graph where:
20/// - Each question becomes a `StepSpec::Question`
21/// - Questions with `visible_if` get a preceding `StepSpec::Decision` that
22///   evaluates the condition and either proceeds to the question or skips it
23/// - A welcome message step is prepended as the entry point
24pub 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    // Entry: welcome message
29    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    // Patch welcome → first real step
80    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
109/// Build a section-based QAFlowSpec where questions are grouped into
110/// named sections with message headers and decision gates.
111pub 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 &section.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    // Patch next pointers
164    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/// A named section of questions for sectioned flow building.
189#[derive(Clone, Debug)]
190pub struct FlowSection {
191    pub title: String,
192    pub question_ids: Vec<String>,
193}
194
195/// Auto-detect sections from question IDs by grouping on the prefix
196/// before the first underscore (e.g., `redis_host` → section `redis`).
197pub 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, &sections);
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}