model: add lockfile migration system (v1 -> v2)

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I335406fc4ee4a04071f6dcb6782e1a076a6a6964
This commit is contained in:
raf 2026-02-12 23:17:57 +03:00
commit f5d735efb8
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF

View file

@ -384,7 +384,8 @@ mod tests {
assert_eq!(loaded.mc_versions, mc_versions);
assert_eq!(loaded.loaders, loaders);
assert_eq!(loaded.projects.len(), 2);
assert_eq!(loaded.lockfile_version, 1);
// Lockfile should be migrated from v1 to v2 on load
assert_eq!(loaded.lockfile_version, 2);
}
#[test]
@ -423,6 +424,95 @@ mod tests {
assert!(lockfile.validate().is_ok());
}
#[test]
fn test_lockfile_migration_v1_to_v2() {
// Test that v1 lockfiles are migrated to v2
let temp_dir = TempDir::new().unwrap();
let mut loaders = HashMap::new();
loaders.insert("fabric".to_string(), "0.15.0".to_string());
// Create a v1 lockfile manually
let v1_content = r#"{
"target": "modrinth",
"mc_versions": ["1.20.1"],
"loaders": {"fabric": "0.15.0"},
"projects": [],
"lockfile_version": 1
}"#;
let lockfile_path = temp_dir.path().join("pakku-lock.json");
std::fs::write(&lockfile_path, v1_content).unwrap();
// Load should trigger migration
let loaded = LockFile::load(temp_dir.path()).unwrap();
assert_eq!(loaded.lockfile_version, 2);
// Verify the migrated file was saved
let reloaded = LockFile::load(temp_dir.path()).unwrap();
assert_eq!(reloaded.lockfile_version, 2);
}
#[test]
fn test_lockfile_migration_preserves_projects() {
// Test that migration preserves all project data
let temp_dir = TempDir::new().unwrap();
// Create a v1 lockfile with projects (using correct enum case)
let v1_content = r#"{
"target": "modrinth",
"mc_versions": ["1.20.1"],
"loaders": {"fabric": "0.15.0"},
"projects": [
{
"pakku_id": "test-id-1",
"type": "MOD",
"side": "BOTH",
"name": {"modrinth": "Test Mod"},
"slug": {"modrinth": "test-mod"},
"id": {"modrinth": "abc123"},
"files": [],
"pakku_links": [],
"aliases": [],
"update_strategy": "LATEST",
"redistributable": true,
"export": true
}
],
"lockfile_version": 1
}"#;
let lockfile_path = temp_dir.path().join("pakku-lock.json");
std::fs::write(&lockfile_path, v1_content).unwrap();
let loaded = LockFile::load(temp_dir.path()).unwrap();
assert_eq!(loaded.lockfile_version, 2);
assert_eq!(loaded.projects.len(), 1);
assert_eq!(loaded.projects[0].pakku_id, Some("test-id-1".to_string()));
}
#[test]
fn test_lockfile_rejects_future_version() {
// Test that lockfiles with version > current are rejected
let temp_dir = TempDir::new().unwrap();
let future_content = r#"{
"target": "modrinth",
"mc_versions": ["1.20.1"],
"loaders": {"fabric": "0.15.0"},
"projects": [],
"lockfile_version": 999
}"#;
let lockfile_path = temp_dir.path().join("pakku-lock.json");
std::fs::write(&lockfile_path, future_content).unwrap();
let result = LockFile::load(temp_dir.path());
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("newer than supported"));
}
#[test]
fn test_lockfile_pretty_json_format() {
// Test that saved JSON is pretty-printed
@ -472,7 +562,10 @@ mod tests {
}
}
const LOCKFILE_VERSION: u32 = 1;
/// Current lockfile version - bump this when making breaking changes
const LOCKFILE_VERSION: u32 = 2;
/// Minimum supported lockfile version for migration
const MIN_SUPPORTED_VERSION: u32 = 1;
const LOCKFILE_NAME: &str = "pakku-lock.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -494,13 +587,26 @@ impl LockFile {
path: P,
validate: bool,
) -> Result<Self> {
let path = path.as_ref().join(LOCKFILE_NAME);
let path_ref = path.as_ref();
let lockfile_path = path_ref.join(LOCKFILE_NAME);
let content =
std::fs::read_to_string(&path).map_err(PakkerError::IoError)?;
std::fs::read_to_string(&lockfile_path).map_err(PakkerError::IoError)?;
let mut lockfile: Self = serde_json::from_str(&content)
.map_err(|e| PakkerError::InvalidLockFile(e.to_string()))?;
// Check if migration is needed
if lockfile.lockfile_version < LOCKFILE_VERSION {
lockfile = lockfile.migrate()?;
// Save migrated lockfile
lockfile.save_without_validation(path_ref)?;
log::info!(
"Migrated lockfile from version {} to {}",
lockfile.lockfile_version,
LOCKFILE_VERSION
);
}
if validate {
lockfile.validate()?;
}
@ -509,6 +615,42 @@ impl LockFile {
Ok(lockfile)
}
/// Migrate lockfile from older version to current version
fn migrate(mut self) -> Result<Self> {
if self.lockfile_version < MIN_SUPPORTED_VERSION {
return Err(PakkerError::InvalidLockFile(format!(
"Lockfile version {} is too old to migrate. Minimum supported: {}",
self.lockfile_version, MIN_SUPPORTED_VERSION
)));
}
// Migration from v1 to v2
if self.lockfile_version == 1 {
log::info!("Migrating lockfile from v1 to v2...");
// v2 changes:
// - Projects now have explicit export field (defaults to true)
// - Side detection is more granular
for project in &mut self.projects {
// Ensure export field is set (v1 didn't always have it)
// Already has a default in Project, but be explicit
if !project.export {
project.export = true;
}
}
self.lockfile_version = 2;
}
// Future migrations would go here:
// if self.lockfile_version == 2 {
// // migrate v2 -> v3
// self.lockfile_version = 3;
// }
Ok(self)
}
pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
self.validate()?;
let path = path.as_ref().join(LOCKFILE_NAME);
@ -525,10 +667,17 @@ impl LockFile {
}
pub fn validate(&self) -> Result<()> {
if self.lockfile_version != LOCKFILE_VERSION {
if self.lockfile_version > LOCKFILE_VERSION {
return Err(PakkerError::InvalidLockFile(format!(
"Unsupported lockfile version: {}",
self.lockfile_version
"Lockfile version {} is newer than supported version {}. Please \
upgrade Pakker.",
self.lockfile_version, LOCKFILE_VERSION
)));
}
if self.lockfile_version < MIN_SUPPORTED_VERSION {
return Err(PakkerError::InvalidLockFile(format!(
"Lockfile version {} is too old. Minimum supported: {}",
self.lockfile_version, MIN_SUPPORTED_VERSION
)));
}