use std::marker::PhantomData;
use std::sync::Arc;
use serde::de::DeserializeOwned;
pub trait Versioned {
const CURRENT_VERSION: u32;
fn version(&self) -> u32;
fn set_version(&mut self, v: u32);
}
#[derive(Debug, thiserror::Error)]
pub enum MigrationError {
#[error("settings file is version {on_disk}, but this build only reads up to {current}")]
NewerThanCurrent { on_disk: u32, current: u32 },
#[error("no migration step registered for settings version {0}")]
NoStepFor(u32),
#[error("migration step {from} -> {} failed: {message}", from + 1)]
Step { from: u32, message: String },
#[error("post-migration deserialization: {0}")]
Deserialize(#[source] toml::de::Error),
}
type StepFn = Arc<dyn Fn(toml::Value) -> Result<toml::Value, String> + Send + Sync>;
#[derive(Clone)]
struct Step {
from: u32,
func: StepFn,
}
pub struct Migrator<T: Versioned + DeserializeOwned> {
steps: Vec<Step>,
_marker: PhantomData<T>,
}
impl<T: Versioned + DeserializeOwned> Migrator<T> {
pub fn new() -> Self {
Self {
steps: Vec::new(),
_marker: PhantomData,
}
}
pub fn step<F>(mut self, from: u32, func: F) -> Self
where
F: Fn(toml::Value) -> Result<toml::Value, String> + Send + Sync + 'static,
{
self.steps.push(Step {
from,
func: Arc::new(func),
});
self
}
pub fn run(&self, mut raw: toml::Value) -> Result<T, MigrationError> {
let target = T::CURRENT_VERSION;
let mut current = peek_version(&raw).unwrap_or(1);
if current > target {
return Err(MigrationError::NewerThanCurrent {
on_disk: current,
current: target,
});
}
while current < target {
let step = self
.steps
.iter()
.find(|s| s.from == current)
.ok_or(MigrationError::NoStepFor(current))?;
raw = (step.func)(raw).map_err(|message| MigrationError::Step {
from: current,
message,
})?;
current += 1;
if let Some(table) = raw.as_table_mut() {
table.insert("version".into(), toml::Value::Integer(current as i64));
}
}
T::deserialize(raw).map_err(MigrationError::Deserialize)
}
}
impl<T: Versioned + DeserializeOwned> Default for Migrator<T> {
fn default() -> Self {
Self::new()
}
}
impl<T: Versioned + DeserializeOwned> Clone for Migrator<T> {
fn clone(&self) -> Self {
Self {
steps: self.steps.clone(),
_marker: PhantomData,
}
}
}
impl<T: Versioned + DeserializeOwned> std::fmt::Debug for Migrator<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Migrator")
.field("step_count", &self.steps.len())
.field("target_type", &std::any::type_name::<T>())
.finish()
}
}
fn peek_version(raw: &toml::Value) -> Option<u32> {
raw.get("version")
.and_then(|v| v.as_integer())
.and_then(|n| u32::try_from(n).ok())
}
#[cfg(test)]
mod tests {
use super::*;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, PartialEq, Default)]
struct V2 {
version: u32,
name: String,
pinned: bool,
}
impl Versioned for V2 {
const CURRENT_VERSION: u32 = 2;
fn version(&self) -> u32 {
self.version
}
fn set_version(&mut self, v: u32) {
self.version = v;
}
}
#[test]
fn no_op_when_already_current() {
let raw: toml::Value = toml::from_str("version = 2\nname = \"x\"\npinned = true").unwrap();
let migrator: Migrator<V2> = Migrator::new();
let v = migrator.run(raw).unwrap();
assert_eq!(
v,
V2 {
version: 2,
name: "x".into(),
pinned: true
}
);
}
#[test]
fn applies_one_step() {
let raw: toml::Value = toml::from_str("version = 1\nname = \"x\"").unwrap();
let migrator: Migrator<V2> = Migrator::new().step(1, |mut v| {
if let Some(t) = v.as_table_mut() {
t.insert("pinned".into(), toml::Value::Boolean(false));
}
Ok(v)
});
let v = migrator.run(raw).unwrap();
assert_eq!(
v,
V2 {
version: 2,
name: "x".into(),
pinned: false
}
);
}
#[test]
fn missing_version_treated_as_v1() {
let raw: toml::Value = toml::from_str("name = \"y\"").unwrap();
let migrator: Migrator<V2> = Migrator::new().step(1, |mut v| {
if let Some(t) = v.as_table_mut() {
t.insert("pinned".into(), toml::Value::Boolean(true));
}
Ok(v)
});
let v = migrator.run(raw).unwrap();
assert!(v.pinned);
assert_eq!(v.version, 2);
}
#[test]
fn newer_than_current_errors() {
let raw: toml::Value = toml::from_str("version = 7\nname = \"x\"").unwrap();
let migrator: Migrator<V2> = Migrator::new();
let err = migrator.run(raw).unwrap_err();
assert!(matches!(
err,
MigrationError::NewerThanCurrent {
on_disk: 7,
current: 2
}
));
}
#[test]
fn missing_step_errors() {
let raw: toml::Value = toml::from_str("version = 1\nname = \"x\"").unwrap();
let migrator: Migrator<V2> = Migrator::new();
let err = migrator.run(raw).unwrap_err();
assert!(matches!(err, MigrationError::NoStepFor(1)));
}
#[test]
fn step_failure_propagates() {
let raw: toml::Value = toml::from_str("version = 1\nname = \"x\"").unwrap();
let migrator: Migrator<V2> = Migrator::new().step(1, |_| Err("borked".into()));
match migrator.run(raw).unwrap_err() {
MigrationError::Step { from, message } => {
assert_eq!(from, 1);
assert_eq!(message, "borked");
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn multi_step_chain_walks_in_order() {
#[derive(Serialize, Deserialize, Debug, PartialEq, Default)]
struct V3 {
version: u32,
a: i32,
b: i32,
c: i32,
}
impl Versioned for V3 {
const CURRENT_VERSION: u32 = 3;
fn version(&self) -> u32 {
self.version
}
fn set_version(&mut self, v: u32) {
self.version = v;
}
}
let raw: toml::Value = toml::from_str("version = 1\na = 1").unwrap();
let migrator: Migrator<V3> = Migrator::new()
.step(2, |mut v| {
v.as_table_mut().unwrap().insert("c".into(), 3.into());
Ok(v)
})
.step(1, |mut v| {
v.as_table_mut().unwrap().insert("b".into(), 2.into());
Ok(v)
});
let v = migrator.run(raw).unwrap();
assert_eq!(
v,
V3 {
version: 3,
a: 1,
b: 2,
c: 3
}
);
}
}