synwire_storage/
migration.rs1use crate::StorageError;
11use serde::{Deserialize, Serialize};
12use std::path::Path;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct VersionFile {
17 pub version: u32,
19 pub migrated_at: String,
21}
22
23impl VersionFile {
24 pub fn read(dir: &Path) -> Result<Option<Self>, StorageError> {
31 let path = dir.join("version.json");
32 if !path.exists() {
33 return Ok(None);
34 }
35 let data = std::fs::read_to_string(&path)?;
36 let v: Self = serde_json::from_str(&data)?;
37 Ok(Some(v))
38 }
39
40 pub fn write(&self, dir: &Path) -> Result<(), StorageError> {
46 std::fs::create_dir_all(dir)?;
47 let json = serde_json::to_string_pretty(self)?;
48 std::fs::write(dir.join("version.json"), json)?;
49 Ok(())
50 }
51}
52
53pub trait MigrationStep: Send + Sync {
55 #[allow(clippy::wrong_self_convention)]
57 fn from_version(&self) -> u32;
58
59 fn run(&self, data_dir: &Path) -> Result<(), StorageError>;
66}
67
68pub struct StorageMigration {
73 steps: Vec<Box<dyn MigrationStep>>,
75 target_version: u32,
77}
78
79impl StorageMigration {
80 #[must_use]
82 pub fn new(steps: Vec<Box<dyn MigrationStep>>, target_version: u32) -> Self {
83 Self {
84 steps,
85 target_version,
86 }
87 }
88
89 pub fn migrate(&self, data_dir: &Path) -> Result<(), StorageError> {
99 std::fs::create_dir_all(data_dir)?;
100
101 let current = VersionFile::read(data_dir)?.map_or(0, |v| v.version);
102 if current >= self.target_version {
103 return Ok(());
104 }
105
106 for step in &self.steps {
107 let from = step.from_version();
108 if from < current {
109 continue; }
111 if from >= self.target_version {
112 break;
113 }
114
115 step.run(data_dir).map_err(|e| StorageError::Migration {
116 from,
117 to: from + 1,
118 reason: e.to_string(),
119 })?;
120
121 let vf = VersionFile {
122 version: from + 1,
123 migrated_at: chrono::Utc::now().to_rfc3339(),
124 };
125 vf.write(data_dir)?;
126 }
127
128 Ok(())
129 }
130
131 #[must_use]
133 pub const fn target_version(&self) -> u32 {
134 self.target_version
135 }
136}
137
138pub struct NoOpMigrationStep {
140 from: u32,
141}
142
143impl NoOpMigrationStep {
144 #[must_use]
146 pub const fn new(from: u32) -> Self {
147 Self { from }
148 }
149}
150
151impl MigrationStep for NoOpMigrationStep {
152 fn from_version(&self) -> u32 {
153 self.from
154 }
155
156 fn run(&self, _data_dir: &Path) -> Result<(), StorageError> {
157 Ok(())
158 }
159}
160
161#[cfg(test)]
162#[allow(clippy::expect_used, clippy::unwrap_used)]
163mod tests {
164 use super::*;
165 use tempfile::tempdir;
166
167 #[test]
168 fn migration_from_zero_to_target() {
169 let dir = tempdir().expect("tempdir");
170 let runner = StorageMigration::new(vec![Box::new(NoOpMigrationStep::new(0))], 1);
171 runner.migrate(dir.path()).expect("migrate");
172 let vf = VersionFile::read(dir.path())
173 .expect("read")
174 .expect("version file");
175 assert_eq!(vf.version, 1);
176 }
177
178 #[test]
179 fn migration_skips_if_already_at_target() {
180 let dir = tempdir().expect("tempdir");
181 let vf = VersionFile {
183 version: 2,
184 migrated_at: "2026-01-01T00:00:00Z".to_owned(),
185 };
186 vf.write(dir.path()).expect("write version");
187
188 let runner = StorageMigration::new(
189 vec![
190 Box::new(NoOpMigrationStep::new(0)),
191 Box::new(NoOpMigrationStep::new(1)),
192 ],
193 2,
194 );
195 runner.migrate(dir.path()).expect("migrate");
196 let vf2 = VersionFile::read(dir.path())
197 .expect("read")
198 .expect("version file");
199 assert_eq!(vf2.version, 2);
201 }
202
203 #[test]
204 fn version_file_round_trips() {
205 let dir = tempdir().expect("tempdir");
206 let vf = VersionFile {
207 version: 42,
208 migrated_at: "2026-03-16T12:00:00Z".to_owned(),
209 };
210 vf.write(dir.path()).expect("write");
211 let read_back = VersionFile::read(dir.path())
212 .expect("read")
213 .expect("present");
214 assert_eq!(read_back.version, 42);
215 }
216}