dpp_rules/electronics/repairability_index_scope.rs
1//! Which products the repairability index reaches — Reg. (EU) 2023/1669 **Art. 1**.
2//!
3//! ✅ COMPLIANCE-PIN: EU 2023/1669, Art. 1 (OJ L 214, 31.8.2023, p. 12). Read
4//! from the Official Journal text.
5//!
6//! ## Two regulations, two scopes, and the wider one is the one `DeviceType` follows
7//!
8//! Art. 1 of **2023/1669** — the energy-labelling regulation that enacts the
9//! repairability index — establishes requirements for *"smartphones and slate
10//! tablets"*. Art. 1(1) of **2023/1670**, its ecodesign twin, reaches
11//! *"smartphones, other mobile phones, cordless phones and slate tablets"*.
12//!
13//! `DeviceType`'s four values come from the wider one, which is correct for what
14//! that type is for and wrong as an input to this index. A cordless phone is not
15//! a "mobile phone" under 2023/1670 Art. 2(1) at all, and neither it nor a
16//! non-smart mobile phone is in 2023/1669's scope.
17//!
18//! ## The two exclusions, which are the reason this is not a two-line match
19//!
20//! Art. 1 continues, and both regulations carry the carve-out in identical words:
21//!
22//! > This Regulation does not apply to the following products:
23//! > (a) mobile phones and tablets with a flexible main display which the user
24//! > can unroll and roll up partly or fully;
25//! > (b) smartphones for high security communication.
26//!
27//! A rollable-display phone and a high-security smartphone are both smartphones.
28//! Nothing about the device type distinguishes them, so the exclusion has to be
29//! **declared** — which is why this function takes it as a second argument rather
30//! than deriving it.
31//!
32//! ## Why an undeclared exclusion means *not excluded*
33//!
34//! The carve-out is what removes the obligation, so silence cannot grant it: an
35//! operator who says nothing has not claimed an exclusion, and reading the
36//! absence as one would exempt a product on the strength of a missing field.
37//!
38//! That is the same fail-closed direction as
39//! [`PassportScope::CapacityUnknown`](crate::batteries::passport_scope::PassportScope::CapacityUnknown)
40//! and the opposite arithmetic. There the *obligation* turns on a number, so an
41//! unstated number cannot exempt; here the *exemption* is what is stated, so an
42//! unstated exemption does not exist. Both refuse to exempt on a missing field,
43//! which is the property worth holding onto rather than the rule that produces it.
44//!
45//! ## What this does not answer
46//!
47//! Whether a passport should carry the index or its inputs at all. **The word
48//! "passport" does not occur in either regulation** — the index belongs on the
49//! label and in the product information sheet. That is a product question, and no
50//! amount of further reading converts it into a legal one.
51
52/// Whether Reg. (EU) 2023/1669's repairability index reaches a product.
53///
54/// Three outcomes rather than a `bool`. "This device type is outside the act"
55/// and "this unit is carved out of it" are different sentences to put in front of
56/// an operator, and they have different remedies — the first is never fixable,
57/// the second is a declaration the operator made. Same reasoning as
58/// [`PassportScope`](crate::batteries::passport_scope::PassportScope).
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60#[non_exhaustive]
61pub enum RepairabilityIndexScope {
62 /// Art. 1 names this device type and no exclusion is declared.
63 Covered,
64 /// Art. 1 does not name this device type — a cordless phone, or a mobile
65 /// phone that is not a smartphone. In scope of 2023/1670, not of this act.
66 NotCovered,
67 /// Art. 1(a) — a flexible main display the user can unroll and roll up.
68 ExcludedRollableDisplay,
69 /// Art. 1(b) — a smartphone for high security communication.
70 ExcludedHighSecurity,
71}
72
73impl RepairabilityIndexScope {
74 /// Whether an index is owed on the strength of this answer alone.
75 ///
76 /// `true` only for [`Covered`](Self::Covered). A caller that needs to tell
77 /// *why not* apart must match the variant, which is why this is not an
78 /// `Option<bool>`.
79 #[must_use]
80 pub fn is_covered(self) -> bool {
81 matches!(self, Self::Covered)
82 }
83
84 /// Whether the answer rests on an exclusion the operator declared.
85 ///
86 /// Exists so a caller can distinguish "the act never reached this" from
87 /// "the act reaches this type and the operator says this unit is carved
88 /// out" — the second is a claim that can be wrong, and the first cannot.
89 #[must_use]
90 pub fn is_declared_exclusion(self) -> bool {
91 matches!(
92 self,
93 Self::ExcludedRollableDisplay | Self::ExcludedHighSecurity
94 )
95 }
96}
97
98/// Whether `device_type` owes a repairability index under Reg. (EU) 2023/1669.
99///
100/// Both arguments are **wire** names — `"smartphone"`, `"tablet"`,
101/// `"other-mobile-phone"`, `"cordless-phone"` and `"rollable-display"`,
102/// `"high-security-communication"` — because this crate is `no_std` and
103/// zero-dependency and is read by the Wasm product group plugins, which see JSON
104/// and never the Rust enums. Matching is case-insensitive and trims surrounding
105/// whitespace, as in [`crate::batteries::passport_scope`].
106///
107/// `exclusion` is `None` where the operator declared none, which means **not
108/// excluded** — see the module documentation for why silence cannot grant a
109/// carve-out. An unrecognised exclusion string is likewise not an exclusion: a
110/// value this build does not know is not a value it may act on.
111///
112/// The device-type check runs **first**. A cordless phone declaring a rollable
113/// display answers [`NotCovered`](RepairabilityIndexScope::NotCovered), because
114/// the act never reached it and an exclusion from an act that does not apply is
115/// not the reason to give an operator.
116#[must_use]
117pub fn repairability_index_scope(
118 device_type: &str,
119 exclusion: Option<&str>,
120) -> RepairabilityIndexScope {
121 let t = device_type.trim();
122 let named = t.eq_ignore_ascii_case("smartphone") || t.eq_ignore_ascii_case("tablet");
123 if !named {
124 return RepairabilityIndexScope::NotCovered;
125 }
126
127 match exclusion.map(str::trim) {
128 Some(e) if e.eq_ignore_ascii_case("rollable-display") => {
129 RepairabilityIndexScope::ExcludedRollableDisplay
130 }
131 Some(e) if e.eq_ignore_ascii_case("high-security-communication") => {
132 RepairabilityIndexScope::ExcludedHighSecurity
133 }
134 _ => RepairabilityIndexScope::Covered,
135 }
136}