1use serde::{Deserialize, Serialize};
21
22struct EmbeddedManifest {
25 key: &'static str,
26 json: &'static str,
27}
28
29const EMBEDDED: &[EmbeddedManifest] = &[
32 EmbeddedManifest {
33 key: "battery",
34 json: include_str!("../../sectors/battery.json"),
35 },
36 EmbeddedManifest {
37 key: "electronics",
38 json: include_str!("../../sectors/electronics.json"),
39 },
40 EmbeddedManifest {
41 key: "textile-unsold",
42 json: include_str!("../../sectors/textile-unsold.json"),
43 },
44 EmbeddedManifest {
45 key: "textile",
46 json: include_str!("../../sectors/textile.json"),
47 },
48 EmbeddedManifest {
49 key: "steel",
50 json: include_str!("../../sectors/steel.json"),
51 },
52 EmbeddedManifest {
53 key: "construction",
54 json: include_str!("../../sectors/construction.json"),
55 },
56 EmbeddedManifest {
57 key: "tyre",
58 json: include_str!("../../sectors/tyre.json"),
59 },
60 EmbeddedManifest {
61 key: "toy",
62 json: include_str!("../../sectors/toy.json"),
63 },
64 EmbeddedManifest {
65 key: "aluminium",
66 json: include_str!("../../sectors/aluminium.json"),
67 },
68 EmbeddedManifest {
69 key: "furniture",
70 json: include_str!("../../sectors/furniture.json"),
71 },
72 EmbeddedManifest {
73 key: "detergent",
74 json: include_str!("../../sectors/detergent.json"),
75 },
76];
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83#[non_exhaustive]
84pub enum RegulatoryStatus {
85 InForce,
88 Provisional,
92}
93
94impl RegulatoryStatus {
95 #[must_use]
98 pub fn allows_determination(&self) -> bool {
99 matches!(self, Self::InForce)
100 }
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize)]
108#[serde(rename_all = "camelCase")]
109pub struct SectorDescriptor {
110 pub key: String,
113 pub title: String,
115 pub status: RegulatoryStatus,
117 pub legal_basis: Vec<String>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub dpp_applies_from: Option<String>,
122 pub retention_years: u32,
124 pub schema_versions: Vec<String>,
126 pub current_schema_version: String,
131 #[serde(default)]
134 pub product_categories: Vec<String>,
135 #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
140 pub access_tiers: std::collections::HashMap<String, crate::domain::identity::AccessTier>,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub plugin: Option<String>,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub notes: Option<String>,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq)]
154#[non_exhaustive]
155pub enum CatalogError {
156 AlreadyExists(String),
158}
159
160impl std::fmt::Display for CatalogError {
161 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162 match self {
163 Self::AlreadyExists(key) => write!(f, "sector '{key}' already in catalog"),
164 }
165 }
166}
167
168impl std::error::Error for CatalogError {}
169
170pub struct SectorCatalog {
175 entries: Vec<SectorDescriptor>,
176}
177
178impl SectorCatalog {
179 #[must_use]
181 pub fn new() -> Self {
182 let entries = EMBEDDED
183 .iter()
184 .map(|m| {
185 let descriptor: SectorDescriptor =
186 serde_json::from_str(m.json).unwrap_or_else(|e| {
187 panic!("embedded sector manifest '{}' is invalid: {e}", m.key)
188 });
189 assert_eq!(
190 descriptor.key, m.key,
191 "manifest key '{}' does not match its file key '{}'",
192 descriptor.key, m.key
193 );
194 descriptor
195 })
196 .collect();
197 Self { entries }
198 }
199
200 #[must_use]
202 pub fn get(&self, key: &str) -> Option<&SectorDescriptor> {
203 self.entries.iter().find(|d| d.key == key)
204 }
205
206 #[must_use]
208 pub fn all(&self) -> &[SectorDescriptor] {
209 &self.entries
210 }
211
212 #[must_use]
214 pub fn in_force(&self) -> Vec<&SectorDescriptor> {
215 self.entries
216 .iter()
217 .filter(|d| d.status == RegulatoryStatus::InForce)
218 .collect()
219 }
220
221 #[must_use]
223 pub fn provisional(&self) -> Vec<&SectorDescriptor> {
224 self.entries
225 .iter()
226 .filter(|d| d.status == RegulatoryStatus::Provisional)
227 .collect()
228 }
229
230 #[must_use]
232 pub fn is_in_force(&self, key: &str) -> bool {
233 self.get(key)
234 .is_some_and(|d| d.status.allows_determination())
235 }
236
237 #[must_use]
239 pub fn current_schema_version(&self, key: &str) -> Option<&str> {
240 self.get(key).map(|d| d.current_schema_version.as_str())
241 }
242
243 #[must_use]
254 pub fn resolve_schema_version(&self, key: &str, stored: Option<&str>) -> Option<String> {
255 match stored {
256 Some(v) => Some(v.to_owned()),
257 None => self.current_schema_version(key).map(ToOwned::to_owned),
258 }
259 }
260
261 #[must_use]
263 pub fn keys(&self) -> Vec<&str> {
264 let mut keys: Vec<&str> = self.entries.iter().map(|d| d.key.as_str()).collect();
265 keys.sort_unstable();
266 keys
267 }
268
269 pub fn register(&mut self, descriptor: SectorDescriptor) -> Result<(), CatalogError> {
272 if self.get(&descriptor.key).is_some() {
273 return Err(CatalogError::AlreadyExists(descriptor.key));
274 }
275 self.entries.push(descriptor);
276 Ok(())
277 }
278
279 #[must_use]
281 pub fn len(&self) -> usize {
282 self.entries.len()
283 }
284
285 #[must_use]
287 pub fn is_empty(&self) -> bool {
288 self.entries.is_empty()
289 }
290}
291
292impl Default for SectorCatalog {
293 fn default() -> Self {
294 Self::new()
295 }
296}
297
298#[cfg(test)]
301mod tests {
302 use super::*;
303
304 #[test]
305 fn loads_all_embedded_manifests() {
306 let catalog = SectorCatalog::new();
307 assert_eq!(catalog.len(), 11);
308 }
309
310 #[test]
311 fn exactly_three_sectors_are_in_force() {
312 let catalog = SectorCatalog::new();
313 let mut in_force: Vec<&str> = catalog.in_force().iter().map(|d| d.key.as_str()).collect();
314 in_force.sort_unstable();
315 assert_eq!(in_force, vec!["battery", "electronics", "textile-unsold"]);
316 }
317
318 #[test]
319 fn provisional_sectors_are_flagged_not_dropped() {
320 let catalog = SectorCatalog::new();
321 assert_eq!(catalog.provisional().len(), 8);
323 assert!(!catalog.is_in_force("textile"));
324 assert!(!catalog.is_in_force("steel"));
325 }
326
327 #[test]
328 fn battery_descriptor_is_complete() {
329 let catalog = SectorCatalog::new();
330 let battery = catalog.get("battery").expect("battery in catalog");
331 assert_eq!(battery.status, RegulatoryStatus::InForce);
332 assert_eq!(battery.dpp_applies_from.as_deref(), Some("2027-02-18"));
333 assert_eq!(battery.retention_years, 10);
334 assert!(battery.schema_versions.contains(&"2.0.0".to_string()));
335 assert_eq!(battery.current_schema_version, "2.0.0");
337 assert_eq!(battery.plugin.as_deref(), Some("sector-battery"));
338 }
339
340 #[test]
341 fn resolve_schema_version_new_vs_existing() {
342 let catalog = SectorCatalog::new();
343 assert_eq!(
345 catalog.resolve_schema_version("battery", None).as_deref(),
346 Some("2.0.0")
347 );
348 assert_eq!(
350 catalog
351 .resolve_schema_version("battery", Some("1.0.0"))
352 .as_deref(),
353 Some("1.0.0")
354 );
355 assert_eq!(catalog.resolve_schema_version("unknown", None), None);
357 }
358
359 #[test]
360 fn in_force_gating_is_status_driven() {
361 let catalog = SectorCatalog::new();
362 assert!(catalog.is_in_force("battery"));
363 assert!(catalog.is_in_force("textile-unsold"));
364 assert!(catalog.is_in_force("electronics"));
365 assert!(!catalog.is_in_force("detergent")); assert!(!catalog.is_in_force("nonexistent"));
367 }
368
369 #[test]
370 fn allows_determination_matches_status() {
371 assert!(RegulatoryStatus::InForce.allows_determination());
372 assert!(!RegulatoryStatus::Provisional.allows_determination());
373 }
374
375 #[test]
376 fn register_runtime_sector() {
377 let mut catalog = SectorCatalog::new();
378 let descriptor = SectorDescriptor {
379 key: "plastics".into(),
380 title: "Plastics".into(),
381 status: RegulatoryStatus::Provisional,
382 legal_basis: vec!["ESPR Working Plan".into()],
383 dpp_applies_from: None,
384 retention_years: 10,
385 schema_versions: vec!["1.0.0".into()],
386 current_schema_version: "1.0.0".into(),
387 product_categories: vec![],
388 access_tiers: std::collections::HashMap::new(),
389 plugin: None,
390 notes: None,
391 };
392 assert!(catalog.register(descriptor.clone()).is_ok());
393 assert_eq!(catalog.len(), 12);
394 assert!(matches!(
395 catalog.register(descriptor),
396 Err(CatalogError::AlreadyExists(_))
397 ));
398 }
399
400 #[test]
408 fn sector_enum_and_catalog_agree() {
409 use crate::domain::sector::Sector;
410
411 let catalog = SectorCatalog::new();
412
413 let typed = [
415 Sector::Battery,
416 Sector::Textile,
417 Sector::TextileUnsoldGoods,
418 Sector::Steel,
419 Sector::Electronics,
420 Sector::Construction,
421 Sector::Tyre,
422 Sector::Toy,
423 Sector::Aluminium,
424 Sector::Furniture,
425 Sector::Detergent,
426 ];
427 for sector in &typed {
428 let key = sector.catalog_key();
429 assert!(
430 catalog.get(key).is_some(),
431 "Sector::{sector:?} (key '{key}') has no embedded catalog entry"
432 );
433 }
434
435 let typed_keys: std::collections::HashSet<&str> =
437 typed.iter().map(Sector::catalog_key).collect();
438 for key in catalog.keys() {
439 assert!(
440 typed_keys.contains(key),
441 "catalog key '{key}' has no corresponding typed Sector variant"
442 );
443 }
444 }
445
446 #[test]
451 fn unsold_goods_cites_espr_article_25() {
452 let catalog = SectorCatalog::new();
453 let textile = catalog
454 .get("textile-unsold")
455 .expect("textile-unsold present");
456 let basis = textile.legal_basis.join(" ");
457 assert!(
458 basis.contains("Article 25"),
459 "unsold-goods legal basis must cite ESPR Article 25, got: {basis}"
460 );
461 assert!(
462 !basis.contains("Article 22"),
463 "the incorrect Article 22 citation must be gone, got: {basis}"
464 );
465 }
466
467 #[test]
468 fn descriptor_round_trips_camel_case() {
469 let catalog = SectorCatalog::new();
470 let battery = catalog.get("battery").unwrap();
471 let json = serde_json::to_value(battery).unwrap();
472 assert_eq!(json["dppAppliesFrom"], "2027-02-18");
473 assert_eq!(json["status"], "in_force");
474 let back: SectorDescriptor = serde_json::from_value(json).unwrap();
475 assert_eq!(back.key, "battery");
476 }
477
478 #[cfg(not(target_arch = "wasm32"))]
482 #[test]
483 fn access_tiers_keys_match_schema_properties() {
484 use crate::schemas::VersionedSchemaRegistry;
485 let catalog = SectorCatalog::new();
486 let registry = VersionedSchemaRegistry::new();
487
488 for descriptor in catalog.all() {
489 if descriptor.access_tiers.is_empty() {
490 continue;
491 }
492 let version: semver::Version = descriptor
493 .current_schema_version
494 .parse()
495 .unwrap_or_else(|_| {
496 panic!(
497 "sector '{}' currentSchemaVersion '{}' is not valid semver",
498 descriptor.key, descriptor.current_schema_version
499 )
500 });
501 let schema_json = registry.get(&descriptor.key, &version).unwrap_or_else(|| {
502 panic!(
503 "schema not found for sector '{}' v{}",
504 descriptor.key, descriptor.current_schema_version
505 )
506 });
507 let schema: serde_json::Value =
508 serde_json::from_str(schema_json).expect("embedded schema must be valid JSON");
509 let properties = schema
510 .get("properties")
511 .and_then(|p| p.as_object())
512 .unwrap_or_else(|| {
513 panic!(
514 "schema for sector '{}' has no top-level 'properties' object",
515 descriptor.key
516 )
517 });
518
519 for key in descriptor.access_tiers.keys() {
520 assert!(
521 properties.contains_key(key),
522 "access_tiers key '{}' in sector '{}' does not match any property in schema v{} \
523 (properties: {:?}). Either rename the key to match the serialised field name, \
524 or remove it — a mismatched key silently fails to gate the field.",
525 key,
526 descriptor.key,
527 descriptor.current_schema_version,
528 properties.keys().collect::<Vec<_>>()
529 );
530 }
531 }
532 }
533
534 #[cfg(not(target_arch = "wasm32"))]
537 #[test]
538 fn catalog_agrees_with_schema_registry() {
539 use crate::schemas::VersionedSchemaRegistry;
540 let catalog = SectorCatalog::new();
541 let registry = VersionedSchemaRegistry::new();
542
543 for d in catalog.all() {
546 let reg_versions: Vec<String> = registry
547 .versions_for(&d.key)
548 .iter()
549 .map(|v| v.to_string())
550 .collect();
551 for v in &d.schema_versions {
552 assert!(
553 reg_versions.contains(v),
554 "catalog sector '{}' declares schema {v} not embedded in the registry (registry has {reg_versions:?})",
555 d.key
556 );
557 }
558 assert!(
559 d.schema_versions.contains(&d.current_schema_version),
560 "catalog sector '{}' currentSchemaVersion {} is not in its schemaVersions {:?}",
561 d.key,
562 d.current_schema_version,
563 d.schema_versions
564 );
565 }
566
567 for sector in registry.sectors() {
569 assert!(
570 catalog.get(sector).is_some(),
571 "schema registry has sector '{sector}' with no catalog entry"
572 );
573 }
574
575 for d in catalog.in_force() {
577 assert!(
578 d.plugin.is_some(),
579 "in-force sector '{}' must declare a plugin binding",
580 d.key
581 );
582 }
583 }
584}