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