Skip to main content

dpp_rules/textiles/
fibre.rs

1//! Fibre composition validation — EU textile DPP regulation.
2
3use alloc::{format, string::String};
4
5use crate::common::country::country_code_valid;
6use crate::common::numeric::{percentage_in_range, sums_to};
7
8// ⚠️ COMPLIANCE-PIN PENDING (watchlist 🟠): 2.0 pp tolerance is the commonly
9// cited value but confirm against EU 1007/2011 Annex IX (agreed tolerance for
10// fibre composition declarations) before PINning.
11/// Tolerance (percentage points) allowed when fibre percentages are summed.
12pub const FIBRE_SUM_TOLERANCE: f64 = 2.0;
13
14/// A fibre entry for composition validation.
15#[derive(Debug, Clone, Copy)]
16pub struct FibreInput<'a> {
17    pub fibre: &'a str,
18    pub pct: f64,
19    pub country_of_origin: Option<&'a str>,
20}
21
22/// Whether fibre percentages sum to ~100% (± [`FIBRE_SUM_TOLERANCE`]).
23///
24/// Used by plugins for the compliance determination and by
25/// [`validate_fibre_composition`] for the cross-field validation rule.
26#[must_use]
27pub fn fibre_sum_ok(pcts: &[f64]) -> bool {
28    sums_to(pcts.iter().copied(), 100.0, FIBRE_SUM_TOLERANCE).0
29}
30
31/// Validate a textile fibre composition: non-empty, each `pct` in `[0, 100]`,
32/// any `country_of_origin` a valid ISO 3166-1 alpha-2 code, and the percentages
33/// summing to ~100% (± [`FIBRE_SUM_TOLERANCE`]).
34pub fn validate_fibre_composition(fibres: &[FibreInput<'_>]) -> Result<(), String> {
35    if fibres.is_empty() {
36        return Err(String::from("fibre_composition must not be empty"));
37    }
38    for f in fibres {
39        if !percentage_in_range(f.pct) {
40            return Err(format!(
41                "fibre '{}' has invalid pct {} — must be a finite value in 0–100",
42                f.fibre, f.pct
43            ));
44        }
45        if let Some(co) = f.country_of_origin
46            && !country_code_valid(co)
47        {
48            return Err(format!(
49                "fibre '{}' has invalid country_of_origin '{}' — must be ISO 3166-1 alpha-2",
50                f.fibre, co
51            ));
52        }
53    }
54    let (sum_ok, total) = sums_to(fibres.iter().map(|f| f.pct), 100.0, FIBRE_SUM_TOLERANCE);
55    if !sum_ok {
56        return Err(format!(
57            "fibreComposition percentages sum to {total:.1}, expected 100.0 (± {FIBRE_SUM_TOLERANCE:.1})"
58        ));
59    }
60    Ok(())
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66
67    fn fibre<'a>(name: &'a str, pct: f64) -> FibreInput<'a> {
68        FibreInput {
69            fibre: name,
70            pct,
71            country_of_origin: None,
72        }
73    }
74
75    #[test]
76    fn fibre_sum_within_tolerance() {
77        assert!(fibre_sum_ok(&[60.0, 40.0]));
78        assert!(fibre_sum_ok(&[98.5, 1.0])); // 99.5 within ±2
79        assert!(!fibre_sum_ok(&[60.0, 30.0])); // 90
80        assert!(!fibre_sum_ok(&[])); // empty → 0
81    }
82
83    #[test]
84    fn fibre_composition_valid_passes() {
85        assert!(
86            validate_fibre_composition(&[fibre("cotton", 60.0), fibre("polyester", 40.0)]).is_ok()
87        );
88    }
89
90    #[test]
91    fn fibre_sum_invalid_message_has_total() {
92        let err = validate_fibre_composition(&[fibre("cotton", 60.0), fibre("polyester", 30.0)])
93            .unwrap_err();
94        assert!(err.contains("90.0"), "unexpected: {err}");
95    }
96
97    #[test]
98    fn fibre_invalid_country_rejected() {
99        let entry = FibreInput {
100            fibre: "cotton",
101            pct: 100.0,
102            country_of_origin: Some("india"),
103        };
104        let err = validate_fibre_composition(&[entry]).unwrap_err();
105        assert!(err.contains("country_of_origin"), "unexpected: {err}");
106    }
107
108    #[test]
109    fn fibre_empty_rejected() {
110        assert!(validate_fibre_composition(&[]).is_err());
111    }
112
113    #[test]
114    fn nan_pct_rejected() {
115        let err = validate_fibre_composition(&[fibre("cotton", f64::NAN)]).unwrap_err();
116        assert!(
117            err.contains("NaN") || err.contains("finite"),
118            "unexpected: {err}"
119        );
120    }
121
122    #[test]
123    fn infinity_pct_rejected() {
124        let err = validate_fibre_composition(&[fibre("cotton", f64::INFINITY)]).unwrap_err();
125        assert!(
126            err.contains("inf") || err.contains("finite"),
127            "unexpected: {err}"
128        );
129    }
130}