interface/lib.rs
1//! Typed, lossy-aware interface translation between API versions.
2//!
3//! This crate models the migration of data structures between versioned API
4//! schemas. Each translation carries a [`Diff`] and is parameterised by a
5//! [`Lossiness`] marker that distinguishes whether the conversion is
6//! information-preserving ([`Lossless`]) or destructive ([`Lossy`]).
7//!
8//! # Quick start
9//!
10//! ```rust
11//! use interface::{Diff, Lossless, Lossy, Translation, Upgrade, Downgrade};
12//!
13//! #[derive(Debug, PartialEq, Eq)]
14//! struct UserV1 { name: String }
15//!
16//! #[derive(Debug, PartialEq, Eq)]
17//! struct UserV2 { name: String, email: String }
18//!
19//! impl Upgrade<UserV2> for UserV1 {
20//! type Lossiness = Lossless;
21//! fn upgrade(self) -> Translation<Self, UserV2, Lossless> {
22//! let diff = Diff::new().add("email", "default@example.com");
23//! Translation::new(self, Box::new(|s| UserV2 {
24//! name: s.name,
25//! email: "default@example.com".into(),
26//! }), diff)
27//! }
28//! }
29//!
30//! impl Downgrade<UserV1> for UserV2 {
31//! type Lossiness = Lossy;
32//! fn downgrade(self) -> Translation<Self, UserV1, Lossy> {
33//! let diff = Diff::new().sub("email", &self.email);
34//! Translation::new(self, Box::new(|s| UserV1 { name: s.name }), diff)
35//! }
36//! }
37//!
38//! let t = UserV1 { name: "Alice".into() }.upgrade();
39//! assert!(!t.is_lossy());
40//! let v2 = t.translate();
41//! assert_eq!(v2.email, "default@example.com");
42//!
43//! let t = v2.downgrade();
44//! assert!(t.is_lossy());
45//! let v1 = t.translate_lossy();
46//! assert_eq!(v1.name, "Alice");
47//! ```
48
49use std::{
50 fmt::{Debug, Display},
51 marker::PhantomData,
52};
53
54/// Sealed marker trait for translation lossiness.
55///
56/// Implemented only by [`Lossy`] and [`Lossless`].
57pub trait Lossiness {}
58
59/// Marker: the translation drops information present in the source.
60#[derive(Debug, Clone)]
61pub struct Lossy;
62
63impl Lossiness for Lossy {}
64
65/// Marker: the translation preserves all information from the source.
66#[derive(Debug, Clone)]
67pub struct Lossless;
68
69impl Lossiness for Lossless {}
70
71/// A pending translation from `Source` to `Target`.
72///
73/// The conversion is deferred: the source value and a constructor closure are
74/// held together until the caller explicitly calls [`translate`] (lossless) or
75/// [`translate_lossy`] (lossy). This lets callers inspect the [`Diff`] and
76/// decide whether to proceed.
77///
78/// [`translate`]: Translation::<_, _, Lossless>::translate
79/// [`translate_lossy`]: Translation::<_, _, Lossy>::translate_lossy
80pub struct Translation<Source, Target, Lossiness> {
81 source: Source,
82 construct_target: Box<dyn FnOnce(Source) -> Target>,
83 diff: Diff,
84 _lossiness: PhantomData<Lossiness>,
85}
86
87impl<S, T, L> Translation<S, T, L>
88where
89 L: Lossiness,
90{
91 /// Build a translation from its constituent parts.
92 ///
93 /// Normally called from within [`Upgrade::upgrade`] or
94 /// [`Downgrade::downgrade`] implementations.
95 pub fn new(source: S, construct_target: Box<dyn FnOnce(S) -> T>, diff: Diff) -> Self {
96 Self {
97 source,
98 construct_target,
99 diff,
100 _lossiness: PhantomData,
101 }
102 }
103
104 /// Returns the structural diff between source and target schemas.
105 pub fn diff(&self) -> &Diff {
106 &self.diff
107 }
108}
109
110impl<S, T> Translation<S, T, Lossless> {
111 /// Always returns `false`; present for API symmetry with the [`Lossy`] impl.
112 pub const fn is_lossy(&self) -> bool {
113 false
114 }
115
116 /// Consume the translation and produce the target value.
117 pub fn translate(self) -> T {
118 (self.construct_target)(self.source)
119 }
120}
121
122impl<S, T> Translation<S, T, Lossy> {
123 /// Always returns `true`; signals that calling [`translate_lossy`] will
124 /// drop data.
125 ///
126 /// [`translate_lossy`]: Translation::<_, _, Lossy>::translate_lossy
127 pub const fn is_lossy(&self) -> bool {
128 true
129 }
130
131 /// Consume the translation and produce the target value, accepting data loss.
132 pub fn translate_lossy(self) -> T {
133 (self.construct_target)(self.source)
134 }
135}
136
137impl<S, T> Display for Translation<S, T, Lossy>
138where
139 T: Debug,
140 S: Debug,
141{
142 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143 write!(f, "The translation was lossy:\n{}", self.diff())
144 }
145}
146
147impl<S, T> Display for Translation<S, T, Lossless>
148where
149 T: Debug,
150 S: Debug,
151{
152 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153 write!(f, "The translation was lossless:\n{}", self.diff())
154 }
155}
156
157/// A human-readable record of fields added or removed during a translation.
158///
159/// Built with a fluent API:
160///
161/// ```rust
162/// use interface::Diff;
163///
164/// let diff = Diff::new()
165/// .add("email", "default@example.com")
166/// .sub("legacy_id", 42u32);
167///
168/// println!("{diff}");
169/// ```
170#[derive(Debug, Clone)]
171pub struct Diff(String);
172
173impl Diff {
174 /// Create an empty diff.
175 pub fn new() -> Self {
176 Self(String::new())
177 }
178
179 fn push<V: Debug>(mut self, sign: &str, name: &str, value: V) -> Self {
180 let formatted = format!("{:#?}", value);
181 let prefixed = formatted
182 .lines()
183 .enumerate()
184 .map(|(i, line)| {
185 if i == 0 {
186 format!("{line}")
187 } else {
188 format!("{sign}{line}")
189 }
190 })
191 .collect::<Vec<_>>()
192 .join("\n");
193
194 self.0.push_str(&format!("{sign}{name}: {prefixed}\n"));
195
196 self
197 }
198
199 /// Record a field that exists in the target but not the source.
200 pub fn add<V: Debug>(self, name: &str, value: V) -> Self {
201 const ADD: &str = "+";
202 self.push(ADD, name, value)
203 }
204
205 /// Record a field that exists in the source but not the target.
206 pub fn sub<V: Debug>(self, name: &str, value: V) -> Self {
207 const SUB: &str = "-";
208 self.push(SUB, name, value)
209 }
210}
211
212impl Default for Diff {
213 fn default() -> Self {
214 Self::new()
215 }
216}
217
218impl Display for Diff {
219 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
220 write!(f, "{}", self.0)
221 }
222}
223
224/// Implement this to describe how `Self` migrates forward to `Next`.
225///
226/// The associated [`Lossiness`] type must be either [`Lossy`] or [`Lossless`],
227/// encoding at the type level whether the upgrade is destructive.
228pub trait Upgrade<Next>
229where
230 Self: Sized,
231{
232 /// [`Lossy`] if the upgrade drops fields; [`Lossless`] otherwise.
233 type Lossiness;
234
235 /// Produce a [`Translation`] without executing it.
236 fn upgrade(self) -> Translation<Self, Next, Self::Lossiness>;
237}
238
239/// Implement this to describe how `Self` migrates backward to `Prev`.
240///
241/// Downgrades are almost always [`Lossy`] because older schemas typically
242/// have fewer fields.
243pub trait Downgrade<Prev>
244where
245 Self: Sized,
246{
247 /// [`Lossy`] if the downgrade drops fields; [`Lossless`] otherwise.
248 type Lossiness;
249
250 /// Produce a [`Translation`] without executing it.
251 fn downgrade(self) -> Translation<Self, Prev, Self::Lossiness>;
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257
258 mod v1 {
259 use super::*;
260
261 #[derive(Debug, PartialEq, Eq)]
262 pub struct User {
263 name: String,
264 }
265
266 impl User {
267 pub fn new(name: String) -> Self {
268 Self { name }
269 }
270 }
271
272 impl<'a> Upgrade<v2::User> for User {
273 type Lossiness = Lossless;
274
275 fn upgrade(self) -> Translation<Self, v2::User, Self::Lossiness> {
276 let diff = Diff::new().add("email", v2::Email::default());
277
278 let construct_target = Box::from(|s: Self| -> v2::User {
279 v2::User::new(v2::Name::from(s.name), v2::Email::default())
280 });
281
282 Translation::new(self, construct_target, diff)
283 }
284 }
285 }
286
287 mod v2 {
288 use super::*;
289
290 #[derive(Debug, PartialEq, Eq)]
291 pub struct Name(String);
292
293 impl From<String> for Name {
294 fn from(value: String) -> Self {
295 Self(value)
296 }
297 }
298
299 #[derive(Debug, PartialEq, Eq)]
300 pub struct Email(String);
301
302 impl From<String> for Email {
303 fn from(value: String) -> Self {
304 Self(value)
305 }
306 }
307
308 impl Default for Email {
309 fn default() -> Self {
310 Self("mail@example.com".to_string())
311 }
312 }
313
314 #[derive(Debug, PartialEq, Eq)]
315 pub struct User {
316 name: Name,
317 email: Email,
318 }
319
320 impl User {
321 pub fn new(name: Name, email: Email) -> Self {
322 Self { name, email }
323 }
324 }
325
326 impl<'a> Downgrade<v1::User> for User {
327 type Lossiness = Lossy;
328
329 fn downgrade(self) -> Translation<Self, v1::User, Self::Lossiness> {
330 let diff = Diff::new().sub("email", &self.email);
331
332 let construct_target = Box::from(|s: Self| -> v1::User { v1::User::new(s.name.0) });
333
334 Translation::new(self, construct_target, diff)
335 }
336 }
337 }
338
339 #[test]
340 fn upgrade_from_v1_to_v2() {
341 let user = v1::User::new("Foo".to_string());
342 let translation = user.upgrade();
343
344 assert!(!translation.is_lossy());
345 assert_eq!(
346 translation.translate(),
347 v2::User::new(v2::Name::from("Foo".to_string()), v2::Email::default())
348 );
349 }
350
351 #[test]
352 fn downgrade_from_v2_to_v1() {
353 let user = v2::User::new(
354 v2::Name::from("Foo".to_string()),
355 v2::Email::from("Bar".to_string()),
356 );
357 let translation = user.downgrade();
358
359 assert!(translation.is_lossy());
360 assert_eq!(
361 translation.translate_lossy(),
362 v1::User::new("Foo".to_string())
363 );
364 }
365}