use serde::{Deserialize, Serialize};
struct EmbeddedManifest {
key: &'static str,
json: &'static str,
}
const EMBEDDED: &[EmbeddedManifest] = &[
EmbeddedManifest {
key: "battery",
json: include_str!("../../sectors/battery.json"),
},
EmbeddedManifest {
key: "electronics",
json: include_str!("../../sectors/electronics.json"),
},
EmbeddedManifest {
key: "unsold-goods",
json: include_str!("../../sectors/unsold-goods.json"),
},
EmbeddedManifest {
key: "textile",
json: include_str!("../../sectors/textile.json"),
},
EmbeddedManifest {
key: "steel",
json: include_str!("../../sectors/steel.json"),
},
EmbeddedManifest {
key: "construction",
json: include_str!("../../sectors/construction.json"),
},
EmbeddedManifest {
key: "tyre",
json: include_str!("../../sectors/tyre.json"),
},
EmbeddedManifest {
key: "toy",
json: include_str!("../../sectors/toy.json"),
},
EmbeddedManifest {
key: "aluminium",
json: include_str!("../../sectors/aluminium.json"),
},
EmbeddedManifest {
key: "furniture",
json: include_str!("../../sectors/furniture.json"),
},
EmbeddedManifest {
key: "detergent",
json: include_str!("../../sectors/detergent.json"),
},
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum RegulatoryStatus {
InForce,
Provisional,
}
impl RegulatoryStatus {
#[must_use]
pub fn allows_determination(&self) -> bool {
matches!(self, Self::InForce)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SectorDescriptor {
pub key: String,
pub title: String,
pub status: RegulatoryStatus,
pub legal_basis: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dpp_applies_from: Option<String>,
pub retention_years: u32,
pub schema_versions: Vec<String>,
pub current_schema_version: String,
#[serde(default)]
pub product_categories: Vec<String>,
#[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
pub access_tiers: std::collections::HashMap<String, crate::domain::identity::AccessTier>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plugin: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum CatalogError {
AlreadyExists(String),
}
impl std::fmt::Display for CatalogError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::AlreadyExists(key) => write!(f, "sector '{key}' already in catalog"),
}
}
}
impl std::error::Error for CatalogError {}
pub struct SectorCatalog {
entries: Vec<SectorDescriptor>,
}
impl SectorCatalog {
#[must_use]
pub fn new() -> Self {
let entries = EMBEDDED
.iter()
.map(|m| {
let descriptor: SectorDescriptor =
serde_json::from_str(m.json).unwrap_or_else(|e| {
panic!("embedded sector manifest '{}' is invalid: {e}", m.key)
});
assert_eq!(
descriptor.key, m.key,
"manifest key '{}' does not match its file key '{}'",
descriptor.key, m.key
);
descriptor
})
.collect();
Self { entries }
}
#[must_use]
pub fn get(&self, key: &str) -> Option<&SectorDescriptor> {
self.entries.iter().find(|d| d.key == key)
}
#[must_use]
pub fn all(&self) -> &[SectorDescriptor] {
&self.entries
}
#[must_use]
pub fn in_force(&self) -> Vec<&SectorDescriptor> {
self.entries
.iter()
.filter(|d| d.status == RegulatoryStatus::InForce)
.collect()
}
#[must_use]
pub fn provisional(&self) -> Vec<&SectorDescriptor> {
self.entries
.iter()
.filter(|d| d.status == RegulatoryStatus::Provisional)
.collect()
}
#[must_use]
pub fn is_in_force(&self, key: &str) -> bool {
self.get(key)
.is_some_and(|d| d.status.allows_determination())
}
#[must_use]
pub fn current_schema_version(&self, key: &str) -> Option<&str> {
self.get(key).map(|d| d.current_schema_version.as_str())
}
#[must_use]
pub fn resolve_schema_version(&self, key: &str, stored: Option<&str>) -> Option<String> {
match stored {
Some(v) => Some(v.to_owned()),
None => self.current_schema_version(key).map(ToOwned::to_owned),
}
}
#[must_use]
pub fn keys(&self) -> Vec<&str> {
let mut keys: Vec<&str> = self.entries.iter().map(|d| d.key.as_str()).collect();
keys.sort_unstable();
keys
}
pub fn register(&mut self, descriptor: SectorDescriptor) -> Result<(), CatalogError> {
if self.get(&descriptor.key).is_some() {
return Err(CatalogError::AlreadyExists(descriptor.key));
}
self.entries.push(descriptor);
Ok(())
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
impl Default for SectorCatalog {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn loads_all_embedded_manifests() {
let catalog = SectorCatalog::new();
assert_eq!(catalog.len(), 11);
}
#[test]
fn exactly_three_sectors_are_in_force() {
let catalog = SectorCatalog::new();
let mut in_force: Vec<&str> = catalog.in_force().iter().map(|d| d.key.as_str()).collect();
in_force.sort_unstable();
assert_eq!(in_force, vec!["battery", "electronics", "unsold-goods"]);
}
#[test]
fn provisional_sectors_are_flagged_not_dropped() {
let catalog = SectorCatalog::new();
assert_eq!(catalog.provisional().len(), 8);
assert!(!catalog.is_in_force("textile"));
assert!(!catalog.is_in_force("steel"));
}
#[test]
fn battery_descriptor_is_complete() {
let catalog = SectorCatalog::new();
let battery = catalog.get("battery").expect("battery in catalog");
assert_eq!(battery.status, RegulatoryStatus::InForce);
assert_eq!(battery.dpp_applies_from.as_deref(), Some("2027-02-18"));
assert_eq!(battery.retention_years, 10);
assert!(battery.schema_versions.contains(&"2.0.0".to_string()));
assert_eq!(battery.current_schema_version, "2.0.0");
assert_eq!(battery.plugin.as_deref(), Some("sector-battery"));
}
#[test]
fn resolve_schema_version_new_vs_existing() {
let catalog = SectorCatalog::new();
assert_eq!(
catalog.resolve_schema_version("battery", None).as_deref(),
Some("2.0.0")
);
assert_eq!(
catalog
.resolve_schema_version("battery", Some("1.0.0"))
.as_deref(),
Some("1.0.0")
);
assert_eq!(catalog.resolve_schema_version("unknown", None), None);
}
#[test]
fn in_force_gating_is_status_driven() {
let catalog = SectorCatalog::new();
assert!(catalog.is_in_force("battery"));
assert!(catalog.is_in_force("unsold-goods"));
assert!(catalog.is_in_force("electronics"));
assert!(!catalog.is_in_force("detergent")); assert!(!catalog.is_in_force("nonexistent"));
}
#[test]
fn allows_determination_matches_status() {
assert!(RegulatoryStatus::InForce.allows_determination());
assert!(!RegulatoryStatus::Provisional.allows_determination());
}
#[test]
fn register_runtime_sector() {
let mut catalog = SectorCatalog::new();
let descriptor = SectorDescriptor {
key: "plastics".into(),
title: "Plastics".into(),
status: RegulatoryStatus::Provisional,
legal_basis: vec!["ESPR Working Plan".into()],
dpp_applies_from: None,
retention_years: 10,
schema_versions: vec!["1.0.0".into()],
current_schema_version: "1.0.0".into(),
product_categories: vec![],
access_tiers: std::collections::HashMap::new(),
plugin: None,
notes: None,
};
assert!(catalog.register(descriptor.clone()).is_ok());
assert_eq!(catalog.len(), 12);
assert!(matches!(
catalog.register(descriptor),
Err(CatalogError::AlreadyExists(_))
));
}
#[test]
fn sector_enum_and_catalog_agree() {
use crate::domain::sector::Sector;
let catalog = SectorCatalog::new();
let typed = [
Sector::Battery,
Sector::Textile,
Sector::UnsoldGoods,
Sector::Steel,
Sector::Electronics,
Sector::Construction,
Sector::Tyre,
Sector::Toy,
Sector::Aluminium,
Sector::Furniture,
Sector::Detergent,
];
for sector in &typed {
let key = sector.catalog_key();
assert!(
catalog.get(key).is_some(),
"Sector::{sector:?} (key '{key}') has no embedded catalog entry"
);
}
let typed_keys: std::collections::HashSet<&str> =
typed.iter().map(Sector::catalog_key).collect();
for key in catalog.keys() {
assert!(
typed_keys.contains(key),
"catalog key '{key}' has no corresponding typed Sector variant"
);
}
}
#[test]
fn unsold_goods_cites_espr_article_25() {
let catalog = SectorCatalog::new();
let textile = catalog.get("unsold-goods").expect("unsold-goods present");
let basis = textile.legal_basis.join(" ");
assert!(
basis.contains("Article 25"),
"unsold-goods legal basis must cite ESPR Article 25, got: {basis}"
);
assert!(
!basis.contains("Article 22"),
"the incorrect Article 22 citation must be gone, got: {basis}"
);
}
#[test]
fn descriptor_round_trips_camel_case() {
let catalog = SectorCatalog::new();
let battery = catalog.get("battery").unwrap();
let json = serde_json::to_value(battery).unwrap();
assert_eq!(json["dppAppliesFrom"], "2027-02-18");
assert_eq!(json["status"], "in_force");
let back: SectorDescriptor = serde_json::from_value(json).unwrap();
assert_eq!(back.key, "battery");
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn access_tiers_keys_match_schema_properties() {
use crate::schemas::VersionedSchemaRegistry;
let catalog = SectorCatalog::new();
let registry = VersionedSchemaRegistry::new();
for descriptor in catalog.all() {
if descriptor.access_tiers.is_empty() {
continue;
}
let version: semver::Version = descriptor
.current_schema_version
.parse()
.unwrap_or_else(|_| {
panic!(
"sector '{}' currentSchemaVersion '{}' is not valid semver",
descriptor.key, descriptor.current_schema_version
)
});
let schema_json = registry.get(&descriptor.key, &version).unwrap_or_else(|| {
panic!(
"schema not found for sector '{}' v{}",
descriptor.key, descriptor.current_schema_version
)
});
let schema: serde_json::Value =
serde_json::from_str(schema_json).expect("embedded schema must be valid JSON");
let properties = schema
.get("properties")
.and_then(|p| p.as_object())
.unwrap_or_else(|| {
panic!(
"schema for sector '{}' has no top-level 'properties' object",
descriptor.key
)
});
for key in descriptor.access_tiers.keys() {
assert!(
properties.contains_key(key),
"access_tiers key '{}' in sector '{}' does not match any property in schema v{} \
(properties: {:?}). Either rename the key to match the serialised field name, \
or remove it — a mismatched key silently fails to gate the field.",
key,
descriptor.key,
descriptor.current_schema_version,
properties.keys().collect::<Vec<_>>()
);
}
}
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn catalog_agrees_with_schema_registry() {
use crate::schemas::VersionedSchemaRegistry;
let catalog = SectorCatalog::new();
let registry = VersionedSchemaRegistry::new();
for d in catalog.all() {
let reg_versions: Vec<String> = registry
.versions_for(&d.key)
.iter()
.map(|v| v.to_string())
.collect();
for v in &d.schema_versions {
assert!(
reg_versions.contains(v),
"catalog sector '{}' declares schema {v} not embedded in the registry (registry has {reg_versions:?})",
d.key
);
}
assert!(
d.schema_versions.contains(&d.current_schema_version),
"catalog sector '{}' currentSchemaVersion {} is not in its schemaVersions {:?}",
d.key,
d.current_schema_version,
d.schema_versions
);
}
for sector in registry.sectors() {
assert!(
catalog.get(sector).is_some(),
"schema registry has sector '{sector}' with no catalog entry"
);
}
for d in catalog.in_force() {
assert!(
d.plugin.is_some(),
"in-force sector '{}' must declare a plugin binding",
d.key
);
}
}
}