Skip to main content

dpp_plugin_sdk/
validate.rs

1//! Reusable field validation for sector plugins.
2//!
3//! [`Validator`] is a fluent collector: each `require_*` / `optional_*` method
4//! records a [`PluginFieldError`] when a check fails and is chainable, so a
5//! plugin's `validate_input` reads as a declarative list of field constraints.
6//! [`Validator::finish`] reports *all* failures at once rather than stopping at
7//! the first — better for surfacing form errors to a manufacturer.
8//!
9//! The free functions [`num`] and [`str_of`] are convenience readers for
10//! `calculate_metrics` bodies, and [`threshold_status`] is the shared
11//! "measured value at or under threshold" classification they compare against.
12
13use dpp_plugin_traits::{PluginComplianceStatus, PluginError, PluginFieldError, PluginInput};
14use serde_json::Value;
15
16/// A present, non-null value for `key`, or `None` if absent/null.
17fn 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/// Read a finite number field (ignores absent/non-numeric/NaN/inf).
25#[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/// Read a string field.
34#[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/// Classify a measured value against a compliance threshold: `Compliant` if
40/// present and at or under `threshold`, `NonCompliant` otherwise (including
41/// when `value` is absent — a missing measurement is not assumed compliant).
42#[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
51/// Fluent per-field validator. See module docs.
52pub 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    /// Require a present, non-empty string.
77    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    /// Require a string field whose value is one of `allowed`.
91    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    /// Require a 14-digit GS1 GTIN string with a valid check digit.
102    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    /// Require a recognized ISO 3166-1 alpha-2 country code.
119    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    /// Require a present, finite number greater than 0.
142    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    /// Require a present, finite number greater than or equal to 0.
156    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    /// Require a present, finite number in `[0, 100]`.
170    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    /// Require a present non-negative integer that is at least 1.
186    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    /// Require a present boolean.
200    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    /// Require a present, non-empty array.
211    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    /// If present (and non-null), the value must be a finite number in `[0, 100]`.
222    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    /// If present (and non-null), the value must be a finite number in `[min, max]`.
235    ///
236    /// For a field that is optional (its absence is meaningful) but must be
237    /// bounded when supplied — e.g. a 0–10 repairability score that gates a
238    /// verdict only when present.
239    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    /// If present (and non-null), the value must be a finite number ≥ 0.
255    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    /// Finish validation, returning every collected error at once.
268    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
279/// GS1 check-digit validation for GTIN-14.
280///
281/// Even-indexed positions (0, 2, 4, … 12) are weighted ×3; odd-indexed ×1.
282/// The check digit at position 13 must equal `(10 − sum mod 10) mod 10`.
283fn 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        // "12345678901234" — correct digits, correct length, but check digit should be 1, not 4.
359        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        // "12345678901231" — check digit = 1, matches GS1 calculation.
376        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        // "XX" has the right format (2 uppercase letters) but is not an assigned code.
383        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        // Absent → ok (the field's absence is meaningful).
422        assert!(
423            Validator::new(&json!({}))
424                .optional_range("s", 0.0, 10.0)
425                .finish()
426                .is_ok()
427        );
428        // In range → ok.
429        assert!(
430            Validator::new(&json!({ "s": 6.0 }))
431                .optional_range("s", 0.0, 10.0)
432                .finish()
433                .is_ok()
434        );
435        // Out of range → err (the fail-open case the plugins guard against).
436        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}