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 optional_positive_int(&mut self, key: &str) -> &mut Self {
207 let err = match present(self.input, key) {
208 None => None,
209 Some(v) => match v.as_u64() {
210 None => Some((
211 "invalid_type",
212 format!("{key} must be a non-negative integer"),
213 )),
214 Some(0) => Some(("out_of_range", format!("{key} must be at least 1"))),
215 Some(_) => None,
216 },
217 };
218 self.push_opt(key, err);
219 self
220 }
221
222 pub fn require_bool(&mut self, key: &str) -> &mut Self {
224 let err = match present(self.input, key) {
225 None => Some(("missing", format!("{key} is required"))),
226 Some(v) if v.is_boolean() => None,
227 Some(_) => Some(("type", format!("{key} must be a boolean"))),
228 };
229 self.push_opt(key, err);
230 self
231 }
232
233 pub fn require_non_empty_array(&mut self, key: &str) -> &mut Self {
235 let err = match present(self.input, key).and_then(Value::as_array) {
236 None => Some(("missing", format!("{key} is required and must be an array"))),
237 Some(a) if a.is_empty() => Some(("empty", format!("{key} must not be empty"))),
238 Some(_) => None,
239 };
240 self.push_opt(key, err);
241 self
242 }
243
244 pub fn optional_pct(&mut self, key: &str) -> &mut Self {
246 let err = match present(self.input, key) {
247 None => None,
248 Some(v) => match v.as_f64().filter(|n| n.is_finite()) {
249 Some(n) if (0.0..=100.0).contains(&n) => None,
250 _ => Some(("out_of_range", format!("{key} must be a number in 0..=100"))),
251 },
252 };
253 self.push_opt(key, err);
254 self
255 }
256
257 pub fn optional_range(&mut self, key: &str, min: f64, max: f64) -> &mut Self {
263 let err = match present(self.input, key) {
264 None => None,
265 Some(v) => match v.as_f64().filter(|n| n.is_finite()) {
266 Some(n) if (min..=max).contains(&n) => None,
267 _ => Some((
268 "out_of_range",
269 format!("{key} must be a number in {min}..={max}"),
270 )),
271 },
272 };
273 self.push_opt(key, err);
274 self
275 }
276
277 pub fn optional_non_negative(&mut self, key: &str) -> &mut Self {
279 let err = match present(self.input, key) {
280 None => None,
281 Some(v) => match v.as_f64().filter(|n| n.is_finite()) {
282 Some(n) if n >= 0.0 => None,
283 _ => Some(("out_of_range", format!("{key} must be a finite number ≥ 0"))),
284 },
285 };
286 self.push_opt(key, err);
287 self
288 }
289
290 pub fn finish(&mut self) -> Result<(), PluginError> {
292 if self.errors.is_empty() {
293 Ok(())
294 } else {
295 Err(PluginError::ValidationErrors(std::mem::take(
296 &mut self.errors,
297 )))
298 }
299 }
300}
301
302fn gs1_check_digit_valid(gtin: &str) -> bool {
307 let bytes = gtin.as_bytes();
308 debug_assert_eq!(bytes.len(), 14, "caller must check length == 14 first");
309 let sum: u32 = bytes[..13]
310 .iter()
311 .enumerate()
312 .map(|(i, &b)| {
313 let d = (b - b'0') as u32;
314 if i % 2 == 0 { d * 3 } else { d }
315 })
316 .sum();
317 let expected = (10 - sum % 10) % 10;
318 expected == (bytes[13] - b'0') as u32
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324 use serde_json::json;
325
326 #[test]
327 fn collects_all_failures() {
328 let input = json!({ "gtin": "12-34", "voltage": -1.0 });
329 let err = Validator::new(&input)
330 .require_gtin("gtin")
331 .require_positive("voltage")
332 .require_str("name")
333 .finish()
334 .unwrap_err();
335 match err {
336 PluginError::ValidationErrors(errs) => assert_eq!(errs.len(), 3),
337 other => panic!("expected ValidationErrors, got {other:?}"),
338 }
339 }
340
341 #[test]
342 fn valid_input_passes() {
343 let input = json!({
344 "gtin": "12345678901231",
345 "country": "DE",
346 "pct": 42.0,
347 "count": 3,
348 "flag": true,
349 "items": [1]
350 });
351 assert!(
352 Validator::new(&input)
353 .require_gtin("gtin")
354 .require_country("country")
355 .require_pct("pct")
356 .require_positive_int("count")
357 .require_bool("flag")
358 .require_non_empty_array("items")
359 .finish()
360 .is_ok()
361 );
362 }
363
364 #[test]
365 fn enum_and_country_and_pct_bounds() {
366 let input = json!({ "cls": "Z", "country": "de", "pct": 150.0 });
367 let err = Validator::new(&input)
368 .require_enum("cls", &["A", "B"])
369 .require_country("country")
370 .require_pct("pct")
371 .finish()
372 .unwrap_err();
373 match err {
374 PluginError::ValidationErrors(errs) => assert_eq!(errs.len(), 3),
375 other => panic!("expected ValidationErrors, got {other:?}"),
376 }
377 }
378
379 #[test]
380 fn gtin_invalid_check_digit_is_rejected() {
381 let input = json!({ "gtin": "12345678901234" });
383 let err = Validator::new(&input)
384 .require_gtin("gtin")
385 .finish()
386 .unwrap_err();
387 match err {
388 PluginError::ValidationErrors(errs) => {
389 assert_eq!(errs.len(), 1);
390 assert_eq!(errs[0].code, "checksum");
391 }
392 other => panic!("expected ValidationErrors, got {other:?}"),
393 }
394 }
395
396 #[test]
397 fn gtin_valid_check_digit_passes() {
398 let input = json!({ "gtin": "12345678901231" });
400 assert!(Validator::new(&input).require_gtin("gtin").finish().is_ok());
401 }
402
403 #[test]
404 fn country_not_in_iso_list_is_rejected() {
405 let input = json!({ "country": "XX" });
407 let err = Validator::new(&input)
408 .require_country("country")
409 .finish()
410 .unwrap_err();
411 match err {
412 PluginError::ValidationErrors(errs) => {
413 assert_eq!(errs.len(), 1);
414 assert_eq!(errs[0].code, "invalid");
415 }
416 other => panic!("expected ValidationErrors, got {other:?}"),
417 }
418 }
419
420 #[test]
421 fn country_valid_iso_code_passes() {
422 for code in ["DE", "NO", "FR", "US", "JP"] {
423 let input = json!({ "country": code });
424 assert!(
425 Validator::new(&input)
426 .require_country("country")
427 .finish()
428 .is_ok(),
429 "{code} should be a valid ISO 3166-1 alpha-2 code"
430 );
431 }
432 }
433
434 #[test]
435 fn optional_pct_absent_is_ok_present_out_of_range_fails() {
436 let ok = json!({});
437 assert!(Validator::new(&ok).optional_pct("x").finish().is_ok());
438 let bad = json!({ "x": 101.0 });
439 assert!(Validator::new(&bad).optional_pct("x").finish().is_err());
440 }
441
442 #[test]
443 fn optional_range_absent_ok_present_bounded() {
444 assert!(
446 Validator::new(&json!({}))
447 .optional_range("s", 0.0, 10.0)
448 .finish()
449 .is_ok()
450 );
451 assert!(
453 Validator::new(&json!({ "s": 6.0 }))
454 .optional_range("s", 0.0, 10.0)
455 .finish()
456 .is_ok()
457 );
458 for bad in [json!({ "s": 999999.0 }), json!({ "s": -1.0 })] {
460 assert!(
461 Validator::new(&bad)
462 .optional_range("s", 0.0, 10.0)
463 .finish()
464 .is_err()
465 );
466 }
467 }
468
469 #[test]
470 fn optional_non_negative_absent_ok_negative_fails() {
471 assert!(
472 Validator::new(&json!({}))
473 .optional_non_negative("c")
474 .finish()
475 .is_ok()
476 );
477 assert!(
478 Validator::new(&json!({ "c": 0.0 }))
479 .optional_non_negative("c")
480 .finish()
481 .is_ok()
482 );
483 assert!(
484 Validator::new(&json!({ "c": -999.0 }))
485 .optional_non_negative("c")
486 .finish()
487 .is_err()
488 );
489 }
490
491 #[test]
492 fn readers_extract_values() {
493 let input = json!({ "n": 3.5, "s": "hi", "bad": "x" });
494 assert_eq!(num(&input, "n"), Some(3.5));
495 assert_eq!(num(&input, "bad"), None);
496 assert_eq!(str_of(&input, "s"), Some("hi"));
497 }
498}