use std::collections::{HashMap, HashSet, 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(),
})?;
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: to.to_string(),
lens_chain,
lossy,
})
}
pub fn upcast_str(
&self,
sector: &str,
data: &Value,
from: &str,
to: &str,
) -> Result<DerivedView, UpcastError> {
let parse = |s: &str| {
s.trim_start_matches('v')
.parse::<Version>()
.map_err(|_| UpcastError::BadVersion(s.to_owned()))
};
self.upcast(sector, data, &parse(from)?, &parse(to)?)
}
fn path(&self, sector: &str, from: &Version, to: &Version) -> Option<Vec<usize>> {
let mut queue: VecDeque<Version> = VecDeque::from([from.clone()]);
let mut visited: HashSet<Version> = HashSet::from([from.clone()]);
let mut prev: HashMap<Version, usize> = HashMap::new();
while let Some(v) = queue.pop_front() {
if &v == to {
break;
}
for (i, lens) in self.lenses.iter().enumerate() {
if lens.sector == sector && lens.from == v && visited.insert(lens.to.clone()) {
prev.insert(lens.to.clone(), i);
queue.push_back(lens.to.clone());
}
}
}
if !prev.contains_key(to) {
return None;
}
let mut path = Vec::new();
let mut cur = to.clone();
while &cur != from {
let i = *prev.get(&cur)?;
path.push(i);
cur = self.lenses[i].from.clone();
}
path.reverse();
Some(path)
}
}
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,
)]
}
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());
}
#[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 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");
}
}