#![cfg(not(target_arch = "wasm32"))]
use std::sync::OnceLock;
use semver::Version;
use crate::catalog::SectorCatalog;
use crate::domain::field_error::{FieldError, ValidationErrors};
use crate::domain::sector::{
SectorData, SvhcSubstance, battery_recycled_chemistry_conflicts,
validate_battery_operating_temp, validate_fibre_composition, validate_surfactants,
validate_svhc_substances,
};
use crate::schemas::VersionedSchemaRegistry;
pub trait SectorValidator: Send + Sync {
fn validate(&self, data: &serde_json::Value) -> Result<(), Vec<FieldError>>;
}
#[derive(Default)]
pub struct SectorValidatorRegistry {
validators: std::collections::HashMap<String, std::sync::Arc<dyn SectorValidator>>,
}
impl SectorValidatorRegistry {
pub fn new() -> Self {
Self::default()
}
pub fn register(
&mut self,
key: impl Into<String>,
validator: std::sync::Arc<dyn SectorValidator>,
) {
self.validators.insert(key.into(), validator);
}
fn get(&self, key: &str) -> Option<&dyn SectorValidator> {
self.validators.get(key).map(std::sync::Arc::as_ref)
}
}
fn default_registry() -> &'static VersionedSchemaRegistry {
static REGISTRY: OnceLock<VersionedSchemaRegistry> = OnceLock::new();
REGISTRY.get_or_init(VersionedSchemaRegistry::new)
}
fn default_catalog() -> &'static SectorCatalog {
static CATALOG: OnceLock<SectorCatalog> = OnceLock::new();
CATALOG.get_or_init(SectorCatalog::new)
}
pub fn validate_sector_data(sector_data: &SectorData) -> Result<(), ValidationErrors> {
validate_sector_data_with_registry(sector_data, &SectorValidatorRegistry::default())
}
pub fn validate_sector_data_with_registry(
sector_data: &SectorData,
registry: &SectorValidatorRegistry,
) -> Result<(), ValidationErrors> {
let mut errors: Vec<FieldError> = Vec::new();
if let SectorData::Other(_) = sector_data {
match registry.get("other") {
Some(v) => {
if let Err(field_errors) = v.validate(§or_data_instance(sector_data)) {
errors.extend(field_errors);
}
}
None => errors.push(FieldError {
field: "/sector".to_owned(),
message: "SectorData::Other cannot be validated without a registered validator; \
pass a SectorValidatorRegistry with an \"other\" entry"
.to_owned(),
}),
}
} else {
schema_errors(sector_data, &mut errors);
cross_field_errors(sector_data, &mut errors);
}
if errors.is_empty() {
Ok(())
} else {
Err(ValidationErrors { errors })
}
}
pub fn validate_raw_sector_data(
sector_key: &str,
data: &serde_json::Value,
registry: &SectorValidatorRegistry,
) -> Result<(), ValidationErrors> {
let mut errors: Vec<FieldError> = Vec::new();
let catalog = default_catalog();
let has_schema = catalog.current_schema_version(sector_key).is_some();
if let Some(version_str) = catalog.current_schema_version(sector_key)
&& let Ok(version) = version_str.parse::<semver::Version>()
&& let Err(ve) = default_registry().validate(sector_key, &version, data)
{
errors.extend(ve.errors);
}
match registry.get(sector_key) {
Some(v) => {
if let Err(field_errors) = v.validate(data) {
errors.extend(field_errors);
}
}
None if !has_schema => {
errors.push(FieldError {
field: "/sector".to_owned(),
message: format!(
"unknown sector \"{sector_key}\": no JSON schema or cross-field validator registered"
),
});
}
None => {}
}
if errors.is_empty() {
Ok(())
} else {
Err(ValidationErrors { errors })
}
}
fn schema_errors(sector_data: &SectorData, errors: &mut Vec<FieldError>) {
let key = sector_data.sector().catalog_key();
let Some(version_str) = default_catalog().current_schema_version(key) else {
return;
};
let Ok(version) = version_str.parse::<Version>() else {
return;
};
let instance = sector_data_instance(sector_data);
if let Err(ve) = default_registry().validate(key, &version, &instance) {
errors.extend(ve.errors);
}
}
fn sector_data_instance(sector_data: &SectorData) -> serde_json::Value {
let mut value = serde_json::to_value(sector_data).expect("SectorData serializes to Value");
if let Some(obj) = value.as_object_mut() {
obj.remove("sector");
}
value
}
fn cross_field_errors(sector_data: &SectorData, errors: &mut Vec<FieldError>) {
match sector_data {
SectorData::Battery(d) => {
if let Err(msg) =
validate_battery_operating_temp(d.operating_temp_min_c, d.operating_temp_max_c)
{
errors.push(FieldError {
field: "/operatingTempMinC".to_owned(),
message: msg,
});
}
let chemistry = serde_json::to_value(&d.battery_chemistry)
.ok()
.and_then(|v| v.as_str().map(str::to_owned))
.unwrap_or_default();
for metal in battery_recycled_chemistry_conflicts(
&chemistry,
d.recycled_content_cobalt_pct,
d.recycled_content_lithium_pct,
d.recycled_content_nickel_pct,
d.recycled_content_lead_pct,
) {
let field = match metal {
"cobalt" => "/recycledContentCobaltPct",
"lithium" => "/recycledContentLithiumPct",
"nickel" => "/recycledContentNickelPct",
"lead" => "/recycledContentLeadPct",
_ => "/recycledContent",
};
errors.push(FieldError {
field: field.to_owned(),
message: format!(
"{metal} recycled content declared for a {chemistry} battery, \
which contains no {metal}"
),
});
}
}
SectorData::Textile(d) => {
if let Err(msg) = validate_fibre_composition(&d.fibre_composition) {
errors.push(FieldError {
field: "/fibreComposition".to_owned(),
message: msg,
});
}
push_svhc(d.svhc_substances.as_deref(), errors);
if let Some(ds) = d.durability_score
&& !(0.0..=10.0).contains(&ds)
{
errors.push(FieldError {
field: "/durabilityScore".to_owned(),
message: format!("durability_score {ds} must be 0.0–10.0"),
});
}
}
SectorData::Electronics(d) => push_svhc(d.svhc_substances.as_deref(), errors),
SectorData::Toy(d) => push_svhc(d.svhc_substances.as_deref(), errors),
SectorData::Furniture(d) => push_svhc(d.svhc_substances.as_deref(), errors),
SectorData::Detergent(d) => {
if let Err(msg) = validate_surfactants(&d.surfactants) {
errors.push(FieldError {
field: "/surfactants".to_owned(),
message: msg,
});
}
}
_ => {}
}
}
fn push_svhc(substances: Option<&[SvhcSubstance]>, errors: &mut Vec<FieldError>) {
if let Some(s) = substances
&& let Err(msg) = validate_svhc_substances(s)
{
errors.push(FieldError {
field: "/svhcSubstances".to_owned(),
message: msg,
});
}
}
#[derive(Debug, Clone)]
pub struct BatchValidationItem {
pub index: usize,
pub result: Result<(), ValidationErrors>,
}
pub fn validate_sector_data_batch(items: &[SectorData]) -> Vec<BatchValidationItem> {
items
.iter()
.enumerate()
.map(|(index, data)| BatchValidationItem {
index,
result: validate_sector_data(data),
})
.collect()
}
pub fn batch_errors(results: &[BatchValidationItem]) -> Vec<&BatchValidationItem> {
results.iter().filter(|item| item.result.is_err()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::gtin::Gtin;
use crate::domain::sector::{BatteryChemistry, BatteryData, FibreEntry, TextileData};
fn valid_battery() -> SectorData {
SectorData::Battery(BatteryData {
gtin: Gtin::parse("09506000134352").unwrap(),
battery_chemistry: BatteryChemistry::Lfp,
nominal_voltage_v: 48.0,
nominal_capacity_ah: 100.0,
expected_lifetime_cycles: 3000,
co2e_per_unit_kg: 85.4,
recycled_content_cobalt_pct: None,
recycled_content_lithium_pct: None,
recycled_content_nickel_pct: None,
state_of_health_pct: None,
rated_capacity_kwh: None,
carbon_footprint_class: None,
due_diligence_url: None,
cathode_material: None,
anode_material: None,
electrolyte_material: None,
critical_raw_materials: None,
disassembly_instructions_url: None,
soh_methodology: None,
operating_temp_min_c: None,
operating_temp_max_c: None,
rated_energy_wh: None,
recycled_content_lead_pct: None,
battery_weight_kg: None,
battery_type: None,
round_trip_efficiency_pct: None,
internal_resistance_mohm: None,
manufacturing_date: None,
manufacturing_place: None,
battery_model_id: None,
battery_passport_number: None,
})
}
fn valid_textile() -> SectorData {
SectorData::Textile(TextileData {
fibre_composition: vec![
FibreEntry {
fibre: "cotton".into(),
pct: 60.0,
country_of_origin: None,
},
FibreEntry {
fibre: "polyester".into(),
pct: 40.0,
country_of_origin: None,
},
],
country_of_manufacturing: "BD".into(),
care_instructions: "30°C machine wash".into(),
chemical_compliance_standard: "OEKO-TEX 100".into(),
recycled_content_pct: None,
carbon_footprint_kg_co2e: None,
water_use_litres: None,
microplastic_shedding_mg_per_wash: None,
repair_score: None,
durability_score: None,
expected_wash_cycles: None,
country_of_raw_material_origin: None,
svhc_substances: None,
allergens: None,
substances_of_concern: None,
recyclability_class: None,
end_of_life_instructions: None,
reuse_condition: None,
prior_use_cycles: None,
disassembly_instructions: None,
spare_parts_available: None,
product_weight_grams: None,
repair_history_url: None,
repair_count: None,
pef_score: None,
})
}
#[test]
fn valid_battery_passes() {
assert!(validate_sector_data(&valid_battery()).is_ok());
}
fn battery_inner() -> BatteryData {
match valid_battery() {
SectorData::Battery(b) => b,
_ => unreachable!("valid_battery is Battery"),
}
}
#[test]
fn battery_positive_cobalt_on_lfp_fails_cross_field() {
let mut b = battery_inner(); b.recycled_content_cobalt_pct = Some(5.0);
let err = validate_sector_data(&SectorData::Battery(b)).unwrap_err();
assert!(
err.errors
.iter()
.any(|e| e.field == "/recycledContentCobaltPct"),
"expected cobalt-on-LFP conflict, got: {err:?}"
);
}
#[test]
fn battery_zero_cobalt_on_lfp_passes() {
let mut b = battery_inner();
b.recycled_content_cobalt_pct = Some(0.0); b.recycled_content_lithium_pct = Some(12.5);
assert!(validate_sector_data(&SectorData::Battery(b)).is_ok());
}
#[test]
fn battery_inverted_operating_temp_fails_cross_field() {
let mut b = battery_inner();
b.operating_temp_min_c = Some(60.0);
b.operating_temp_max_c = Some(-20.0);
let err = validate_sector_data(&SectorData::Battery(b)).unwrap_err();
assert!(
err.errors.iter().any(|e| e.field == "/operatingTempMinC"),
"expected operating-temp conflict, got: {err:?}"
);
}
#[test]
fn valid_textile_passes() {
assert!(validate_sector_data(&valid_textile()).is_ok());
}
#[test]
fn battery_missing_required_field_fails() {
let reg = VersionedSchemaRegistry::new();
let v: Version = "1.0.0".parse().unwrap();
let instance = serde_json::json!({
"batteryChemistry": "LFP",
"nominalVoltageV": 48.0,
"nominalCapacityAh": 100.0,
"expectedLifetimeCycles": 3000,
"co2ePerUnitKg": 85.4
});
let err = reg.validate("battery", &v, &instance).unwrap_err();
assert!(
err.errors.iter().any(|e| e.message.contains("gtin")),
"expected gtin error, got: {err:?}"
);
}
#[test]
fn battery_invalid_gtin_pattern_fails() {
let reg = VersionedSchemaRegistry::new();
let v: Version = "1.0.0".parse().unwrap();
let instance = serde_json::json!({
"gtin": "123", "batteryChemistry": "LFP",
"nominalVoltageV": 48.0,
"nominalCapacityAh": 100.0,
"expectedLifetimeCycles": 3000,
"co2ePerUnitKg": 85.4
});
assert!(reg.validate("battery", &v, &instance).is_err());
}
#[test]
fn textile_missing_care_instructions_fails() {
let reg = VersionedSchemaRegistry::new();
let v: Version = "1.1.0".parse().unwrap();
let instance = serde_json::json!({
"fibreComposition": [{"fibre": "cotton", "pct": 100}],
"countryOfManufacturing": "BD",
"chemicalComplianceStandard": "REACH"
});
let err = reg.validate("textile", &v, &instance).unwrap_err();
assert!(
err.errors
.iter()
.any(|e| e.message.contains("careInstructions")),
"expected careInstructions error, got: {err:?}"
);
}
#[test]
fn textile_empty_fibre_composition_fails() {
let reg = VersionedSchemaRegistry::new();
let v: Version = "1.1.0".parse().unwrap();
let instance = serde_json::json!({
"fibreComposition": [], "countryOfManufacturing": "DE",
"careInstructions": "dry clean only",
"chemicalComplianceStandard": "GOTS"
});
assert!(reg.validate("textile", &v, &instance).is_err());
}
#[test]
fn textile_fibre_sum_not_100_fails() {
let data = SectorData::Textile(TextileData {
fibre_composition: vec![
FibreEntry {
fibre: "cotton".into(),
pct: 60.0,
country_of_origin: None,
},
FibreEntry {
fibre: "polyester".into(),
pct: 30.0, country_of_origin: None,
},
],
country_of_manufacturing: "PT".into(),
care_instructions: "Hand wash only".into(),
chemical_compliance_standard: "REACH".into(),
recycled_content_pct: None,
carbon_footprint_kg_co2e: None,
water_use_litres: None,
microplastic_shedding_mg_per_wash: None,
repair_score: None,
durability_score: None,
expected_wash_cycles: None,
country_of_raw_material_origin: None,
svhc_substances: None,
allergens: None,
substances_of_concern: None,
recyclability_class: None,
end_of_life_instructions: None,
reuse_condition: None,
prior_use_cycles: None,
disassembly_instructions: None,
spare_parts_available: None,
product_weight_grams: None,
repair_history_url: None,
repair_count: None,
pef_score: None,
});
let err = validate_sector_data(&data).unwrap_err();
assert!(
err.errors.iter().any(|e| e.field == "/fibreComposition"),
"expected /fibreComposition error, got: {err:?}"
);
}
#[test]
fn other_sector_data_fails_without_registry() {
let data = SectorData::Other(serde_json::json!({"field": "value"}));
let err = validate_sector_data(&data).unwrap_err();
assert!(
err.errors.iter().any(|e| e.field == "/sector"),
"expected /sector error for Other without registry"
);
}
#[test]
fn other_sector_data_passes_with_registered_validator() {
use std::sync::Arc;
struct AlwaysOkValidator;
impl SectorValidator for AlwaysOkValidator {
fn validate(&self, _: &serde_json::Value) -> Result<(), Vec<FieldError>> {
Ok(())
}
}
let mut registry = SectorValidatorRegistry::new();
registry.register("other", Arc::new(AlwaysOkValidator));
let data = SectorData::Other(serde_json::json!({"field": "value"}));
assert!(
validate_sector_data_with_registry(&data, ®istry).is_ok(),
"registered AlwaysOkValidator must allow Other sector"
);
}
#[test]
fn other_sector_data_validator_errors_propagate() {
use std::sync::Arc;
struct AlwaysFailValidator;
impl SectorValidator for AlwaysFailValidator {
fn validate(&self, _: &serde_json::Value) -> Result<(), Vec<FieldError>> {
Err(vec![FieldError {
field: "/field".to_owned(),
message: "injected failure".to_owned(),
}])
}
}
let mut registry = SectorValidatorRegistry::new();
registry.register("other", Arc::new(AlwaysFailValidator));
let data = SectorData::Other(serde_json::json!({"field": "bad"}));
let err = validate_sector_data_with_registry(&data, ®istry).unwrap_err();
assert!(
err.errors
.iter()
.any(|e| e.message.contains("injected failure")),
"validator errors must propagate"
);
}
#[test]
fn validate_raw_sector_data_known_sector_succeeds() {
let data = serde_json::json!({
"gtin": "09506000134352",
"batteryChemistry": "LFP",
"nominalVoltageV": 48.0,
"nominalCapacityAh": 100.0,
"expectedLifetimeCycles": 3000,
"co2ePerUnitKg": 85.4
});
let registry = SectorValidatorRegistry::default();
assert!(validate_raw_sector_data("battery", &data, ®istry).is_ok());
}
#[test]
fn validate_raw_sector_data_unknown_sector_fails() {
let data = serde_json::json!({"field": "value"});
let registry = SectorValidatorRegistry::default();
let err = validate_raw_sector_data("nonexistent-sector", &data, ®istry).unwrap_err();
assert!(
err.errors
.iter()
.any(|e| e.message.contains("nonexistent-sector")),
"expected error naming the unknown sector key"
);
}
#[test]
fn batch_validation_mixed_results() {
let items = vec![
valid_battery(),
valid_textile(),
SectorData::Textile(TextileData {
fibre_composition: vec![FibreEntry {
fibre: "cotton".into(),
pct: 50.0,
country_of_origin: None,
}],
country_of_manufacturing: "PT".into(),
care_instructions: "Hand wash".into(),
chemical_compliance_standard: "REACH".into(),
recycled_content_pct: None,
carbon_footprint_kg_co2e: None,
water_use_litres: None,
microplastic_shedding_mg_per_wash: None,
repair_score: None,
durability_score: None,
expected_wash_cycles: None,
country_of_raw_material_origin: None,
svhc_substances: None,
allergens: None,
substances_of_concern: None,
recyclability_class: None,
end_of_life_instructions: None,
reuse_condition: None,
prior_use_cycles: None,
disassembly_instructions: None,
spare_parts_available: None,
product_weight_grams: None,
repair_history_url: None,
repair_count: None,
pef_score: None,
}),
];
let results = validate_sector_data_batch(&items);
assert_eq!(results.len(), 3);
assert!(results[0].result.is_ok());
assert!(results[1].result.is_ok());
assert!(results[2].result.is_err());
let errors = batch_errors(&results);
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].index, 2);
}
}