use std::collections::{HashMap, VecDeque};
use semver::Version;
use serde_json::Value;
pub struct Lens {
pub sector: String,
pub from: Version,
pub to: Version,
pub lossy: bool,
pub note: &'static str,
transform: fn(&Value) -> Result<Value, LensError>,
}
impl Lens {
#[must_use]
pub fn new(
sector: impl Into<String>,
from: Version,
to: Version,
lossy: bool,
note: &'static str,
transform: fn(&Value) -> Result<Value, LensError>,
) -> Self {
Self {
sector: sector.into(),
from,
to,
lossy,
note,
transform,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LensError(pub String);
impl std::fmt::Display for LensError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "lens transform failed: {}", self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DerivedView {
pub data: Value,
pub derived: bool,
pub from: String,
pub to: String,
pub lens_chain: Vec<[String; 2]>,
pub lossy: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UpcastError {
NoPath {
sector: String,
from: Version,
to: Version,
},
NotAnUpcast { from: Version, to: Version },
Transform(LensError),
BadVersion(String),
}
impl std::fmt::Display for UpcastError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoPath { sector, from, to } => {
write!(f, "no lens path for {sector} {from} → {to}")
}
Self::NotAnUpcast { from, to } => {
write!(
f,
"{to} is not an upcast of {from} — downcast is unsupported"
)
}
Self::Transform(e) => write!(f, "{e}"),
Self::BadVersion(v) => write!(f, "'{v}' is not a valid semver version"),
}
}
}
impl std::error::Error for UpcastError {}
pub struct LensRegistry {
lenses: Vec<Lens>,
}
impl LensRegistry {
#[must_use]
pub fn new() -> Self {
Self {
lenses: builtin_lenses(),
}
}
#[must_use]
pub fn from_lenses(lenses: Vec<Lens>) -> Self {
Self { lenses }
}
pub fn upcast(
&self,
sector: &str,
data: &Value,
from: &Version,
to: &Version,
) -> Result<DerivedView, UpcastError> {
match to.cmp(from) {
std::cmp::Ordering::Less => {
return Err(UpcastError::NotAnUpcast {
from: from.clone(),
to: to.clone(),
});
}
std::cmp::Ordering::Equal => {
return Ok(DerivedView {
data: data.clone(),
derived: true,
from: from.to_string(),
to: to.to_string(),
lens_chain: Vec::new(),
lossy: false,
});
}
std::cmp::Ordering::Greater => {}
}
let path = self
.path(sector, from, to)
.ok_or_else(|| UpcastError::NoPath {
sector: sector.to_owned(),
from: from.clone(),
to: to.clone(),
})?;
self.apply(data, from, to, &path)
}
pub fn upcast_toward(
&self,
sector: &str,
data: &Value,
from: &Version,
to: &Version,
) -> Result<DerivedView, UpcastError> {
match to.cmp(from) {
std::cmp::Ordering::Less => {
return Err(UpcastError::NotAnUpcast {
from: from.clone(),
to: to.clone(),
});
}
std::cmp::Ordering::Equal => return self.apply(data, from, from, &[]),
std::cmp::Ordering::Greater => {}
}
let (reached, path) = self
.reachable(sector, from)
.into_iter()
.filter(|(v, _)| v <= to)
.max_by(|(a, _), (b, _)| a.cmp(b))
.ok_or_else(|| UpcastError::NoPath {
sector: sector.to_owned(),
from: from.clone(),
to: to.clone(),
})?;
self.apply(data, from, &reached, &path)
}
pub fn upcast_str_toward(
&self,
sector: &str,
data: &Value,
from: &str,
to: &str,
) -> Result<DerivedView, UpcastError> {
self.upcast_toward(sector, data, &parse_version(from)?, &parse_version(to)?)
}
fn apply(
&self,
data: &Value,
from: &Version,
reached: &Version,
path: &[usize],
) -> Result<DerivedView, UpcastError> {
let mut current = data.clone();
let mut lens_chain = Vec::new();
let mut lossy = false;
for &i in path {
let lens = &self.lenses[i];
current = (lens.transform)(¤t).map_err(UpcastError::Transform)?;
lens_chain.push([lens.from.to_string(), lens.to.to_string()]);
lossy |= lens.lossy;
}
Ok(DerivedView {
data: current,
derived: true,
from: from.to_string(),
to: reached.to_string(),
lens_chain,
lossy,
})
}
pub fn upcast_str(
&self,
sector: &str,
data: &Value,
from: &str,
to: &str,
) -> Result<DerivedView, UpcastError> {
self.upcast(sector, data, &parse_version(from)?, &parse_version(to)?)
}
fn path(&self, sector: &str, from: &Version, to: &Version) -> Option<Vec<usize>> {
self.reachable(sector, from).remove(to)
}
fn reachable(&self, sector: &str, from: &Version) -> HashMap<Version, Vec<usize>> {
let mut queue: VecDeque<Version> = VecDeque::from([from.clone()]);
let mut paths: HashMap<Version, Vec<usize>> = HashMap::from([(from.clone(), Vec::new())]);
while let Some(v) = queue.pop_front() {
let so_far = paths[&v].clone();
for (i, lens) in self.lenses.iter().enumerate() {
if lens.sector == sector && lens.from == v && !paths.contains_key(&lens.to) {
let mut path = so_far.clone();
path.push(i);
paths.insert(lens.to.clone(), path);
queue.push_back(lens.to.clone());
}
}
}
paths.remove(from);
paths
}
}
fn parse_version(s: &str) -> Result<Version, UpcastError> {
s.trim_start_matches('v')
.parse::<Version>()
.map_err(|_| UpcastError::BadVersion(s.to_owned()))
}
impl Default for LensRegistry {
fn default() -> Self {
Self::new()
}
}
fn builtin_lenses() -> Vec<Lens> {
vec![
Lens::new(
"battery",
Version::new(1, 0, 0),
Version::new(2, 0, 0),
false,
"EU Battery Regulation 2023/1542 Annex XIII v2.0.0: derives ratedEnergyWh (Wh) \
from v1 ratedCapacityKwh (kWh); every other v2 field is an optional addition.",
battery_v1_to_v2,
),
Lens::new(
"steel",
Version::new(1, 0, 0),
Version::new(1, 1, 0),
false,
"Cross-sector naming consistency: renames countryOfProduction to \
countryOfOrigin. Pure rename, no information lost.",
rename_country_of_production,
),
Lens::new(
"aluminium",
Version::new(1, 0, 0),
Version::new(1, 1, 0),
false,
"Cross-sector naming consistency: renames countryOfProduction to \
countryOfOrigin. Pure rename, no information lost.",
rename_country_of_production,
),
Lens::new(
"construction",
Version::new(1, 0, 0),
Version::new(1, 1, 0),
false,
"Cross-sector naming consistency: renames countryOfManufacture to \
countryOfOrigin. Pure rename, no information lost.",
rename_country_of_manufacture,
),
Lens::new(
"detergent",
Version::new(1, 0, 0),
Version::new(1, 1, 0),
false,
"Cross-sector naming consistency: renames countryOfManufacture to \
countryOfOrigin. Pure rename, no information lost.",
rename_country_of_manufacture,
),
Lens::new(
"furniture",
Version::new(1, 0, 0),
Version::new(1, 1, 0),
false,
"Cross-sector naming consistency: renames countryOfManufacture to \
countryOfOrigin. Pure rename, no information lost.",
rename_country_of_manufacture,
),
Lens::new(
"toy",
Version::new(1, 0, 0),
Version::new(1, 1, 0),
false,
"Cross-sector naming consistency: renames countryOfManufacture to \
countryOfOrigin. Pure rename, no information lost.",
rename_country_of_manufacture,
),
Lens::new(
"textile",
Version::new(1, 1, 0),
Version::new(1, 2, 0),
false,
"Cross-sector naming consistency: renames countryOfManufacturing to \
countryOfOrigin. Pure rename, no information lost.",
rename_country_of_manufacturing,
),
]
}
fn rename_country_field(v: &Value, old_key: &str) -> Result<Value, LensError> {
let mut out = v.clone();
let obj = out
.as_object_mut()
.ok_or_else(|| LensError("sector data must be a JSON object".to_owned()))?;
if let Some(val) = obj.remove(old_key) {
obj.insert("countryOfOrigin".to_owned(), val);
}
Ok(out)
}
fn rename_country_of_production(v: &Value) -> Result<Value, LensError> {
rename_country_field(v, "countryOfProduction")
}
fn rename_country_of_manufacture(v: &Value) -> Result<Value, LensError> {
rename_country_field(v, "countryOfManufacture")
}
fn rename_country_of_manufacturing(v: &Value) -> Result<Value, LensError> {
rename_country_field(v, "countryOfManufacturing")
}
fn battery_v1_to_v2(v1: &Value) -> Result<Value, LensError> {
let mut out = v1.clone();
let obj = out
.as_object_mut()
.ok_or_else(|| LensError("battery sector data must be a JSON object".to_owned()))?;
if let Some(kwh) = obj.get("ratedCapacityKwh").and_then(Value::as_f64)
&& !obj.contains_key("ratedEnergyWh")
{
let wh = (kwh * 1000.0 * 1_000_000.0).round() / 1_000_000.0;
obj.insert("ratedEnergyWh".to_owned(), serde_json::json!(wh));
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::schemas::VersionedSchemaRegistry;
fn v(s: &str) -> Version {
s.parse().unwrap()
}
fn battery_v1() -> Value {
serde_json::json!({
"gtin": "09506000134352",
"batteryChemistry": "LFP",
"nominalVoltageV": 48.0,
"nominalCapacityAh": 100.0,
"expectedLifetimeCycles": 3000,
"co2ePerUnitKg": 45.2,
"ratedCapacityKwh": 4.8
})
}
#[test]
fn battery_v1_upcasts_to_v2_and_validates() {
let lenses = LensRegistry::new();
let schemas = VersionedSchemaRegistry::new();
let original = battery_v1();
let derived = lenses
.upcast("battery", &original, &v("1.0.0"), &v("2.0.0"))
.unwrap();
assert!(derived.derived);
assert!(!derived.lossy);
assert_eq!(derived.from, "1.0.0");
assert_eq!(derived.to, "2.0.0");
assert_eq!(
derived.lens_chain,
vec![["1.0.0".to_string(), "2.0.0".to_string()]]
);
assert_eq!(derived.data["ratedEnergyWh"].as_f64(), Some(4800.0));
schemas
.validate("battery", &v("2.0.0"), &derived.data)
.expect("derived view must validate against v2");
assert!(original.get("ratedEnergyWh").is_none());
}
fn steel_v1() -> Value {
serde_json::json!({
"gtin": "09506000134352",
"co2ePerTonneSteel": 1.8,
"recycledScrapContentPct": 35.0,
"productCategory": "flat",
"countryOfProduction": "DE",
"productionRoute": "electric-arc"
})
}
#[test]
fn steel_v1_upcasts_to_v1_1_and_renames_country_field() {
let lenses = LensRegistry::new();
let schemas = VersionedSchemaRegistry::new();
let original = steel_v1();
let derived = lenses
.upcast("steel", &original, &v("1.0.0"), &v("1.1.0"))
.unwrap();
assert!(!derived.lossy);
assert_eq!(derived.data["countryOfOrigin"], "DE");
assert!(derived.data.get("countryOfProduction").is_none());
schemas
.validate("steel", &v("1.1.0"), &derived.data)
.expect("derived view must validate against v1.1.0");
assert_eq!(original["countryOfProduction"], "DE");
}
fn textile_v1_1() -> Value {
serde_json::json!({
"gtin": "09506000134352",
"fibreComposition": [{"fibre": "cotton", "pct": 100.0}],
"countryOfManufacturing": "PT",
"careInstructions": "Hand wash",
"chemicalComplianceStandard": "REACH"
})
}
#[test]
fn textile_v1_1_upcasts_to_v1_2_and_renames_country_field() {
let lenses = LensRegistry::new();
let schemas = VersionedSchemaRegistry::new();
let original = textile_v1_1();
let derived = lenses
.upcast("textile", &original, &v("1.1.0"), &v("1.2.0"))
.unwrap();
assert!(!derived.lossy);
assert_eq!(derived.data["countryOfOrigin"], "PT");
assert!(derived.data.get("countryOfManufacturing").is_none());
schemas
.validate("textile", &v("1.2.0"), &derived.data)
.expect("derived view must validate against v1.2.0");
}
#[test]
fn identity_view_for_same_version_is_lossless() {
let lenses = LensRegistry::new();
let data = battery_v1();
let derived = lenses
.upcast("battery", &data, &v("1.0.0"), &v("1.0.0"))
.unwrap();
assert!(derived.derived);
assert!(!derived.lossy);
assert!(derived.lens_chain.is_empty());
assert_eq!(derived.data, data);
}
#[test]
fn downcast_is_refused() {
let lenses = LensRegistry::new();
let err = lenses
.upcast("battery", &battery_v1(), &v("2.0.0"), &v("1.0.0"))
.unwrap_err();
assert!(matches!(err, UpcastError::NotAnUpcast { .. }));
}
#[test]
fn missing_hop_is_a_typed_refusal_not_silent_identity() {
let lenses = LensRegistry::new();
let err = lenses
.upcast("battery", &battery_v1(), &v("1.0.0"), &v("3.0.0"))
.unwrap_err();
assert!(matches!(err, UpcastError::NoPath { .. }));
}
fn add_a(v: &Value) -> Result<Value, LensError> {
let mut out = v.clone();
out.as_object_mut()
.ok_or_else(|| LensError("not an object".into()))?
.insert("a".into(), Value::Bool(true));
Ok(out)
}
fn add_b_lossy(v: &Value) -> Result<Value, LensError> {
let mut out = v.clone();
let obj = out
.as_object_mut()
.ok_or_else(|| LensError("not an object".into()))?;
obj.insert("b".into(), Value::Bool(true));
obj.remove("dropped"); Ok(out)
}
#[test]
fn multi_hop_chain_composes_and_propagates_loss() {
let reg = LensRegistry::from_lenses(vec![
Lens::new("demo", v("1.0.0"), v("2.0.0"), false, "add a", add_a),
Lens::new(
"demo",
v("2.0.0"),
v("3.0.0"),
true,
"add b, drop",
add_b_lossy,
),
]);
let data = serde_json::json!({ "dropped": 1 });
let derived = reg.upcast("demo", &data, &v("1.0.0"), &v("3.0.0")).unwrap();
assert_eq!(derived.data["a"], Value::Bool(true));
assert_eq!(derived.data["b"], Value::Bool(true));
assert!(derived.data.get("dropped").is_none());
assert!(derived.lossy, "a lossy hop must mark the whole chain lossy");
assert_eq!(
derived.lens_chain,
vec![
["1.0.0".to_string(), "2.0.0".to_string()],
["2.0.0".to_string(), "3.0.0".to_string()],
]
);
}
#[test]
fn toward_reaches_the_newest_registered_version_short_of_the_target() {
let reg = LensRegistry::new();
let current: Version = crate::catalog::SectorCatalog::new()
.current_schema_version("battery")
.expect("battery is in the catalog")
.parse()
.expect("catalog versions are semver");
assert!(
current > v("2.0.0"),
"this test is only meaningful while battery's current version is \
past its last lens — got {current}"
);
assert!(matches!(
reg.upcast("battery", &battery_v1(), &v("1.0.0"), ¤t),
Err(UpcastError::NoPath { .. })
));
let derived = reg
.upcast_toward("battery", &battery_v1(), &v("1.0.0"), ¤t)
.expect("the 1.0.0 -> 2.0.0 hop must still be applied");
assert_eq!(derived.to, "2.0.0");
assert_eq!(derived.from, "1.0.0");
assert_eq!(
derived.lens_chain,
vec![["1.0.0".to_string(), "2.0.0".to_string()]]
);
assert_eq!(derived.data["ratedEnergyWh"].as_f64(), Some(4800.0));
}
#[test]
fn toward_never_overshoots_the_target() {
let reg = LensRegistry::from_lenses(vec![
Lens::new("demo", v("1.0.0"), v("2.0.0"), false, "add a", add_a),
Lens::new("demo", v("2.0.0"), v("3.0.0"), false, "add b", add_b_lossy),
]);
let data = serde_json::json!({ "dropped": 1 });
let derived = reg
.upcast_toward("demo", &data, &v("1.0.0"), &v("2.5.0"))
.expect("2.0.0 is reachable and below the ceiling");
assert_eq!(derived.to, "2.0.0");
assert_eq!(derived.data["a"], Value::Bool(true));
assert!(derived.data.get("b").is_none());
}
#[test]
fn toward_still_refuses_a_gap_no_lens_touches_at_all() {
let reg = LensRegistry::new();
let err = reg
.upcast_toward("textile", &serde_json::json!({}), &v("1.0.0"), &v("1.2.0"))
.unwrap_err();
assert!(
matches!(err, UpcastError::NoPath { .. }),
"nothing leaves textile 1.0.0, so this must stay a typed refusal"
);
}
#[test]
fn toward_refuses_a_downcast() {
let reg = LensRegistry::new();
assert!(matches!(
reg.upcast_toward("battery", &battery_v1(), &v("2.0.0"), &v("1.0.0")),
Err(UpcastError::NotAnUpcast { .. })
));
}
#[test]
fn toward_is_the_identity_for_the_same_version() {
let reg = LensRegistry::new();
let data = battery_v1();
let derived = reg
.upcast_toward("battery", &data, &v("2.0.0"), &v("2.0.0"))
.expect("same version is the identity, not a missing path");
assert!(derived.lens_chain.is_empty());
assert!(!derived.lossy);
assert_eq!(derived.to, "2.0.0");
assert_eq!(derived.data, data);
}
#[test]
fn upcast_str_tolerates_v_prefix_and_refuses_garbage() {
let reg = LensRegistry::new();
let data = battery_v1();
assert!(reg.upcast_str("battery", &data, "v1.0.0", "v2.0.0").is_ok());
assert!(matches!(
reg.upcast_str("battery", &data, "1.0.0", "two"),
Err(UpcastError::BadVersion(_))
));
}
#[test]
fn battery_lens_derives_clean_watt_hours() {
let reg = LensRegistry::new();
for (kwh, wh) in [(4.8, 4800.0), (0.1, 100.0), (4.8005, 4800.5)] {
let mut data = battery_v1();
data.as_object_mut()
.unwrap()
.insert("ratedCapacityKwh".into(), serde_json::json!(kwh));
let d = reg
.upcast("battery", &data, &v("1.0.0"), &v("2.0.0"))
.unwrap();
assert_eq!(d.data["ratedEnergyWh"].as_f64(), Some(wh), "kwh {kwh}");
}
}
#[test]
fn battery_lens_with_nothing_to_derive_still_validates_against_v2() {
let mut data = battery_v1();
data.as_object_mut().unwrap().remove("ratedCapacityKwh");
let reg = LensRegistry::new();
let schemas = VersionedSchemaRegistry::new();
let derived = reg
.upcast("battery", &data, &v("1.0.0"), &v("2.0.0"))
.unwrap();
assert!(derived.data.get("ratedEnergyWh").is_none());
schemas
.validate("battery", &v("2.0.0"), &derived.data)
.expect("a v1 record with no rated capacity still validates against v2");
}
}