Skip to main content

dpp_rules/lint/
textile.rs

1//! Textile plausibility lints — consistency checks the EU textile DPP rules
2//! do not themselves require, but that flag likely mistakes or claims made
3//! without their usual supporting field.
4
5use alloc::{format, vec::Vec};
6
7use super::{LintFinding, LintSeverity};
8
9/// Borrowing view over the textile fields these lints inspect.
10#[derive(Debug, Clone, Copy)]
11pub struct TextileLintInput<'a> {
12    pub durability_score: Option<f64>,
13    pub expected_wash_cycles: Option<u32>,
14    pub repair_count: Option<u32>,
15    pub repair_history_url: Option<&'a str>,
16    pub prior_use_cycles: Option<u32>,
17    pub reuse_condition: Option<&'a str>,
18    pub repair_score: Option<f64>,
19    pub disassembly_instructions: Option<&'a str>,
20    pub spare_parts_available: Option<bool>,
21    pub microplastic_shedding_mg_per_wash: Option<f64>,
22    pub fibres: &'a [&'a str],
23}
24
25const HIGH_DURABILITY_THRESHOLD: f64 = 8.0;
26const LOW_WASH_CYCLES_THRESHOLD: u32 = 10;
27
28/// Cross-field plausibility: a garment declared highly durable (≥8/10) that
29/// is also expected to degrade within under 10 wash cycles is self-contradictory.
30#[must_use]
31pub fn durability_wash_cycles_mismatch(input: &TextileLintInput<'_>) -> Option<LintFinding> {
32    let durability = input.durability_score?;
33    let cycles = input.expected_wash_cycles?;
34    if durability < HIGH_DURABILITY_THRESHOLD || cycles >= LOW_WASH_CYCLES_THRESHOLD {
35        return None;
36    }
37    Some(LintFinding {
38        code: "textile.durability_wash_cycles_mismatch",
39        field: "expectedWashCycles",
40        severity: LintSeverity::Notice,
41        message: format!(
42            "durabilityScore ({durability:.1}/10) is high but expectedWashCycles ({cycles}) is low — intended?"
43        ),
44    })
45}
46
47/// Claim-without-evidence check: a positive `repairCount` with no
48/// `repairHistoryUrl` asserts repairs happened with nothing to point to.
49#[must_use]
50pub fn repair_count_without_history(input: &TextileLintInput<'_>) -> Option<LintFinding> {
51    let count = input.repair_count?;
52    if count == 0 || input.repair_history_url.is_some() {
53        return None;
54    }
55    Some(LintFinding {
56        code: "textile.repair_count_without_history",
57        field: "repairCount",
58        severity: LintSeverity::Notice,
59        message: format!("repairCount is {count} but repairHistoryUrl is absent — intended?"),
60    })
61}
62
63/// Claim-without-evidence check: a positive `priorUseCycles` (this is not a
64/// new item) with no `reuseCondition` grade omits the condition a buyer of a
65/// used item would expect.
66#[must_use]
67pub fn prior_use_without_reuse_condition(input: &TextileLintInput<'_>) -> Option<LintFinding> {
68    let cycles = input.prior_use_cycles?;
69    if cycles == 0 || input.reuse_condition.is_some() {
70        return None;
71    }
72    Some(LintFinding {
73        code: "textile.prior_use_without_reuse_condition",
74        field: "priorUseCycles",
75        severity: LintSeverity::Notice,
76        message: format!("priorUseCycles is {cycles} but reuseCondition is absent — intended?"),
77    })
78}
79
80const HIGH_REPAIR_SCORE_THRESHOLD: f64 = 8.0;
81
82/// Claim-without-evidence check: a high `repairScore` (≥8/10) with neither
83/// disassembly instructions nor confirmed spare-parts availability asserts
84/// repairability with nothing backing it.
85#[must_use]
86pub fn repair_score_high_without_support(input: &TextileLintInput<'_>) -> Option<LintFinding> {
87    let score = input.repair_score?;
88    if score < HIGH_REPAIR_SCORE_THRESHOLD
89        || input.disassembly_instructions.is_some()
90        || input.spare_parts_available == Some(true)
91    {
92        return None;
93    }
94    Some(LintFinding {
95        code: "textile.repair_score_high_without_support",
96        field: "repairScore",
97        severity: LintSeverity::Notice,
98        message: format!(
99            "repairScore ({score:.1}/10) is high but neither disassemblyInstructions nor \
100             sparePartsAvailable is declared — intended?"
101        ),
102    })
103}
104
105const KNOWN_NATURAL_FIBRES: &[&str] = &[
106    "cotton", "wool", "silk", "linen", "hemp", "jute", "cashmere", "alpaca", "mohair", "flax",
107    "ramie", "angora",
108];
109
110fn is_known_natural(fibre: &str) -> bool {
111    let normalized = fibre.trim();
112    KNOWN_NATURAL_FIBRES
113        .iter()
114        .any(|f| normalized.eq_ignore_ascii_case(f))
115}
116
117/// Physics check: microplastic fibre shedding (ISO/DIS 4484) is a
118/// synthetic-polymer phenomenon — natural fibres like cotton or wool do not
119/// shed microplastics on washing. Fires only when *every* declared fibre is
120/// recognised as natural, to avoid false positives on unrecognised or blended
121/// synthetic names.
122#[must_use]
123pub fn microplastic_shedding_without_synthetic_fibre(
124    input: &TextileLintInput<'_>,
125) -> Option<LintFinding> {
126    let shedding = input.microplastic_shedding_mg_per_wash?;
127    if !shedding.is_finite() || shedding <= 0.0 {
128        return None;
129    }
130    if input.fibres.is_empty() || !input.fibres.iter().all(|f| is_known_natural(f)) {
131        return None;
132    }
133    Some(LintFinding {
134        code: "textile.microplastic_shedding_without_synthetic_fibre",
135        field: "microplasticSheddingMgPerWash",
136        severity: LintSeverity::Notice,
137        message: format!(
138            "microplasticSheddingMgPerWash ({shedding:.2} mg) is declared but every fibre in \
139             fibreComposition is a natural fibre — intended?"
140        ),
141    })
142}
143
144/// Run every textile plausibility lint and collect the findings.
145#[must_use]
146pub fn lint_textile(input: &TextileLintInput<'_>) -> Vec<LintFinding> {
147    let mut out = Vec::new();
148    out.extend(durability_wash_cycles_mismatch(input));
149    out.extend(repair_count_without_history(input));
150    out.extend(prior_use_without_reuse_condition(input));
151    out.extend(repair_score_high_without_support(input));
152    out.extend(microplastic_shedding_without_synthetic_fibre(input));
153    out
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    fn base_input() -> TextileLintInput<'static> {
161        TextileLintInput {
162            durability_score: Some(5.0),
163            expected_wash_cycles: Some(50),
164            repair_count: None,
165            repair_history_url: None,
166            prior_use_cycles: None,
167            reuse_condition: None,
168            repair_score: Some(5.0),
169            disassembly_instructions: None,
170            spare_parts_available: None,
171            microplastic_shedding_mg_per_wash: None,
172            fibres: &["cotton", "polyester"],
173        }
174    }
175
176    // ── durability_wash_cycles_mismatch ─────────────────────────────────────
177
178    #[test]
179    fn durability_wash_cycles_consistent_passes() {
180        assert!(durability_wash_cycles_mismatch(&base_input()).is_none());
181    }
182
183    #[test]
184    fn high_durability_low_cycles_triggers() {
185        let mut input = base_input();
186        input.durability_score = Some(9.0);
187        input.expected_wash_cycles = Some(5);
188        let finding = durability_wash_cycles_mismatch(&input).unwrap();
189        assert_eq!(finding.code, "textile.durability_wash_cycles_mismatch");
190    }
191
192    // ── repair_count_without_history ────────────────────────────────────────
193
194    #[test]
195    fn no_repairs_passes() {
196        assert!(repair_count_without_history(&base_input()).is_none());
197    }
198
199    #[test]
200    fn repairs_without_url_triggers() {
201        let mut input = base_input();
202        input.repair_count = Some(3);
203        let finding = repair_count_without_history(&input).unwrap();
204        assert_eq!(finding.code, "textile.repair_count_without_history");
205    }
206
207    #[test]
208    fn repairs_with_url_passes() {
209        let mut input = base_input();
210        input.repair_count = Some(3);
211        input.repair_history_url = Some("https://example.com/repairs/123");
212        assert!(repair_count_without_history(&input).is_none());
213    }
214
215    // ── prior_use_without_reuse_condition ───────────────────────────────────
216
217    #[test]
218    fn new_item_passes() {
219        assert!(prior_use_without_reuse_condition(&base_input()).is_none());
220    }
221
222    #[test]
223    fn prior_use_without_condition_triggers() {
224        let mut input = base_input();
225        input.prior_use_cycles = Some(2);
226        let finding = prior_use_without_reuse_condition(&input).unwrap();
227        assert_eq!(finding.code, "textile.prior_use_without_reuse_condition");
228    }
229
230    // ── repair_score_high_without_support ───────────────────────────────────
231
232    #[test]
233    fn moderate_repair_score_passes() {
234        assert!(repair_score_high_without_support(&base_input()).is_none());
235    }
236
237    #[test]
238    fn high_repair_score_without_support_triggers() {
239        let mut input = base_input();
240        input.repair_score = Some(9.0);
241        let finding = repair_score_high_without_support(&input).unwrap();
242        assert_eq!(finding.code, "textile.repair_score_high_without_support");
243    }
244
245    #[test]
246    fn high_repair_score_with_spare_parts_passes() {
247        let mut input = base_input();
248        input.repair_score = Some(9.0);
249        input.spare_parts_available = Some(true);
250        assert!(repair_score_high_without_support(&input).is_none());
251    }
252
253    // ── microplastic_shedding_without_synthetic_fibre ───────────────────────
254
255    #[test]
256    fn shedding_with_synthetic_fibre_passes() {
257        let mut input = base_input();
258        input.microplastic_shedding_mg_per_wash = Some(12.0); // fibres include polyester
259        assert!(microplastic_shedding_without_synthetic_fibre(&input).is_none());
260    }
261
262    #[test]
263    fn shedding_with_only_natural_fibres_triggers() {
264        let mut input = base_input();
265        input.fibres = &["cotton", "wool"];
266        input.microplastic_shedding_mg_per_wash = Some(12.0);
267        let finding = microplastic_shedding_without_synthetic_fibre(&input).unwrap();
268        assert_eq!(
269            finding.code,
270            "textile.microplastic_shedding_without_synthetic_fibre"
271        );
272    }
273
274    #[test]
275    fn no_shedding_declared_passes() {
276        let mut input = base_input();
277        input.fibres = &["cotton"];
278        assert!(microplastic_shedding_without_synthetic_fibre(&input).is_none());
279    }
280
281    // ── lint_textile aggregator ─────────────────────────────────────────────
282
283    #[test]
284    fn clean_input_produces_no_findings() {
285        assert!(lint_textile(&base_input()).is_empty());
286    }
287}