Skip to main content

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}