Skip to main content

dpp_rules/textiles/
fibre.rs

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