Skip to main content

polyspec_crudui_validator/validate/
group_source.rs

1//! Choices from a repeated group (schema.md, "Choices from a repeated group").
2//!
3//! A choice field whose `items` is a group source `{ from, label? }` takes its choices from
4//! the rows of a repeated group of the same form. This module checks the declaration and
5//! reads the choices from the data; the generator lists them and the validator checks a
6//! value against them (validation-rules.md, "Values").
7
8use serde_json::{Map, Value};
9use std::collections::HashSet;
10
11use super::canonical::canonical_text;
12use super::whitespace::trim;
13
14/// The field types that take a group source.
15const CHOICE_TYPES: [&str; 8] = [
16    "select",
17    "dropdown",
18    "selectbox",
19    "choice",
20    "radio",
21    "multichoice",
22    "checkboxes",
23    "checkcontainer",
24];
25
26/// One choice of a group source: the canonical text of a row's value and its label text.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct GroupChoice {
29    /// The canonical text of the row's value field.
30    pub value: String,
31    /// The declared label of a literal choice (CUSC-089), or the canonical text of the row's label
32    /// field, or the value when it gives none.
33    pub label: Value,
34}
35
36/// Why the choices of a group source could not be read.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum GroupChoicesError {
39    /// The source breaks a rule of its declaration, with the message of that refusal.
40    Declaration(String),
41    /// A row's value repeats a literal choice (CUSC-089), with the message that names its path and value.
42    Repeated(String),
43}
44
45/// The literal choices of a group source (CUSC-089), each with the canonical text of its value, or
46/// `None` when `choices` is not a list of `{value, label}` pairs whose values are strings or
47/// finite numbers with distinct canonical texts that are not empty after trimming.
48fn literal_choices(choices: Option<&Value>) -> Option<Vec<GroupChoice>> {
49    let Some(choices) = choices else {
50        return Some(Vec::new());
51    };
52    let mut literal: Vec<GroupChoice> = Vec::new();
53    for choice in choices.as_array()? {
54        let choice = choice.as_object()?;
55        if choice.len() != 2 || !choice.contains_key("label") {
56            return None;
57        }
58        let value = choice_text(choice.get("value"))?;
59        if literal.iter().any(|known| known.value == value) {
60            return None;
61        }
62        literal.push(GroupChoice { value, label: choice["label"].clone() });
63    }
64    Some(literal)
65}
66
67/// Whether `items` is a group source: an object with the member `from`.
68pub fn is_group_source(items: &Value) -> bool {
69    items
70        .as_object()
71        .is_some_and(|members| members.contains_key("from"))
72}
73
74/// Whether a field repeats: `multiple` is `true`, `only` or a settings object.
75fn repeated(field: &Value) -> bool {
76    matches!(
77        field.get("multiple"),
78        Some(Value::Bool(true)) | Some(Value::Object(_))
79    ) || field.get("multiple").and_then(Value::as_str) == Some("only")
80}
81
82/// Whether a field declares a language: `lang` is `true` or an object.
83fn has_lang(field: &Value) -> bool {
84    matches!(
85        field.get("lang"),
86        Some(Value::Bool(true)) | Some(Value::Object(_))
87    )
88}
89
90/// Whether `child` renders one scalar value: an object whose type is not `group`, that has no
91/// `properties` member, is not repeated and has no `lang`.
92pub fn scalar_child(child: &Value) -> bool {
93    child.is_object()
94        && child
95            .get("type")
96            .is_none_or(|kind| kind.as_str() != Some("group"))
97        && child.get("properties").is_none()
98        && !repeated(child)
99        && !has_lang(child)
100}
101
102/// The properties of the repeated group that `from` names under the root `properties`, when
103/// `from` is `{group}.*.{field}` with `field` a scalar child of that group, every group of the
104/// path a group field with properties, and only the last one repeated.
105fn source_group<'a>(from: &str, root: &'a Map<String, Value>) -> Option<&'a Map<String, Value>> {
106    let segments: Vec<&str> = from.split('.').collect();
107    let count = segments.len();
108    if count < 3 || segments[count - 2] != "*" {
109        return None;
110    }
111    let names = &segments[..count - 2];
112    let mut current = root;
113    for (index, name) in names.iter().enumerate() {
114        let field = current.get(*name)?;
115        if field.get("type").and_then(Value::as_str) != Some("group") {
116            return None;
117        }
118        let properties = field.get("properties").and_then(Value::as_object)?;
119        if repeated(field) != (index == names.len() - 1) {
120            return None;
121        }
122        current = properties;
123    }
124    current
125        .get(segments[count - 1])
126        .filter(|child| scalar_child(child))
127        .map(|_| current)
128}
129
130/// The message of the expected `from` of a group source, the same for every rule it breaks.
131fn from_message(path: &str) -> String {
132    format!(
133        "Invalid items.from at {path}: expected {{group}}.*.{{field}} for a field of a repeated group outside repeated groups"
134    )
135}
136
137/// The message of a `label` that is not a scalar child of its group.
138fn label_message(path: &str) -> String {
139    format!(
140        "Invalid items.label at {path}: expected the name of a direct child field of the group without multiple, properties or lang"
141    )
142}
143
144/// The field types whose stored value is a boolean, which a group source refuses (CUSC-068).
145const BOOLEAN_TYPES: [&str; 2] = ["checkbox", "switcher"];
146
147/// Whether the scalar child `name` of a group is of a type that stores a boolean.
148fn holds_boolean(group: &Map<String, Value>, name: &str) -> bool {
149    group
150        .get(name)
151        .and_then(|child| child.get("type"))
152        .and_then(Value::as_str)
153        .is_some_and(|kind| BOOLEAN_TYPES.contains(&kind))
154}
155
156/// The message of a group source whose `member` names the field `field` that holds a boolean
157/// (CUSC-068).
158fn boolean_message(member: &str, path: &str, field: &str) -> String {
159    format!(
160        "Invalid items.{member} at {path}: {field} holds a boolean, expected a field whose values are strings or numbers"
161    )
162}
163
164/// The message of the first rule that the group source of `field` at the declaration `path`
165/// breaks under the root `properties`, or `None`. The members of `items` are checked in
166/// declaration order, then the field, then `from`, the type of its field, `label` and then the
167/// type of its field.
168pub fn group_source_failure(
169    field: &Value,
170    path: &str,
171    root: &Map<String, Value>,
172) -> Option<String> {
173    let items = field
174        .get("items")
175        .filter(|items| is_group_source(items))?;
176    let members = items.as_object()?;
177    if let Some(key) = members.keys().find(|key| *key != "from" && *key != "label" && *key != "choices") {
178        return Some(format!("Invalid items.{key} at {path}: unknown key"));
179    }
180    let is_choice = field
181        .get("type")
182        .and_then(Value::as_str)
183        .is_some_and(|kind| CHOICE_TYPES.contains(&kind));
184    if !is_choice || has_lang(field) {
185        return Some(format!(
186            "Invalid items.from at {path}: expected a choice field without lang"
187        ));
188    }
189    let from = members.get("from").and_then(Value::as_str);
190    let Some((from, group)) = from.and_then(|from| source_group(from, root).map(|group| (from, group)))
191    else {
192        return Some(from_message(path));
193    };
194    let segments: Vec<&str> = from.split('.').collect();
195    let group_path = segments[..segments.len() - 2].join(".");
196    let value_name = segments[segments.len() - 1];
197    if holds_boolean(group, value_name) {
198        return Some(boolean_message("from", path, &format!("{group_path}.{value_name}")));
199    }
200    if let Some(label) = members.get("label") {
201        match label.as_str() {
202            Some(name) if group.get(name).is_some_and(scalar_child) => {
203                if holds_boolean(group, name) {
204                    return Some(boolean_message("label", path, &format!("{group_path}.{name}")));
205                }
206            }
207            _ => return Some(label_message(path)),
208        }
209    }
210    literal_choices(members.get("choices")).is_none().then(|| choices_message(path))
211}
212
213fn choices_message(path: &str) -> String {
214    format!("Invalid items.choices at {path}: expected value and label pairs with distinct string or number values")
215}
216
217/// The canonical text of a string or a number whose text is not empty after trimming.
218fn choice_text(value: Option<&Value>) -> Option<String> {
219    let value = value.filter(|value| value.is_string() || value.is_number())?;
220    let text = canonical_text(value)?;
221    if trim(&text).is_empty() {
222        None
223    } else {
224        Some(text.into_owned())
225    }
226}
227
228/// The choices of a group source for the root `data`: one per row of the group in data order
229/// whose value gives a non-empty canonical text, each value once, labelled with the text of its
230/// `label` field or else its value. A group missing from the data gives no choices.
231///
232/// `items` must have passed [`group_source_failure`]; a source whose `from` or `label` is not
233/// of the declared shape gives the message of its declaration failure, located at `path`.
234pub fn group_choices(items: &Value, data: &Value, path: &str) -> Result<Vec<GroupChoice>, GroupChoicesError> {
235    let declaration = |message: String| GroupChoicesError::Declaration(message);
236    let members = items.as_object().ok_or_else(|| declaration(from_message(path)))?;
237    let from = members
238        .get("from")
239        .and_then(Value::as_str)
240        .ok_or_else(|| declaration(from_message(path)))?;
241    let segments: Vec<&str> = from.split('.').collect();
242    let count = segments.len();
243    if count < 3 || segments[count - 2] != "*" {
244        return Err(declaration(from_message(path)));
245    }
246    let label_name = match members.get("label") {
247        None => None,
248        Some(Value::String(name)) => Some(name.as_str()),
249        Some(_) => return Err(declaration(label_message(path))),
250    };
251    let literal = literal_choices(members.get("choices")).ok_or_else(|| declaration(choices_message(path)))?;
252    if let Some(repeat) = first_repeat(items, data, false) {
253        return Err(GroupChoicesError::Repeated(repeat));
254    }
255    let value_name = segments[count - 1];
256    let Some(rows) = source_rows(&segments, data) else {
257        return Ok(literal);
258    };
259    let mut choices = literal;
260    let mut seen = HashSet::new();
261    for row in rows.values() {
262        let Some(row) = row.as_object() else {
263            continue;
264        };
265        let Some(value) = choice_text(row.get(value_name)) else {
266            continue;
267        };
268        if !seen.insert(value.clone()) {
269            continue;
270        }
271        let label = label_name
272            .and_then(|name| choice_text(row.get(name)))
273            .unwrap_or_else(|| value.clone());
274        choices.push(GroupChoice { value, label: Value::String(label) });
275    }
276    Ok(choices)
277}
278
279/// The rows of the group that the `from` path `segments` name in the root `data`, by row key in
280/// data order, when the group has object data.
281fn source_rows<'a>(segments: &[&str], data: &'a Value) -> Option<&'a Map<String, Value>> {
282    let mut rows = data;
283    for name in &segments[..segments.len() - 2] {
284        rows = rows.as_object()?.get(*name)?;
285    }
286    rows.as_object()
287}
288
289/// The input failure message of the first row of a checked group source, in data order, whose
290/// choice value repeats a literal choice (CUSC-089) or that of an earlier row (CUVR-087), or
291/// `None`. A source whose `from` is not of the declared shape has no rows.
292pub fn group_source_repeat(items: &Value, data: &Value) -> Option<String> {
293    first_repeat(items, data, true)
294}
295
296/// The message of the first row whose choice value is a literal choice or, with `rows`, the value
297/// of an earlier row.
298fn first_repeat(items: &Value, data: &Value, rows: bool) -> Option<String> {
299    let from = items.get("from").and_then(Value::as_str)?;
300    let literal = literal_choices(items.get("choices")).unwrap_or_default();
301    let segments: Vec<&str> = from.split('.').collect();
302    if segments.len() < 3 {
303        return None;
304    }
305    let group_path = segments[..segments.len() - 2].join(".");
306    let value_name = segments[segments.len() - 1];
307    let mut seen = HashSet::new();
308    for (key, row) in source_rows(&segments, data)? {
309        let Some(value) = choice_text(row.as_object().and_then(|row| row.get(value_name))) else {
310            continue;
311        };
312        if literal.iter().any(|choice| choice.value == value) {
313            return Some(format!(
314                "Group source value repeats a literal choice at {group_path}.{key}.{value_name}: {value}"
315            ));
316        }
317        if !seen.insert(value.clone()) && rows {
318            return Some(format!(
319                "Group source value repeats an earlier row: {value} at {group_path}.{key}.{value_name}"
320            ));
321        }
322    }
323    None
324}