dpp_rules/lint/
textile.rs1use alloc::{format, vec::Vec};
6
7use super::{LintFinding, LintSeverity};
8
9#[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#[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#[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#[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#[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#[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#[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 #[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 #[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 #[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 #[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 #[test]
256 fn shedding_with_synthetic_fibre_passes() {
257 let mut input = base_input();
258 input.microplastic_shedding_mg_per_wash = Some(12.0); 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 #[test]
284 fn clean_input_produces_no_findings() {
285 assert!(lint_textile(&base_input()).is_empty());
286 }
287}