use crate::StorageError;
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersionFile {
pub version: u32,
pub migrated_at: String,
}
impl VersionFile {
pub fn read(dir: &Path) -> Result<Option<Self>, StorageError> {
let path = dir.join("version.json");
if !path.exists() {
return Ok(None);
}
let data = std::fs::read_to_string(&path)?;
let v: Self = serde_json::from_str(&data)?;
Ok(Some(v))
}
pub fn write(&self, dir: &Path) -> Result<(), StorageError> {
std::fs::create_dir_all(dir)?;
let json = serde_json::to_string_pretty(self)?;
std::fs::write(dir.join("version.json"), json)?;
Ok(())
}
}
pub trait MigrationStep: Send + Sync {
#[allow(clippy::wrong_self_convention)]
fn from_version(&self) -> u32;
fn run(&self, data_dir: &Path) -> Result<(), StorageError>;
}
pub struct StorageMigration {
steps: Vec<Box<dyn MigrationStep>>,
target_version: u32,
}
impl StorageMigration {
#[must_use]
pub fn new(steps: Vec<Box<dyn MigrationStep>>, target_version: u32) -> Self {
Self {
steps,
target_version,
}
}
pub fn migrate(&self, data_dir: &Path) -> Result<(), StorageError> {
std::fs::create_dir_all(data_dir)?;
let current = VersionFile::read(data_dir)?.map_or(0, |v| v.version);
if current >= self.target_version {
return Ok(());
}
for step in &self.steps {
let from = step.from_version();
if from < current {
continue; }
if from >= self.target_version {
break;
}
step.run(data_dir).map_err(|e| StorageError::Migration {
from,
to: from + 1,
reason: e.to_string(),
})?;
let vf = VersionFile {
version: from + 1,
migrated_at: chrono::Utc::now().to_rfc3339(),
};
vf.write(data_dir)?;
}
Ok(())
}
#[must_use]
pub const fn target_version(&self) -> u32 {
self.target_version
}
}
pub struct NoOpMigrationStep {
from: u32,
}
impl NoOpMigrationStep {
#[must_use]
pub const fn new(from: u32) -> Self {
Self { from }
}
}
impl MigrationStep for NoOpMigrationStep {
fn from_version(&self) -> u32 {
self.from
}
fn run(&self, _data_dir: &Path) -> Result<(), StorageError> {
Ok(())
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn migration_from_zero_to_target() {
let dir = tempdir().expect("tempdir");
let runner = StorageMigration::new(vec![Box::new(NoOpMigrationStep::new(0))], 1);
runner.migrate(dir.path()).expect("migrate");
let vf = VersionFile::read(dir.path())
.expect("read")
.expect("version file");
assert_eq!(vf.version, 1);
}
#[test]
fn migration_skips_if_already_at_target() {
let dir = tempdir().expect("tempdir");
let vf = VersionFile {
version: 2,
migrated_at: "2026-01-01T00:00:00Z".to_owned(),
};
vf.write(dir.path()).expect("write version");
let runner = StorageMigration::new(
vec![
Box::new(NoOpMigrationStep::new(0)),
Box::new(NoOpMigrationStep::new(1)),
],
2,
);
runner.migrate(dir.path()).expect("migrate");
let vf2 = VersionFile::read(dir.path())
.expect("read")
.expect("version file");
assert_eq!(vf2.version, 2);
}
#[test]
fn version_file_round_trips() {
let dir = tempdir().expect("tempdir");
let vf = VersionFile {
version: 42,
migrated_at: "2026-03-16T12:00:00Z".to_owned(),
};
vf.write(dir.path()).expect("write");
let read_back = VersionFile::read(dir.path())
.expect("read")
.expect("present");
assert_eq!(read_back.version, 42);
}
}