1#![cfg(not(target_arch = "wasm32"))]
14
15use std::sync::OnceLock;
16
17use semver::Version;
18
19use crate::catalog::SectorCatalog;
20use crate::domain::field_error::{FieldError, ValidationErrors};
21use crate::domain::sector::{
22 SectorData, SvhcSubstance, validate_fibre_composition, validate_surfactants,
23 validate_svhc_substances,
24};
25use crate::schemas::VersionedSchemaRegistry;
26
27pub trait SectorValidator: Send + Sync {
35 fn validate(&self, data: &serde_json::Value) -> Result<(), Vec<FieldError>>;
37}
38
39#[derive(Default)]
44pub struct SectorValidatorRegistry {
45 validators: std::collections::HashMap<String, std::sync::Arc<dyn SectorValidator>>,
46}
47
48impl SectorValidatorRegistry {
49 pub fn new() -> Self {
50 Self::default()
51 }
52
53 pub fn register(
54 &mut self,
55 key: impl Into<String>,
56 validator: std::sync::Arc<dyn SectorValidator>,
57 ) {
58 self.validators.insert(key.into(), validator);
59 }
60
61 fn get(&self, key: &str) -> Option<&dyn SectorValidator> {
62 self.validators.get(key).map(std::sync::Arc::as_ref)
63 }
64}
65
66fn default_registry() -> &'static VersionedSchemaRegistry {
70 static REGISTRY: OnceLock<VersionedSchemaRegistry> = OnceLock::new();
71 REGISTRY.get_or_init(VersionedSchemaRegistry::new)
72}
73
74fn default_catalog() -> &'static SectorCatalog {
76 static CATALOG: OnceLock<SectorCatalog> = OnceLock::new();
77 CATALOG.get_or_init(SectorCatalog::new)
78}
79
80pub fn validate_sector_data(sector_data: &SectorData) -> Result<(), ValidationErrors> {
97 validate_sector_data_with_registry(sector_data, &SectorValidatorRegistry::default())
98}
99
100pub fn validate_sector_data_with_registry(
106 sector_data: &SectorData,
107 registry: &SectorValidatorRegistry,
108) -> Result<(), ValidationErrors> {
109 let mut errors: Vec<FieldError> = Vec::new();
110 if let SectorData::Other(_) = sector_data {
111 match registry.get("other") {
112 Some(v) => {
113 if let Err(field_errors) = v.validate(§or_data_instance(sector_data)) {
114 errors.extend(field_errors);
115 }
116 }
117 None => errors.push(FieldError {
118 field: "/sector".to_owned(),
119 message: "SectorData::Other cannot be validated without a registered validator; \
120 pass a SectorValidatorRegistry with an \"other\" entry"
121 .to_owned(),
122 }),
123 }
124 } else {
125 schema_errors(sector_data, &mut errors);
126 cross_field_errors(sector_data, &mut errors);
127 }
128 if errors.is_empty() {
129 Ok(())
130 } else {
131 Err(ValidationErrors { errors })
132 }
133}
134
135pub fn validate_raw_sector_data(
150 sector_key: &str,
151 data: &serde_json::Value,
152 registry: &SectorValidatorRegistry,
153) -> Result<(), ValidationErrors> {
154 let mut errors: Vec<FieldError> = Vec::new();
155 let catalog = default_catalog();
156 let has_schema = catalog.current_schema_version(sector_key).is_some();
157
158 if let Some(version_str) = catalog.current_schema_version(sector_key)
159 && let Ok(version) = version_str.parse::<semver::Version>()
160 && let Err(ve) = default_registry().validate(sector_key, &version, data)
161 {
162 errors.extend(ve.errors);
163 }
164
165 match registry.get(sector_key) {
166 Some(v) => {
167 if let Err(field_errors) = v.validate(data) {
168 errors.extend(field_errors);
169 }
170 }
171 None if !has_schema => {
172 errors.push(FieldError {
173 field: "/sector".to_owned(),
174 message: format!(
175 "unknown sector \"{sector_key}\": no JSON schema or cross-field validator registered"
176 ),
177 });
178 }
179 None => {}
180 }
181
182 if errors.is_empty() {
183 Ok(())
184 } else {
185 Err(ValidationErrors { errors })
186 }
187}
188
189fn schema_errors(sector_data: &SectorData, errors: &mut Vec<FieldError>) {
191 let key = sector_data.sector().catalog_key();
192 let Some(version_str) = default_catalog().current_schema_version(key) else {
194 return;
195 };
196 let Ok(version) = version_str.parse::<Version>() else {
197 return;
198 };
199 let instance = sector_data_instance(sector_data);
200 if let Err(ve) = default_registry().validate(key, &version, &instance) {
201 errors.extend(ve.errors);
202 }
203}
204
205fn sector_data_instance(sector_data: &SectorData) -> serde_json::Value {
208 let mut value = serde_json::to_value(sector_data).expect("SectorData serializes to Value");
209 if let Some(obj) = value.as_object_mut() {
210 obj.remove("sector");
211 }
212 value
213}
214
215fn cross_field_errors(sector_data: &SectorData, errors: &mut Vec<FieldError>) {
218 match sector_data {
219 SectorData::Textile(d) => {
220 if let Err(msg) = validate_fibre_composition(&d.fibre_composition) {
221 errors.push(FieldError {
222 field: "/fibreComposition".to_owned(),
223 message: msg,
224 });
225 }
226 push_svhc(d.svhc_substances.as_deref(), errors);
227 if let Some(ds) = d.durability_score
228 && !(0.0..=10.0).contains(&ds)
229 {
230 errors.push(FieldError {
231 field: "/durabilityScore".to_owned(),
232 message: format!("durability_score {ds} must be 0.0–10.0"),
233 });
234 }
235 }
236 SectorData::Electronics(d) => push_svhc(d.svhc_substances.as_deref(), errors),
237 SectorData::Toy(d) => push_svhc(d.svhc_substances.as_deref(), errors),
238 SectorData::Furniture(d) => push_svhc(d.svhc_substances.as_deref(), errors),
239 SectorData::Detergent(d) => {
240 if let Err(msg) = validate_surfactants(&d.surfactants) {
241 errors.push(FieldError {
242 field: "/surfactants".to_owned(),
243 message: msg,
244 });
245 }
246 }
247 _ => {}
248 }
249}
250
251fn push_svhc(substances: Option<&[SvhcSubstance]>, errors: &mut Vec<FieldError>) {
252 if let Some(s) = substances
253 && let Err(msg) = validate_svhc_substances(s)
254 {
255 errors.push(FieldError {
256 field: "/svhcSubstances".to_owned(),
257 message: msg,
258 });
259 }
260}
261
262#[derive(Debug, Clone)]
266pub struct BatchValidationItem {
267 pub index: usize,
269 pub result: Result<(), ValidationErrors>,
271}
272
273pub fn validate_sector_data_batch(items: &[SectorData]) -> Vec<BatchValidationItem> {
277 items
278 .iter()
279 .enumerate()
280 .map(|(index, data)| BatchValidationItem {
281 index,
282 result: validate_sector_data(data),
283 })
284 .collect()
285}
286
287pub fn batch_errors(results: &[BatchValidationItem]) -> Vec<&BatchValidationItem> {
289 results.iter().filter(|item| item.result.is_err()).collect()
290}
291
292#[cfg(test)]
295mod tests {
296 use super::*;
297 use crate::domain::gtin::Gtin;
298 use crate::domain::sector::{BatteryChemistry, BatteryData, FibreEntry, TextileData};
299
300 fn valid_battery() -> SectorData {
301 SectorData::Battery(BatteryData {
302 gtin: Gtin::parse("09506000134352").unwrap(),
303 battery_chemistry: BatteryChemistry::Lfp,
304 nominal_voltage_v: 48.0,
305 nominal_capacity_ah: 100.0,
306 expected_lifetime_cycles: 3000,
307 co2e_per_unit_kg: 85.4,
308 recycled_content_cobalt_pct: None,
309 recycled_content_lithium_pct: None,
310 recycled_content_nickel_pct: None,
311 state_of_health_pct: None,
312 rated_capacity_kwh: None,
313 carbon_footprint_class: None,
314 due_diligence_url: None,
315 cathode_material: None,
316 anode_material: None,
317 electrolyte_material: None,
318 critical_raw_materials: None,
319 disassembly_instructions_url: None,
320 soh_methodology: None,
321 operating_temp_min_c: None,
322 operating_temp_max_c: None,
323 rated_energy_wh: None,
324 recycled_content_lead_pct: None,
325 battery_weight_kg: None,
326 battery_type: None,
327 round_trip_efficiency_pct: None,
328 internal_resistance_mohm: None,
329 manufacturing_date: None,
330 manufacturing_place: None,
331 battery_model_id: None,
332 battery_passport_number: None,
333 })
334 }
335
336 fn valid_textile() -> SectorData {
337 SectorData::Textile(TextileData {
338 fibre_composition: vec![
339 FibreEntry {
340 fibre: "cotton".into(),
341 pct: 60.0,
342 country_of_origin: None,
343 },
344 FibreEntry {
345 fibre: "polyester".into(),
346 pct: 40.0,
347 country_of_origin: None,
348 },
349 ],
350 country_of_manufacturing: "BD".into(),
351 care_instructions: "30°C machine wash".into(),
352 chemical_compliance_standard: "OEKO-TEX 100".into(),
353 recycled_content_pct: None,
354 carbon_footprint_kg_co2e: None,
355 water_use_litres: None,
356 microplastic_shedding_mg_per_wash: None,
357 repair_score: None,
358 durability_score: None,
359 expected_wash_cycles: None,
360 country_of_raw_material_origin: None,
361 svhc_substances: None,
362 allergens: None,
363 substances_of_concern: None,
364 recyclability_class: None,
365 end_of_life_instructions: None,
366 reuse_condition: None,
367 prior_use_cycles: None,
368 disassembly_instructions: None,
369 spare_parts_available: None,
370 product_weight_grams: None,
371 repair_history_url: None,
372 repair_count: None,
373 pef_score: None,
374 })
375 }
376
377 #[test]
378 fn valid_battery_passes() {
379 assert!(validate_sector_data(&valid_battery()).is_ok());
381 }
382
383 #[test]
384 fn valid_textile_passes() {
385 assert!(validate_sector_data(&valid_textile()).is_ok());
386 }
387
388 #[test]
393 fn battery_missing_required_field_fails() {
394 let reg = VersionedSchemaRegistry::new();
395 let v: Version = "1.0.0".parse().unwrap();
396 let instance = serde_json::json!({
397 "batteryChemistry": "LFP",
398 "nominalVoltageV": 48.0,
399 "nominalCapacityAh": 100.0,
400 "expectedLifetimeCycles": 3000,
401 "co2ePerUnitKg": 85.4
402 });
404 let err = reg.validate("battery", &v, &instance).unwrap_err();
405 assert!(
406 err.errors.iter().any(|e| e.message.contains("gtin")),
407 "expected gtin error, got: {err:?}"
408 );
409 }
410
411 #[test]
412 fn battery_invalid_gtin_pattern_fails() {
413 let reg = VersionedSchemaRegistry::new();
414 let v: Version = "1.0.0".parse().unwrap();
415 let instance = serde_json::json!({
416 "gtin": "123", "batteryChemistry": "LFP",
418 "nominalVoltageV": 48.0,
419 "nominalCapacityAh": 100.0,
420 "expectedLifetimeCycles": 3000,
421 "co2ePerUnitKg": 85.4
422 });
423 assert!(reg.validate("battery", &v, &instance).is_err());
424 }
425
426 #[test]
427 fn textile_missing_care_instructions_fails() {
428 let reg = VersionedSchemaRegistry::new();
429 let v: Version = "1.1.0".parse().unwrap();
430 let instance = serde_json::json!({
431 "fibreComposition": [{"fibre": "cotton", "pct": 100}],
432 "countryOfManufacturing": "BD",
433 "chemicalComplianceStandard": "REACH"
435 });
436 let err = reg.validate("textile", &v, &instance).unwrap_err();
437 assert!(
438 err.errors
439 .iter()
440 .any(|e| e.message.contains("careInstructions")),
441 "expected careInstructions error, got: {err:?}"
442 );
443 }
444
445 #[test]
446 fn textile_empty_fibre_composition_fails() {
447 let reg = VersionedSchemaRegistry::new();
448 let v: Version = "1.1.0".parse().unwrap();
449 let instance = serde_json::json!({
450 "fibreComposition": [], "countryOfManufacturing": "DE",
452 "careInstructions": "dry clean only",
453 "chemicalComplianceStandard": "GOTS"
454 });
455 assert!(reg.validate("textile", &v, &instance).is_err());
456 }
457
458 #[test]
459 fn textile_fibre_sum_not_100_fails() {
460 let data = SectorData::Textile(TextileData {
462 fibre_composition: vec![
463 FibreEntry {
464 fibre: "cotton".into(),
465 pct: 60.0,
466 country_of_origin: None,
467 },
468 FibreEntry {
469 fibre: "polyester".into(),
470 pct: 30.0, country_of_origin: None,
472 },
473 ],
474 country_of_manufacturing: "PT".into(),
475 care_instructions: "Hand wash only".into(),
476 chemical_compliance_standard: "REACH".into(),
477 recycled_content_pct: None,
478 carbon_footprint_kg_co2e: None,
479 water_use_litres: None,
480 microplastic_shedding_mg_per_wash: None,
481 repair_score: None,
482 durability_score: None,
483 expected_wash_cycles: None,
484 country_of_raw_material_origin: None,
485 svhc_substances: None,
486 allergens: None,
487 substances_of_concern: None,
488 recyclability_class: None,
489 end_of_life_instructions: None,
490 reuse_condition: None,
491 prior_use_cycles: None,
492 disassembly_instructions: None,
493 spare_parts_available: None,
494 product_weight_grams: None,
495 repair_history_url: None,
496 repair_count: None,
497 pef_score: None,
498 });
499 let err = validate_sector_data(&data).unwrap_err();
500 assert!(
501 err.errors.iter().any(|e| e.field == "/fibreComposition"),
502 "expected /fibreComposition error, got: {err:?}"
503 );
504 }
505
506 #[test]
509 fn other_sector_data_fails_without_registry() {
510 let data = SectorData::Other(serde_json::json!({"field": "value"}));
511 let err = validate_sector_data(&data).unwrap_err();
512 assert!(
513 err.errors.iter().any(|e| e.field == "/sector"),
514 "expected /sector error for Other without registry"
515 );
516 }
517
518 #[test]
519 fn other_sector_data_passes_with_registered_validator() {
520 use std::sync::Arc;
521
522 struct AlwaysOkValidator;
523 impl SectorValidator for AlwaysOkValidator {
524 fn validate(&self, _: &serde_json::Value) -> Result<(), Vec<FieldError>> {
525 Ok(())
526 }
527 }
528
529 let mut registry = SectorValidatorRegistry::new();
530 registry.register("other", Arc::new(AlwaysOkValidator));
531
532 let data = SectorData::Other(serde_json::json!({"field": "value"}));
533 assert!(
534 validate_sector_data_with_registry(&data, ®istry).is_ok(),
535 "registered AlwaysOkValidator must allow Other sector"
536 );
537 }
538
539 #[test]
540 fn other_sector_data_validator_errors_propagate() {
541 use std::sync::Arc;
542
543 struct AlwaysFailValidator;
544 impl SectorValidator for AlwaysFailValidator {
545 fn validate(&self, _: &serde_json::Value) -> Result<(), Vec<FieldError>> {
546 Err(vec![FieldError {
547 field: "/field".to_owned(),
548 message: "injected failure".to_owned(),
549 }])
550 }
551 }
552
553 let mut registry = SectorValidatorRegistry::new();
554 registry.register("other", Arc::new(AlwaysFailValidator));
555
556 let data = SectorData::Other(serde_json::json!({"field": "bad"}));
557 let err = validate_sector_data_with_registry(&data, ®istry).unwrap_err();
558 assert!(
559 err.errors
560 .iter()
561 .any(|e| e.message.contains("injected failure")),
562 "validator errors must propagate"
563 );
564 }
565
566 #[test]
567 fn validate_raw_sector_data_known_sector_succeeds() {
568 let data = serde_json::json!({
570 "gtin": "09506000134352",
571 "batteryChemistry": "LFP",
572 "nominalVoltageV": 48.0,
573 "nominalCapacityAh": 100.0,
574 "expectedLifetimeCycles": 3000,
575 "co2ePerUnitKg": 85.4
576 });
577 let registry = SectorValidatorRegistry::default();
578 assert!(validate_raw_sector_data("battery", &data, ®istry).is_ok());
579 }
580
581 #[test]
582 fn validate_raw_sector_data_unknown_sector_fails() {
583 let data = serde_json::json!({"field": "value"});
584 let registry = SectorValidatorRegistry::default();
585 let err = validate_raw_sector_data("nonexistent-sector", &data, ®istry).unwrap_err();
586 assert!(
587 err.errors
588 .iter()
589 .any(|e| e.message.contains("nonexistent-sector")),
590 "expected error naming the unknown sector key"
591 );
592 }
593
594 #[test]
595 fn batch_validation_mixed_results() {
596 let items = vec![
597 valid_battery(),
598 valid_textile(),
599 SectorData::Textile(TextileData {
601 fibre_composition: vec![FibreEntry {
602 fibre: "cotton".into(),
603 pct: 50.0,
604 country_of_origin: None,
605 }],
606 country_of_manufacturing: "PT".into(),
607 care_instructions: "Hand wash".into(),
608 chemical_compliance_standard: "REACH".into(),
609 recycled_content_pct: None,
610 carbon_footprint_kg_co2e: None,
611 water_use_litres: None,
612 microplastic_shedding_mg_per_wash: None,
613 repair_score: None,
614 durability_score: None,
615 expected_wash_cycles: None,
616 country_of_raw_material_origin: None,
617 svhc_substances: None,
618 allergens: None,
619 substances_of_concern: None,
620 recyclability_class: None,
621 end_of_life_instructions: None,
622 reuse_condition: None,
623 prior_use_cycles: None,
624 disassembly_instructions: None,
625 spare_parts_available: None,
626 product_weight_grams: None,
627 repair_history_url: None,
628 repair_count: None,
629 pef_score: None,
630 }),
631 ];
632
633 let results = validate_sector_data_batch(&items);
634 assert_eq!(results.len(), 3);
635 assert!(results[0].result.is_ok());
636 assert!(results[1].result.is_ok());
637 assert!(results[2].result.is_err());
638
639 let errors = batch_errors(&results);
640 assert_eq!(errors.len(), 1);
641 assert_eq!(errors[0].index, 2);
642 }
643}