1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
//! [`LensRegistry`] — composes single-hop lenses into multi-hop chains at read time.
use std::collections::{HashMap, VecDeque};
use semver::Version;
use serde_json::Value;
use super::builtin::builtin_lenses;
use super::derived_view::DerivedView;
use super::transform::Lens;
use super::upcast_error::UpcastError;
/// A registry of upcast lenses, composing single-hop transforms into multi-hop
/// chains at read time.
pub struct LensRegistry {
lenses: Vec<Lens>,
}
impl LensRegistry {
/// A registry pre-loaded with the compiled-in built-in lenses.
#[must_use]
pub fn new() -> Self {
Self {
lenses: builtin_lenses(),
}
}
/// Build a registry from an explicit lens set (extensibility / tests).
#[must_use]
pub fn from_lenses(lenses: Vec<Lens>) -> Self {
Self { lenses }
}
/// Upcast `data` for `product_group` from `from` up to `to`, composing single-hop
/// lenses along the fewest-hop path.
///
/// `from == to` is the identity (a no-loss derived view of the same version).
/// A `to` older than `from` is a downcast and is refused; a gap no chain of
/// lenses bridges is refused — both with a typed error, never a silent
/// identity.
pub fn upcast(
&self,
product_group: &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(product_group, from, to)
.ok_or_else(|| UpcastError::NoPath {
product_group: product_group.to_owned(),
from: from.clone(),
to: to.clone(),
})?;
self.apply(data, from, to, &path)
}
/// Upcast `data` for `product_group` as far toward `to` as the registered lenses
/// reach, stopping at the newest reachable version no newer than `to`.
///
/// [`Self::upcast`] demands a path to exactly `to` and refuses anything
/// short of it. That is right for a caller that asked to see a specific
/// version, and wrong for a reader that only needs stored data readable at
/// the current one: a purely additive version bump after a lens leaves no
/// hop ending at the exact current version, so an exact-path search refuses
/// a document that the hops it *does* have would have made perfectly
/// readable. Battery is already in that position — the registry bridges
/// `1.0.0 → 2.0.0` while the current version is further on, every step
/// beyond it additive and correctly lens-free.
///
/// The remaining additive gap needs no transform by definition, so the
/// caller's own deserialize closes it. What this will not do is pretend to
/// have bridged something: a real gap that no hop touches is refused with
/// [`UpcastError::NoPath`] rather than returned as a silent identity, and
/// the returned [`DerivedView`] reports the version actually reached, never
/// the one requested. `from == to` is the identity, as for
/// [`Self::upcast`] — there is no gap, so there is no progress to require.
pub fn upcast_toward(
&self,
product_group: &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(),
});
}
// No gap to bridge, so no progress to require: the identity, as
// [`Self::upcast`] gives for the same input.
std::cmp::Ordering::Equal => return self.apply(data, from, from, &[]),
std::cmp::Ordering::Greater => {}
}
// The newest version reachable that `to` does not precede — fewest hops
// is already guaranteed per destination by the breadth-first search.
let (reached, path) = self
.reachable(product_group, from)
.into_iter()
.filter(|(v, _)| v <= to)
.max_by(|(a, _), (b, _)| a.cmp(b))
.ok_or_else(|| UpcastError::NoPath {
product_group: product_group.to_owned(),
from: from.clone(),
to: to.clone(),
})?;
self.apply(data, from, &reached, &path)
}
/// [`Self::upcast_toward`] taking version *strings*, mirroring
/// [`Self::upcast_str`].
pub fn upcast_str_toward(
&self,
product_group: &str,
data: &Value,
from: &str,
to: &str,
) -> Result<DerivedView, UpcastError> {
self.upcast_toward(
product_group,
data,
&parse_version(from)?,
&parse_version(to)?,
)
}
/// Run `path`'s hops over `data`, recording the chain and whether any hop
/// was lossy. `reached` is the version the chain actually ends at, which is
/// not always the one a caller asked for — see [`Self::upcast_toward`].
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,
})
}
/// [`Self::upcast`] taking version *strings* — the read-path convenience so
/// callers (HTTP handlers) don't depend on `semver`. A leading `v` is
/// tolerated (`v2.0.0`); an unparseable version is a typed refusal.
pub fn upcast_str(
&self,
product_group: &str,
data: &Value,
from: &str,
to: &str,
) -> Result<DerivedView, UpcastError> {
self.upcast(
product_group,
data,
&parse_version(from)?,
&parse_version(to)?,
)
}
/// Fewest-hop lens path (as lens indices) from `from` to `to` for `product_group`.
/// `None` if no path.
fn path(&self, product_group: &str, from: &Version, to: &Version) -> Option<Vec<usize>> {
self.reachable(product_group, from).remove(to)
}
/// Every version reachable from `from` for `product_group`, each mapped to the
/// fewest-hop lens path that reaches it, via breadth-first search over the
/// product group's lens graph. Excludes `from` itself: the identity is not a path.
fn reachable(&self, product_group: &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() {
// Breadth-first, so the first path found to a version is a shortest
// one and later arrivals at it are ignored.
let so_far = paths[&v].clone();
for (i, lens) in self.lenses.iter().enumerate() {
if lens.product_group == product_group
&& 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
}
}
/// Parse a version string, tolerating a leading `v` (`v2.0.0`). An unparseable
/// version is a typed refusal, never a silent identity.
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()
}
}