1use chrono::NaiveDate;
11use serde::{Deserialize, Serialize};
12
13use crate::error::CalcError;
14use crate::repairability::parameters::RepairabilityInputs;
15use crate::ruleset::{EffectiveDateBound, RegulatoryBasis, Ruleset, RulesetId, RulesetVersion};
16
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22pub struct RepairabilityWeights {
23 pub disassembly: f64,
24 pub spare_parts: f64,
25 pub repair_info: f64,
26 pub diagnostic_tools: f64,
27 pub software_updatability: f64,
28 pub customer_support: f64,
29}
30
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35pub struct RepairabilityThresholds {
36 pub a: f64,
38 pub b: f64,
39 pub c: f64,
40 pub d: f64,
42}
43
44pub trait RepairabilityRuleset: Ruleset {
56 fn weights(&self) -> &RepairabilityWeights;
57 fn thresholds(&self) -> &RepairabilityThresholds;
58
59 fn validate_cross_fields(&self, _inputs: &RepairabilityInputs) -> Result<(), CalcError> {
65 Ok(())
66 }
67}
68
69static SMARTPHONE_WEIGHTS: RepairabilityWeights = RepairabilityWeights {
82 disassembly: 0.25,
83 spare_parts: 0.15,
84 repair_info: 0.15,
85 diagnostic_tools: 0.15,
86 software_updatability: 0.15,
87 customer_support: 0.15,
88};
89
90static SMARTPHONE_THRESHOLDS: RepairabilityThresholds = RepairabilityThresholds {
93 a: 8.5,
94 b: 7.0,
95 c: 5.5,
96 d: 4.0,
97};
98
99static SMARTPHONE_BASIS: RegulatoryBasis = RegulatoryBasis {
100 regulation: "Non-regulatory: simplified repairability heuristic (NOT EU 2023/1669 Annex IV)",
101 article: "Six-factor heuristic — disassembly, spare parts, repair info, \
102 diagnostic tools, software updates, customer support",
103 standard: None,
104 technical_study: None,
105 source_url: None,
106 superseded_by: None,
107};
108
109static SMARTPHONE_RULESET_ID: std::sync::OnceLock<RulesetId> = std::sync::OnceLock::new();
110static SMARTPHONE_RULESET_VERSION: std::sync::OnceLock<RulesetVersion> = std::sync::OnceLock::new();
111static SMARTPHONE_EFFECTIVE_DATES: std::sync::OnceLock<EffectiveDateBound> =
112 std::sync::OnceLock::new();
113
114pub struct SimplifiedRepairabilityHeuristic;
119
120impl Ruleset for SimplifiedRepairabilityHeuristic {
121 fn id(&self) -> &RulesetId {
122 SMARTPHONE_RULESET_ID.get_or_init(|| RulesetId("repairability-heuristic-v1".into()))
123 }
124
125 fn version(&self) -> &RulesetVersion {
126 SMARTPHONE_RULESET_VERSION.get_or_init(|| RulesetVersion("1.0.0".into()))
127 }
128
129 fn effective_dates(&self) -> &EffectiveDateBound {
130 SMARTPHONE_EFFECTIVE_DATES.get_or_init(|| {
131 EffectiveDateBound::open(NaiveDate::from_ymd_opt(2025, 6, 20).expect("valid date"))
132 })
133 }
134
135 fn regulatory_basis(&self) -> &RegulatoryBasis {
136 &SMARTPHONE_BASIS
137 }
138}
139
140impl RepairabilityRuleset for SimplifiedRepairabilityHeuristic {
141 fn weights(&self) -> &RepairabilityWeights {
142 &SMARTPHONE_WEIGHTS
143 }
144
145 fn thresholds(&self) -> &RepairabilityThresholds {
146 &SMARTPHONE_THRESHOLDS
147 }
148
149 fn validate_cross_fields(&self, inputs: &RepairabilityInputs) -> Result<(), CalcError> {
150 if inputs.disassembly == 0 && inputs.spare_parts > 0 {
154 return Err(CalcError::CrossFieldViolation(
155 "spare_parts requires disassembly ≥ 1: parts are inaccessible \
156 without disassembly instructions"
157 .into(),
158 ));
159 }
160 Ok(())
161 }
162}
163
164pub struct LaptopRuleset;
174
175static LAPTOP_WEIGHTS: RepairabilityWeights = RepairabilityWeights {
176 disassembly: 0.20,
177 spare_parts: 0.25,
178 repair_info: 0.20,
179 diagnostic_tools: 0.10,
180 software_updatability: 0.15,
181 customer_support: 0.10,
182};
183
184static LAPTOP_THRESHOLDS: RepairabilityThresholds = RepairabilityThresholds {
185 a: 8.5,
186 b: 7.0,
187 c: 5.5,
188 d: 4.0,
189};
190
191static LAPTOP_BASIS: RegulatoryBasis = RegulatoryBasis {
192 regulation: "pending — ESPR laptop repairability delegated act (expected ~2027)",
193 article: "TBD",
194 standard: Some("EN 45554:2021"),
195 technical_study: None,
196 source_url: None,
197 superseded_by: None,
198};
199
200static LAPTOP_RULESET_ID: std::sync::OnceLock<RulesetId> = std::sync::OnceLock::new();
201static LAPTOP_RULESET_VERSION: std::sync::OnceLock<RulesetVersion> = std::sync::OnceLock::new();
202static LAPTOP_EFFECTIVE_DATES: std::sync::OnceLock<EffectiveDateBound> = std::sync::OnceLock::new();
203
204impl Ruleset for LaptopRuleset {
205 fn id(&self) -> &RulesetId {
206 LAPTOP_RULESET_ID.get_or_init(|| RulesetId("laptop-repairability".into()))
207 }
208
209 fn version(&self) -> &RulesetVersion {
210 LAPTOP_RULESET_VERSION.get_or_init(|| RulesetVersion("0.0.0-stub".into()))
211 }
212
213 fn effective_dates(&self) -> &EffectiveDateBound {
214 LAPTOP_EFFECTIVE_DATES.get_or_init(|| {
217 EffectiveDateBound::open(NaiveDate::from_ymd_opt(2100, 1, 1).expect("valid date"))
218 })
219 }
220
221 fn regulatory_basis(&self) -> &RegulatoryBasis {
222 &LAPTOP_BASIS
223 }
224}
225
226impl RepairabilityRuleset for LaptopRuleset {
227 fn weights(&self) -> &RepairabilityWeights {
228 &LAPTOP_WEIGHTS
229 }
230
231 fn thresholds(&self) -> &RepairabilityThresholds {
232 &LAPTOP_THRESHOLDS
233 }
234}
235
236pub struct DisplaysRuleset;
245
246static DISPLAYS_WEIGHTS: RepairabilityWeights = RepairabilityWeights {
247 disassembly: 0.20,
248 spare_parts: 0.20,
249 repair_info: 0.20,
250 diagnostic_tools: 0.15,
251 software_updatability: 0.15,
252 customer_support: 0.10,
253};
254
255static DISPLAYS_THRESHOLDS: RepairabilityThresholds = RepairabilityThresholds {
256 a: 8.5,
257 b: 7.0,
258 c: 5.5,
259 d: 4.0,
260};
261
262static DISPLAYS_BASIS: RegulatoryBasis = RegulatoryBasis {
263 regulation: "pending — ESPR electronic displays repairability delegated act",
264 article: "TBD",
265 standard: Some("EN 45554:2021"),
266 technical_study: None,
267 source_url: None,
268 superseded_by: None,
269};
270
271static DISPLAYS_RULESET_ID: std::sync::OnceLock<RulesetId> = std::sync::OnceLock::new();
272static DISPLAYS_RULESET_VERSION: std::sync::OnceLock<RulesetVersion> = std::sync::OnceLock::new();
273static DISPLAYS_EFFECTIVE_DATES: std::sync::OnceLock<EffectiveDateBound> =
274 std::sync::OnceLock::new();
275
276impl Ruleset for DisplaysRuleset {
277 fn id(&self) -> &RulesetId {
278 DISPLAYS_RULESET_ID.get_or_init(|| RulesetId("displays-repairability".into()))
279 }
280
281 fn version(&self) -> &RulesetVersion {
282 DISPLAYS_RULESET_VERSION.get_or_init(|| RulesetVersion("0.0.0-stub".into()))
283 }
284
285 fn effective_dates(&self) -> &EffectiveDateBound {
286 DISPLAYS_EFFECTIVE_DATES.get_or_init(|| {
287 EffectiveDateBound::open(NaiveDate::from_ymd_opt(2100, 1, 1).expect("valid date"))
288 })
289 }
290
291 fn regulatory_basis(&self) -> &RegulatoryBasis {
292 &DISPLAYS_BASIS
293 }
294}
295
296impl RepairabilityRuleset for DisplaysRuleset {
297 fn weights(&self) -> &RepairabilityWeights {
298 &DISPLAYS_WEIGHTS
299 }
300
301 fn thresholds(&self) -> &RepairabilityThresholds {
302 &DISPLAYS_THRESHOLDS
303 }
304}
305
306pub struct WashingMachineRuleset;
315
316static WASHING_WEIGHTS: RepairabilityWeights = RepairabilityWeights {
317 disassembly: 0.25,
318 spare_parts: 0.20,
319 repair_info: 0.20,
320 diagnostic_tools: 0.15,
321 software_updatability: 0.10,
322 customer_support: 0.10,
323};
324
325static WASHING_THRESHOLDS: RepairabilityThresholds = RepairabilityThresholds {
326 a: 8.5,
327 b: 7.0,
328 c: 5.5,
329 d: 4.0,
330};
331
332static WASHING_BASIS: RegulatoryBasis = RegulatoryBasis {
333 regulation: "pending — ESPR washing machine repairability delegated act (expected ~2026)",
334 article: "TBD",
335 standard: Some("EN 45554:2021"),
336 technical_study: None,
337 source_url: None,
338 superseded_by: None,
339};
340
341static WASHING_RULESET_ID: std::sync::OnceLock<RulesetId> = std::sync::OnceLock::new();
342static WASHING_RULESET_VERSION: std::sync::OnceLock<RulesetVersion> = std::sync::OnceLock::new();
343static WASHING_EFFECTIVE_DATES: std::sync::OnceLock<EffectiveDateBound> =
344 std::sync::OnceLock::new();
345
346impl Ruleset for WashingMachineRuleset {
347 fn id(&self) -> &RulesetId {
348 WASHING_RULESET_ID.get_or_init(|| RulesetId("washing-machine-repairability".into()))
349 }
350
351 fn version(&self) -> &RulesetVersion {
352 WASHING_RULESET_VERSION.get_or_init(|| RulesetVersion("0.0.0-stub".into()))
353 }
354
355 fn effective_dates(&self) -> &EffectiveDateBound {
356 WASHING_EFFECTIVE_DATES.get_or_init(|| {
357 EffectiveDateBound::open(NaiveDate::from_ymd_opt(2100, 1, 1).expect("valid date"))
358 })
359 }
360
361 fn regulatory_basis(&self) -> &RegulatoryBasis {
362 &WASHING_BASIS
363 }
364}
365
366impl RepairabilityRuleset for WashingMachineRuleset {
367 fn weights(&self) -> &RepairabilityWeights {
368 &WASHING_WEIGHTS
369 }
370
371 fn thresholds(&self) -> &RepairabilityThresholds {
372 &WASHING_THRESHOLDS
373 }
374}
375
376#[cfg(test)]
377mod stub_ruleset_tests {
378 use super::*;
379 use crate::error::CalcError;
380 use crate::repairability::calculate;
381
382 fn valid_inputs() -> RepairabilityInputs {
383 RepairabilityInputs {
384 disassembly: 2,
385 spare_parts: 2,
386 repair_info: 2,
387 diagnostic_tools: 2,
388 software_updatability: 2,
389 customer_support: 2,
390 }
391 }
392
393 #[test]
394 fn stub_rulesets_expose_consistent_metadata() {
395 let rulesets: [&dyn RepairabilityRuleset; 3] =
396 [&LaptopRuleset, &DisplaysRuleset, &WashingMachineRuleset];
397 for rs in rulesets {
398 let w = rs.weights();
399 let sum = w.disassembly
400 + w.spare_parts
401 + w.repair_info
402 + w.diagnostic_tools
403 + w.software_updatability
404 + w.customer_support;
405 assert!((sum - 1.0).abs() < 1e-9, "weights must sum to 1.0");
406 assert_eq!(rs.thresholds().a, 8.5);
407 assert!(!rs.id().0.is_empty());
408 assert!(!rs.version().0.is_empty());
409 assert!(!rs.regulatory_basis().regulation.is_empty());
410 assert!(
412 !rs.effective_dates()
413 .is_active_on(chrono::Utc::now().date_naive())
414 );
415 }
416 }
417
418 #[test]
419 fn calculating_with_a_not_yet_in_force_ruleset_is_rejected() {
420 for result in [
423 calculate(&valid_inputs(), &LaptopRuleset),
424 calculate(&valid_inputs(), &DisplaysRuleset),
425 calculate(&valid_inputs(), &WashingMachineRuleset),
426 ] {
427 assert!(matches!(result, Err(CalcError::RulesetExpired { .. })));
428 }
429 }
430}