1use dpp_plugin_traits::{PluginComplianceStatus, PluginError, PluginFieldError, PluginInput};
14use serde_json::Value;
15
16fn present<'a>(input: &'a Value, key: &str) -> Option<&'a Value> {
18 match input.get(key) {
19 Some(Value::Null) | None => None,
20 other => other,
21 }
22}
23
24#[must_use]
26pub fn num(input: &PluginInput, key: &str) -> Option<f64> {
27 input
28 .get(key)
29 .and_then(Value::as_f64)
30 .filter(|n| n.is_finite())
31}
32
33#[must_use]
35pub fn str_of<'a>(input: &'a PluginInput, key: &str) -> Option<&'a str> {
36 input.get(key).and_then(Value::as_str)
37}
38
39#[must_use]
43pub fn threshold_status(value: Option<f64>, threshold: f64) -> PluginComplianceStatus {
44 if value.is_some_and(|v| v <= threshold) {
45 PluginComplianceStatus::Compliant
46 } else {
47 PluginComplianceStatus::NonCompliant
48 }
49}
50
51pub struct Validator<'a> {
53 input: &'a Value,
54 errors: Vec<PluginFieldError>,
55}
56
57impl<'a> Validator<'a> {
58 #[must_use]
59 pub fn new(input: &'a PluginInput) -> Self {
60 Self {
61 input,
62 errors: Vec::new(),
63 }
64 }
65
66 fn push_opt(&mut self, key: &str, err: Option<(&str, String)>) {
67 if let Some((code, message)) = err {
68 self.errors.push(PluginFieldError {
69 field: format!("/{key}"),
70 code: code.to_owned(),
71 message,
72 });
73 }
74 }
75
76 pub fn require_str(&mut self, key: &str) -> &mut Self {
78 let err = match present(self.input, key) {
79 None => Some(("missing", format!("{key} is required"))),
80 Some(v) => match v.as_str() {
81 Some(s) if !s.trim().is_empty() => None,
82 Some(_) => Some(("empty", format!("{key} must not be empty"))),
83 None => Some(("type", format!("{key} must be a string"))),
84 },
85 };
86 self.push_opt(key, err);
87 self
88 }
89
90 pub fn require_enum(&mut self, key: &str, allowed: &[&str]) -> &mut Self {
92 let err = match present(self.input, key).and_then(Value::as_str) {
93 None => Some(("missing", format!("{key} is required"))),
94 Some(s) if allowed.contains(&s) => None,
95 Some(_) => Some(("out_of_range", format!("{key} must be one of {allowed:?}"))),
96 };
97 self.push_opt(key, err);
98 self
99 }
100
101 pub fn require_gtin(&mut self, key: &str) -> &mut Self {
103 let err = match present(self.input, key).and_then(Value::as_str) {
104 None => Some(("missing", format!("{key} is required"))),
105 Some(g) if g.len() == 14 && g.bytes().all(|b| b.is_ascii_digit()) => {
106 if gs1_check_digit_valid(g) {
107 None
108 } else {
109 Some(("checksum", format!("{key} has an invalid GS1 check digit")))
110 }
111 }
112 Some(_) => Some(("format", format!("{key} must be 14 digits"))),
113 };
114 self.push_opt(key, err);
115 self
116 }
117
118 pub fn require_country(&mut self, key: &str) -> &mut Self {
120 let err = match present(self.input, key).and_then(Value::as_str) {
121 None => Some(("missing", format!("{key} is required"))),
122 Some(c) if c.len() == 2 && c.bytes().all(|b| b.is_ascii_uppercase()) => {
123 if dpp_rules::country_code_valid(c) {
124 None
125 } else {
126 Some((
127 "invalid",
128 format!("{key} is not a recognized ISO 3166-1 alpha-2 code"),
129 ))
130 }
131 }
132 Some(_) => Some((
133 "format",
134 format!("{key} must be a 2-letter uppercase country code"),
135 )),
136 };
137 self.push_opt(key, err);
138 self
139 }
140
141 pub fn require_positive(&mut self, key: &str) -> &mut Self {
143 let err = match num(self.input, key) {
144 None => Some((
145 "missing",
146 format!("{key} is required and must be a finite number"),
147 )),
148 Some(v) if v <= 0.0 => Some(("out_of_range", format!("{key} must be greater than 0"))),
149 Some(_) => None,
150 };
151 self.push_opt(key, err);
152 self
153 }
154
155 pub fn require_non_negative(&mut self, key: &str) -> &mut Self {
157 let err = match num(self.input, key) {
158 None => Some((
159 "missing",
160 format!("{key} is required and must be a finite number"),
161 )),
162 Some(v) if v < 0.0 => Some(("out_of_range", format!("{key} must be 0 or greater"))),
163 Some(_) => None,
164 };
165 self.push_opt(key, err);
166 self
167 }
168
169 pub fn require_pct(&mut self, key: &str) -> &mut Self {
171 let err = match num(self.input, key) {
172 None => Some((
173 "missing",
174 format!("{key} is required and must be a number in 0..=100"),
175 )),
176 Some(v) if !(0.0..=100.0).contains(&v) => {
177 Some(("out_of_range", format!("{key} must be in 0..=100")))
178 }
179 Some(_) => None,
180 };
181 self.push_opt(key, err);
182 self
183 }
184
185 pub fn require_positive_int(&mut self, key: &str) -> &mut Self {
187 let err = match present(self.input, key).and_then(Value::as_u64) {
188 None => Some((
189 "missing",
190 format!("{key} is required and must be a non-negative integer"),
191 )),
192 Some(0) => Some(("out_of_range", format!("{key} must be at least 1"))),
193 Some(_) => None,
194 };
195 self.push_opt(key, err);
196 self
197 }
198
199 pub fn require_bool(&mut self, key: &str) -> &mut Self {
201 let err = match present(self.input, key) {
202 None => Some(("missing", format!("{key} is required"))),
203 Some(v) if v.is_boolean() => None,
204 Some(_) => Some(("type", format!("{key} must be a boolean"))),
205 };
206 self.push_opt(key, err);
207 self
208 }
209
210 pub fn require_non_empty_array(&mut self, key: &str) -> &mut Self {
212 let err = match present(self.input, key).and_then(Value::as_array) {
213 None => Some(("missing", format!("{key} is required and must be an array"))),
214 Some(a) if a.is_empty() => Some(("empty", format!("{key} must not be empty"))),
215 Some(_) => None,
216 };
217 self.push_opt(key, err);
218 self
219 }
220
221 pub fn optional_pct(&mut self, key: &str) -> &mut Self {
223 let err = match present(self.input, key) {
224 None => None,
225 Some(v) => match v.as_f64().filter(|n| n.is_finite()) {
226 Some(n) if (0.0..=100.0).contains(&n) => None,
227 _ => Some(("out_of_range", format!("{key} must be a number in 0..=100"))),
228 },
229 };
230 self.push_opt(key, err);
231 self
232 }
233
234 pub fn optional_range(&mut self, key: &str, min: f64, max: f64) -> &mut Self {
240 let err = match present(self.input, key) {
241 None => None,
242 Some(v) => match v.as_f64().filter(|n| n.is_finite()) {
243 Some(n) if (min..=max).contains(&n) => None,
244 _ => Some((
245 "out_of_range",
246 format!("{key} must be a number in {min}..={max}"),
247 )),
248 },
249 };
250 self.push_opt(key, err);
251 self
252 }
253
254 pub fn optional_non_negative(&mut self, key: &str) -> &mut Self {
256 let err = match present(self.input, key) {
257 None => None,
258 Some(v) => match v.as_f64().filter(|n| n.is_finite()) {
259 Some(n) if n >= 0.0 => None,
260 _ => Some(("out_of_range", format!("{key} must be a finite number ≥ 0"))),
261 },
262 };
263 self.push_opt(key, err);
264 self
265 }
266
267 pub fn finish(&mut self) -> Result<(), PluginError> {
269 if self.errors.is_empty() {
270 Ok(())
271 } else {
272 Err(PluginError::ValidationErrors(std::mem::take(
273 &mut self.errors,
274 )))
275 }
276 }
277}
278
279fn gs1_check_digit_valid(gtin: &str) -> bool {
284 let bytes = gtin.as_bytes();
285 debug_assert_eq!(bytes.len(), 14, "caller must check length == 14 first");
286 let sum: u32 = bytes[..13]
287 .iter()
288 .enumerate()
289 .map(|(i, &b)| {
290 let d = (b - b'0') as u32;
291 if i % 2 == 0 { d * 3 } else { d }
292 })
293 .sum();
294 let expected = (10 - sum % 10) % 10;
295 expected == (bytes[13] - b'0') as u32
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301 use serde_json::json;
302
303 #[test]
304 fn collects_all_failures() {
305 let input = json!({ "gtin": "12-34", "voltage": -1.0 });
306 let err = Validator::new(&input)
307 .require_gtin("gtin")
308 .require_positive("voltage")
309 .require_str("name")
310 .finish()
311 .unwrap_err();
312 match err {
313 PluginError::ValidationErrors(errs) => assert_eq!(errs.len(), 3),
314 other => panic!("expected ValidationErrors, got {other:?}"),
315 }
316 }
317
318 #[test]
319 fn valid_input_passes() {
320 let input = json!({
321 "gtin": "12345678901231",
322 "country": "DE",
323 "pct": 42.0,
324 "count": 3,
325 "flag": true,
326 "items": [1]
327 });
328 assert!(
329 Validator::new(&input)
330 .require_gtin("gtin")
331 .require_country("country")
332 .require_pct("pct")
333 .require_positive_int("count")
334 .require_bool("flag")
335 .require_non_empty_array("items")
336 .finish()
337 .is_ok()
338 );
339 }
340
341 #[test]
342 fn enum_and_country_and_pct_bounds() {
343 let input = json!({ "cls": "Z", "country": "de", "pct": 150.0 });
344 let err = Validator::new(&input)
345 .require_enum("cls", &["A", "B"])
346 .require_country("country")
347 .require_pct("pct")
348 .finish()
349 .unwrap_err();
350 match err {
351 PluginError::ValidationErrors(errs) => assert_eq!(errs.len(), 3),
352 other => panic!("expected ValidationErrors, got {other:?}"),
353 }
354 }
355
356 #[test]
357 fn gtin_invalid_check_digit_is_rejected() {
358 let input = json!({ "gtin": "12345678901234" });
360 let err = Validator::new(&input)
361 .require_gtin("gtin")
362 .finish()
363 .unwrap_err();
364 match err {
365 PluginError::ValidationErrors(errs) => {
366 assert_eq!(errs.len(), 1);
367 assert_eq!(errs[0].code, "checksum");
368 }
369 other => panic!("expected ValidationErrors, got {other:?}"),
370 }
371 }
372
373 #[test]
374 fn gtin_valid_check_digit_passes() {
375 let input = json!({ "gtin": "12345678901231" });
377 assert!(Validator::new(&input).require_gtin("gtin").finish().is_ok());
378 }
379
380 #[test]
381 fn country_not_in_iso_list_is_rejected() {
382 let input = json!({ "country": "XX" });
384 let err = Validator::new(&input)
385 .require_country("country")
386 .finish()
387 .unwrap_err();
388 match err {
389 PluginError::ValidationErrors(errs) => {
390 assert_eq!(errs.len(), 1);
391 assert_eq!(errs[0].code, "invalid");
392 }
393 other => panic!("expected ValidationErrors, got {other:?}"),
394 }
395 }
396
397 #[test]
398 fn country_valid_iso_code_passes() {
399 for code in ["DE", "NO", "FR", "US", "JP"] {
400 let input = json!({ "country": code });
401 assert!(
402 Validator::new(&input)
403 .require_country("country")
404 .finish()
405 .is_ok(),
406 "{code} should be a valid ISO 3166-1 alpha-2 code"
407 );
408 }
409 }
410
411 #[test]
412 fn optional_pct_absent_is_ok_present_out_of_range_fails() {
413 let ok = json!({});
414 assert!(Validator::new(&ok).optional_pct("x").finish().is_ok());
415 let bad = json!({ "x": 101.0 });
416 assert!(Validator::new(&bad).optional_pct("x").finish().is_err());
417 }
418
419 #[test]
420 fn optional_range_absent_ok_present_bounded() {
421 assert!(
423 Validator::new(&json!({}))
424 .optional_range("s", 0.0, 10.0)
425 .finish()
426 .is_ok()
427 );
428 assert!(
430 Validator::new(&json!({ "s": 6.0 }))
431 .optional_range("s", 0.0, 10.0)
432 .finish()
433 .is_ok()
434 );
435 for bad in [json!({ "s": 999999.0 }), json!({ "s": -1.0 })] {
437 assert!(
438 Validator::new(&bad)
439 .optional_range("s", 0.0, 10.0)
440 .finish()
441 .is_err()
442 );
443 }
444 }
445
446 #[test]
447 fn optional_non_negative_absent_ok_negative_fails() {
448 assert!(
449 Validator::new(&json!({}))
450 .optional_non_negative("c")
451 .finish()
452 .is_ok()
453 );
454 assert!(
455 Validator::new(&json!({ "c": 0.0 }))
456 .optional_non_negative("c")
457 .finish()
458 .is_ok()
459 );
460 assert!(
461 Validator::new(&json!({ "c": -999.0 }))
462 .optional_non_negative("c")
463 .finish()
464 .is_err()
465 );
466 }
467
468 #[test]
469 fn readers_extract_values() {
470 let input = json!({ "n": 3.5, "s": "hi", "bad": "x" });
471 assert_eq!(num(&input, "n"), Some(3.5));
472 assert_eq!(num(&input, "bad"), None);
473 assert_eq!(str_of(&input, "s"), Some("hi"));
474 }
475}