polyspec_crudui_validator/validate/
group_source.rs1use serde_json::{Map, Value};
9use std::collections::HashSet;
10
11use super::canonical::canonical_text;
12use super::whitespace::trim;
13
14const CHOICE_TYPES: [&str; 8] = [
16 "select",
17 "dropdown",
18 "selectbox",
19 "choice",
20 "radio",
21 "multichoice",
22 "checkboxes",
23 "checkcontainer",
24];
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct GroupChoice {
29 pub value: String,
31 pub label: Value,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum GroupChoicesError {
39 Declaration(String),
41 Repeated(String),
43}
44
45fn 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
67pub fn is_group_source(items: &Value) -> bool {
69 items
70 .as_object()
71 .is_some_and(|members| members.contains_key("from"))
72}
73
74fn 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
82fn has_lang(field: &Value) -> bool {
84 matches!(
85 field.get("lang"),
86 Some(Value::Bool(true)) | Some(Value::Object(_))
87 )
88}
89
90pub 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
102fn 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
130fn 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
137fn 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
144const BOOLEAN_TYPES: [&str; 2] = ["checkbox", "switcher"];
146
147fn 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
156fn 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
164pub 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
217fn 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
228pub 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
279fn 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
289pub fn group_source_repeat(items: &Value, data: &Value) -> Option<String> {
293 first_repeat(items, data, true)
294}
295
296fn 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}