1pub mod parameters;
32pub mod thresholds;
33
34#[cfg(test)]
35mod golden_vectors;
36
37use chrono::Utc;
38use serde::{Deserialize, Serialize};
39
40use crate::error::CalcError;
41use crate::receipt::{CalculationReceipt, input_hash, jcs_hash};
42
43pub use parameters::RepairabilityInputs;
44pub use thresholds::{
45 DisplaysRuleset, LaptopRuleset, RepairabilityRuleset, SimplifiedRepairabilityHeuristic,
46 WashingMachineRuleset,
47};
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53pub enum RepairabilityClass {
54 A,
55 B,
56 C,
57 D,
58 E,
59}
60
61impl RepairabilityClass {
62 pub fn as_ordinal(self) -> u8 {
64 match self {
65 Self::A => 5,
66 Self::B => 4,
67 Self::C => 3,
68 Self::D => 2,
69 Self::E => 1,
70 }
71 }
72}
73
74impl std::fmt::Display for RepairabilityClass {
75 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76 write!(
77 f,
78 "{}",
79 match self {
80 Self::A => "A",
81 Self::B => "B",
82 Self::C => "C",
83 Self::D => "D",
84 Self::E => "E",
85 }
86 )
87 }
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct ParameterContributions {
97 pub disassembly: f64,
98 pub spare_parts: f64,
99 pub repair_info: f64,
100 pub diagnostic_tools: f64,
101 pub software_updatability: f64,
102 pub customer_support: f64,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct RepairabilityResult {
108 pub class: RepairabilityClass,
110 pub numeric_score: f64,
112 pub contributions: ParameterContributions,
114 pub receipt: CalculationReceipt,
116}
117
118pub fn calculate(
124 inputs: &RepairabilityInputs,
125 ruleset: &dyn RepairabilityRuleset,
126) -> Result<RepairabilityResult, CalcError> {
127 validate_inputs(inputs)?;
128 ruleset.validate_cross_fields(inputs)?;
129
130 let today = Utc::now().date_naive();
131 if !ruleset.effective_dates().is_active_on(today) {
132 return Err(CalcError::RulesetExpired {
133 id: ruleset.id().0.clone(),
134 until: ruleset
135 .effective_dates()
136 .until
137 .map(|d| d.to_string())
138 .unwrap_or_else(|| "open-ended".into()),
139 });
140 }
141
142 let w = ruleset.weights();
143 let scale = 5.0; let contributions = ParameterContributions {
146 disassembly: f64::from(inputs.disassembly) * w.disassembly * scale,
147 spare_parts: f64::from(inputs.spare_parts) * w.spare_parts * scale,
148 repair_info: f64::from(inputs.repair_info) * w.repair_info * scale,
149 diagnostic_tools: f64::from(inputs.diagnostic_tools) * w.diagnostic_tools * scale,
150 software_updatability: f64::from(inputs.software_updatability)
151 * w.software_updatability
152 * scale,
153 customer_support: f64::from(inputs.customer_support) * w.customer_support * scale,
154 };
155
156 let numeric_score = contributions.disassembly
157 + contributions.spare_parts
158 + contributions.repair_info
159 + contributions.diagnostic_tools
160 + contributions.software_updatability
161 + contributions.customer_support;
162
163 let t = ruleset.thresholds();
164 let class = if numeric_score >= t.a {
165 RepairabilityClass::A
166 } else if numeric_score >= t.b {
167 RepairabilityClass::B
168 } else if numeric_score >= t.c {
169 RepairabilityClass::C
170 } else if numeric_score >= t.d {
171 RepairabilityClass::D
172 } else {
173 RepairabilityClass::E
174 };
175
176 let output_hash = jcs_hash(&(numeric_score, class.as_ordinal()))?;
177
178 let receipt = CalculationReceipt::new(
179 input_hash(inputs)?,
180 ruleset.id().0.as_str(),
181 ruleset.version().0.as_str(),
182 )
183 .with_output_hash(output_hash);
184
185 Ok(RepairabilityResult {
186 class,
187 numeric_score,
188 contributions,
189 receipt,
190 })
191}
192
193fn validate_inputs(inputs: &RepairabilityInputs) -> Result<(), CalcError> {
194 for (name, val) in [
195 ("disassembly", inputs.disassembly),
196 ("spare_parts", inputs.spare_parts),
197 ("repair_info", inputs.repair_info),
198 ("diagnostic_tools", inputs.diagnostic_tools),
199 ("software_updatability", inputs.software_updatability),
200 ("customer_support", inputs.customer_support),
201 ] {
202 if val > 2 {
203 return Err(CalcError::InvalidInput(format!(
204 "{name} must be 0, 1, or 2; got {val}"
205 )));
206 }
207 }
208 Ok(())
209}
210
211#[cfg(test)]
212mod class_tests {
213 use super::*;
214
215 #[test]
216 fn class_display_and_ordinal_for_all_grades() {
217 for (class, letter, ordinal) in [
218 (RepairabilityClass::A, "A", 5),
219 (RepairabilityClass::B, "B", 4),
220 (RepairabilityClass::C, "C", 3),
221 (RepairabilityClass::D, "D", 2),
222 (RepairabilityClass::E, "E", 1),
223 ] {
224 assert_eq!(class.to_string(), letter);
225 assert_eq!(class.as_ordinal(), ordinal);
226 }
227 }
228
229 #[test]
230 fn out_of_range_parameter_is_rejected() {
231 let inputs = RepairabilityInputs {
232 disassembly: 3, spare_parts: 1,
234 repair_info: 1,
235 diagnostic_tools: 1,
236 software_updatability: 1,
237 customer_support: 1,
238 };
239 assert!(matches!(
240 calculate(&inputs, &SimplifiedRepairabilityHeuristic),
241 Err(CalcError::InvalidInput(_))
242 ));
243 }
244}