teksilo_settings/
migration.rs1use std::marker::PhantomData;
45use std::sync::Arc;
46
47use serde::de::DeserializeOwned;
48
49pub trait Versioned {
54 const CURRENT_VERSION: u32;
57
58 fn version(&self) -> u32;
60
61 fn set_version(&mut self, v: u32);
64}
65
66#[derive(Debug, thiserror::Error)]
68pub enum MigrationError {
69 #[error("settings file is version {on_disk}, but this build only reads up to {current}")]
72 NewerThanCurrent { on_disk: u32, current: u32 },
73 #[error("no migration step registered for settings version {0}")]
76 NoStepFor(u32),
77 #[error("migration step {from} -> {} failed: {message}", from + 1)]
79 Step { from: u32, message: String },
80 #[error("post-migration deserialization: {0}")]
82 Deserialize(#[source] toml::de::Error),
83}
84
85type StepFn = Arc<dyn Fn(toml::Value) -> Result<toml::Value, String> + Send + Sync>;
86
87#[derive(Clone)]
88struct Step {
89 from: u32,
90 func: StepFn,
91}
92
93pub struct Migrator<T: Versioned + DeserializeOwned> {
106 steps: Vec<Step>,
107 _marker: PhantomData<T>,
108}
109
110impl<T: Versioned + DeserializeOwned> Migrator<T> {
111 pub fn new() -> Self {
117 Self {
118 steps: Vec::new(),
119 _marker: PhantomData,
120 }
121 }
122
123 pub fn step<F>(mut self, from: u32, func: F) -> Self
127 where
128 F: Fn(toml::Value) -> Result<toml::Value, String> + Send + Sync + 'static,
129 {
130 self.steps.push(Step {
131 from,
132 func: Arc::new(func),
133 });
134 self
135 }
136
137 pub fn run(&self, mut raw: toml::Value) -> Result<T, MigrationError> {
146 let target = T::CURRENT_VERSION;
147 let mut current = peek_version(&raw).unwrap_or(1);
148
149 if current > target {
150 return Err(MigrationError::NewerThanCurrent {
151 on_disk: current,
152 current: target,
153 });
154 }
155
156 while current < target {
157 let step = self
158 .steps
159 .iter()
160 .find(|s| s.from == current)
161 .ok_or(MigrationError::NoStepFor(current))?;
162 raw = (step.func)(raw).map_err(|message| MigrationError::Step {
163 from: current,
164 message,
165 })?;
166 current += 1;
167 if let Some(table) = raw.as_table_mut() {
170 table.insert("version".into(), toml::Value::Integer(current as i64));
171 }
172 }
173
174 T::deserialize(raw).map_err(MigrationError::Deserialize)
175 }
176}
177
178impl<T: Versioned + DeserializeOwned> Default for Migrator<T> {
179 fn default() -> Self {
180 Self::new()
181 }
182}
183
184impl<T: Versioned + DeserializeOwned> Clone for Migrator<T> {
185 fn clone(&self) -> Self {
191 Self {
192 steps: self.steps.clone(),
193 _marker: PhantomData,
194 }
195 }
196}
197
198impl<T: Versioned + DeserializeOwned> std::fmt::Debug for Migrator<T> {
199 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200 f.debug_struct("Migrator")
201 .field("step_count", &self.steps.len())
202 .field("target_type", &std::any::type_name::<T>())
203 .finish()
204 }
205}
206
207fn peek_version(raw: &toml::Value) -> Option<u32> {
208 raw.get("version")
209 .and_then(|v| v.as_integer())
210 .and_then(|n| u32::try_from(n).ok())
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216 use serde::{Deserialize, Serialize};
217
218 #[derive(Serialize, Deserialize, Debug, PartialEq, Default)]
219 struct V2 {
220 version: u32,
221 name: String,
222 pinned: bool,
223 }
224
225 impl Versioned for V2 {
226 const CURRENT_VERSION: u32 = 2;
227 fn version(&self) -> u32 {
228 self.version
229 }
230 fn set_version(&mut self, v: u32) {
231 self.version = v;
232 }
233 }
234
235 #[test]
236 fn no_op_when_already_current() {
237 let raw: toml::Value = toml::from_str("version = 2\nname = \"x\"\npinned = true").unwrap();
238 let migrator: Migrator<V2> = Migrator::new();
239 let v = migrator.run(raw).unwrap();
240 assert_eq!(
241 v,
242 V2 {
243 version: 2,
244 name: "x".into(),
245 pinned: true
246 }
247 );
248 }
249
250 #[test]
251 fn applies_one_step() {
252 let raw: toml::Value = toml::from_str("version = 1\nname = \"x\"").unwrap();
253 let migrator: Migrator<V2> = Migrator::new().step(1, |mut v| {
254 if let Some(t) = v.as_table_mut() {
255 t.insert("pinned".into(), toml::Value::Boolean(false));
256 }
257 Ok(v)
258 });
259 let v = migrator.run(raw).unwrap();
260 assert_eq!(
261 v,
262 V2 {
263 version: 2,
264 name: "x".into(),
265 pinned: false
266 }
267 );
268 }
269
270 #[test]
271 fn missing_version_treated_as_v1() {
272 let raw: toml::Value = toml::from_str("name = \"y\"").unwrap();
274 let migrator: Migrator<V2> = Migrator::new().step(1, |mut v| {
275 if let Some(t) = v.as_table_mut() {
276 t.insert("pinned".into(), toml::Value::Boolean(true));
277 }
278 Ok(v)
279 });
280 let v = migrator.run(raw).unwrap();
281 assert!(v.pinned);
282 assert_eq!(v.version, 2);
283 }
284
285 #[test]
286 fn newer_than_current_errors() {
287 let raw: toml::Value = toml::from_str("version = 7\nname = \"x\"").unwrap();
288 let migrator: Migrator<V2> = Migrator::new();
289 let err = migrator.run(raw).unwrap_err();
290 assert!(matches!(
291 err,
292 MigrationError::NewerThanCurrent {
293 on_disk: 7,
294 current: 2
295 }
296 ));
297 }
298
299 #[test]
300 fn missing_step_errors() {
301 let raw: toml::Value = toml::from_str("version = 1\nname = \"x\"").unwrap();
302 let migrator: Migrator<V2> = Migrator::new();
304 let err = migrator.run(raw).unwrap_err();
305 assert!(matches!(err, MigrationError::NoStepFor(1)));
306 }
307
308 #[test]
309 fn step_failure_propagates() {
310 let raw: toml::Value = toml::from_str("version = 1\nname = \"x\"").unwrap();
311 let migrator: Migrator<V2> = Migrator::new().step(1, |_| Err("borked".into()));
312 match migrator.run(raw).unwrap_err() {
313 MigrationError::Step { from, message } => {
314 assert_eq!(from, 1);
315 assert_eq!(message, "borked");
316 }
317 other => panic!("unexpected error: {other:?}"),
318 }
319 }
320
321 #[test]
322 fn multi_step_chain_walks_in_order() {
323 #[derive(Serialize, Deserialize, Debug, PartialEq, Default)]
324 struct V3 {
325 version: u32,
326 a: i32,
327 b: i32,
328 c: i32,
329 }
330 impl Versioned for V3 {
331 const CURRENT_VERSION: u32 = 3;
332 fn version(&self) -> u32 {
333 self.version
334 }
335 fn set_version(&mut self, v: u32) {
336 self.version = v;
337 }
338 }
339
340 let raw: toml::Value = toml::from_str("version = 1\na = 1").unwrap();
341 let migrator: Migrator<V3> = Migrator::new()
342 .step(2, |mut v| {
343 v.as_table_mut().unwrap().insert("c".into(), 3.into());
344 Ok(v)
345 })
346 .step(1, |mut v| {
348 v.as_table_mut().unwrap().insert("b".into(), 2.into());
349 Ok(v)
350 });
351
352 let v = migrator.run(raw).unwrap();
353 assert_eq!(
354 v,
355 V3 {
356 version: 3,
357 a: 1,
358 b: 2,
359 c: 3
360 }
361 );
362 }
363}