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)) => {
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/// A named section of questions for sectioned flow building.
191#[derive(Clone, Debug)]
192pub struct FlowSection {
193    pub title: String,
194    pub question_ids: Vec<String>,
195}
196
197/// Auto-detect sections from question IDs by grouping on the prefix
198/// before the first underscore (e.g., `redis_host` → section `redis`).
199pub 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, &sections);
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}