Skip to main content

qa_spec/
compose.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use thiserror::Error;
4
5use crate::{Expr, FormSpec, QuestionSpec, spec::validation::CrossFieldValidation};
6
7#[derive(Debug, Error)]
8pub enum IncludeError {
9    #[error("missing include target '{form_ref}'")]
10    MissingIncludeTarget { form_ref: String },
11    #[error("include cycle detected: {chain:?}")]
12    IncludeCycleDetected { chain: Vec<String> },
13    #[error("duplicate question id after include expansion: '{question_id}'")]
14    DuplicateQuestionId { question_id: String },
15}
16
17/// Expand includes recursively into a flattened form spec with deterministic ordering.
18pub fn expand_includes(
19    root: &FormSpec,
20    registry: &BTreeMap<String, FormSpec>,
21) -> Result<FormSpec, IncludeError> {
22    let mut chain = Vec::new();
23    let mut seen = BTreeSet::new();
24    expand_form(root, "", registry, &mut chain, &mut seen)
25}
26
27fn expand_form(
28    form: &FormSpec,
29    prefix: &str,
30    registry: &BTreeMap<String, FormSpec>,
31    chain: &mut Vec<String>,
32    seen_ids: &mut BTreeSet<String>,
33) -> Result<FormSpec, IncludeError> {
34    if chain.contains(&form.id) {
35        let start = chain.iter().position(|id| id == &form.id).unwrap_or(0);
36        let mut cycle = chain[start..].to_vec();
37        cycle.push(form.id.clone());
38        return Err(IncludeError::IncludeCycleDetected { chain: cycle });
39    }
40    chain.push(form.id.clone());
41
42    let mut out = form.clone();
43    out.questions.clear();
44    out.validations.clear();
45    out.includes.clear();
46
47    for question in &form.questions {
48        let question = apply_prefix_question(question, prefix);
49        if !seen_ids.insert(question.id.clone()) {
50            return Err(IncludeError::DuplicateQuestionId {
51                question_id: question.id,
52            });
53        }
54        out.questions.push(question);
55    }
56
57    for validation in &form.validations {
58        out.validations
59            .push(apply_prefix_validation(validation, prefix));
60    }
61
62    for include in &form.includes {
63        let included =
64            registry
65                .get(&include.form_ref)
66                .ok_or_else(|| IncludeError::MissingIncludeTarget {
67                    form_ref: include.form_ref.clone(),
68                })?;
69        let nested_prefix = combine_prefix(prefix, include.prefix.as_deref());
70        let expanded = expand_form(included, &nested_prefix, registry, chain, seen_ids)?;
71        out.questions.extend(expanded.questions);
72        out.validations.extend(expanded.validations);
73    }
74
75    chain.pop();
76    Ok(out)
77}
78
79fn apply_prefix_validation(
80    validation: &CrossFieldValidation,
81    prefix: &str,
82) -> CrossFieldValidation {
83    if prefix.is_empty() {
84        return validation.clone();
85    }
86    let mut out = validation.clone();
87    out.id = out.id.map(|id| prefix_key(prefix, &id));
88    out.fields = out
89        .fields
90        .iter()
91        .map(|field| prefix_key(prefix, field))
92        .collect();
93    out.condition = prefix_expr(out.condition, prefix);
94    out
95}
96
97fn apply_prefix_question(question: &QuestionSpec, prefix: &str) -> QuestionSpec {
98    if prefix.is_empty() {
99        return question.clone();
100    }
101    let mut out = question.clone();
102    out.id = prefix_key(prefix, &out.id);
103    out.visible_if = out.visible_if.map(|expr| prefix_expr(expr, prefix));
104    out.computed = out.computed.map(|expr| prefix_expr(expr, prefix));
105    if let Some(list) = &mut out.list {
106        list.fields = list
107            .fields
108            .iter()
109            .map(|field| apply_prefix_question(field, prefix))
110            .collect();
111    }
112    out
113}
114
115fn prefix_expr(expr: Expr, prefix: &str) -> Expr {
116    match expr {
117        Expr::Answer { path } => Expr::Answer {
118            path: prefix_path(prefix, &path),
119        },
120        Expr::IsSet { path } => Expr::IsSet {
121            path: prefix_path(prefix, &path),
122        },
123        Expr::And { expressions } => Expr::And {
124            expressions: expressions
125                .into_iter()
126                .map(|expr| prefix_expr(expr, prefix))
127                .collect(),
128        },
129        Expr::Or { expressions } => Expr::Or {
130            expressions: expressions
131                .into_iter()
132                .map(|expr| prefix_expr(expr, prefix))
133                .collect(),
134        },
135        Expr::Not { expression } => Expr::Not {
136            expression: Box::new(prefix_expr(*expression, prefix)),
137        },
138        Expr::Eq { left, right } => Expr::Eq {
139            left: Box::new(prefix_expr(*left, prefix)),
140            right: Box::new(prefix_expr(*right, prefix)),
141        },
142        Expr::Ne { left, right } => Expr::Ne {
143            left: Box::new(prefix_expr(*left, prefix)),
144            right: Box::new(prefix_expr(*right, prefix)),
145        },
146        Expr::Lt { left, right } => Expr::Lt {
147            left: Box::new(prefix_expr(*left, prefix)),
148            right: Box::new(prefix_expr(*right, prefix)),
149        },
150        Expr::Lte { left, right } => Expr::Lte {
151            left: Box::new(prefix_expr(*left, prefix)),
152            right: Box::new(prefix_expr(*right, prefix)),
153        },
154        Expr::Gt { left, right } => Expr::Gt {
155            left: Box::new(prefix_expr(*left, prefix)),
156            right: Box::new(prefix_expr(*right, prefix)),
157        },
158        Expr::Gte { left, right } => Expr::Gte {
159            left: Box::new(prefix_expr(*left, prefix)),
160            right: Box::new(prefix_expr(*right, prefix)),
161        },
162        other => other,
163    }
164}
165
166fn prefix_path(prefix: &str, path: &str) -> String {
167    if path.is_empty() || path.starts_with('/') || prefix.is_empty() {
168        return path.to_string();
169    }
170    format!("{}.{}", prefix, path)
171}
172
173fn prefix_key(prefix: &str, key: &str) -> String {
174    if prefix.is_empty() {
175        key.to_string()
176    } else {
177        format!("{}.{}", prefix, key)
178    }
179}
180
181fn combine_prefix(parent: &str, child: Option<&str>) -> String {
182    match (parent.is_empty(), child.unwrap_or("").is_empty()) {
183        (true, true) => String::new(),
184        (false, true) => parent.to_string(),
185        (true, false) => child.unwrap_or_default().to_string(),
186        (false, false) => format!("{}.{}", parent, child.unwrap_or_default()),
187    }
188}