Skip to main content

mvm_core/
migration.rs

1use anyhow::{Result, bail};
2
3/// A versioned migration function: takes a raw JSON value at schema version N
4/// and returns a transformed value at schema version N+1.
5pub type MigrateFn = fn(serde_json::Value) -> Result<serde_json::Value>;
6
7/// Apply a chain of migrations to a raw JSON value.
8///
9/// Migrations are indexed by the version they *produce*: `migrations[0]`
10/// upgrades version 0 → 1, `migrations[1]` upgrades version 1 → 2, etc.
11///
12/// - If `from == to`, the value is returned unchanged.
13/// - If `from > to`, an error is returned (downgrade not supported).
14/// - If the required migration functions are not all present, an error is returned.
15pub fn migrate(
16    mut value: serde_json::Value,
17    from: u32,
18    to: u32,
19    migrations: &[MigrateFn],
20) -> Result<serde_json::Value> {
21    if from > to {
22        bail!(
23            "cannot downgrade schema from version {} to {} (downgrade not supported)",
24            from,
25            to
26        );
27    }
28    for version in from..to {
29        let idx = version as usize;
30        if idx >= migrations.len() {
31            bail!(
32                "no migration available from version {} to {} (only {} migration(s) defined)",
33                version,
34                version + 1,
35                migrations.len()
36            );
37        }
38        value = migrations[idx](value)?;
39    }
40    Ok(value)
41}
42
43/// Read the `schema_version` field from a raw JSON object value.
44/// Returns 0 if the field is absent (pre-versioned files).
45pub fn schema_version_of(value: &serde_json::Value) -> u32 {
46    value
47        .get("schema_version")
48        .and_then(|v| v.as_u64())
49        .map(|v| v as u32)
50        .unwrap_or(0)
51}
52
53#[cfg(test)]
54mod tests {
55    use super::*;
56    use serde_json::json;
57
58    fn bump_version(mut v: serde_json::Value) -> Result<serde_json::Value> {
59        v["schema_version"] = json!(
60            v.get("schema_version")
61                .and_then(|s| s.as_u64())
62                .unwrap_or(0)
63                + 1
64        );
65        Ok(v)
66    }
67
68    fn add_field(mut v: serde_json::Value) -> Result<serde_json::Value> {
69        v["new_field"] = json!("added");
70        Ok(v)
71    }
72
73    #[test]
74    fn migrate_noop() {
75        let value = json!({"schema_version": 1, "mode": "dev"});
76        let result = migrate(value.clone(), 1, 1, &[]).unwrap();
77        assert_eq!(result, value);
78    }
79
80    #[test]
81    fn migrate_one_step() {
82        let value = json!({"schema_version": 0, "mode": "dev"});
83        let result = migrate(value, 0, 1, &[bump_version as MigrateFn]).unwrap();
84        assert_eq!(result["schema_version"], 1);
85        assert_eq!(result["mode"], "dev");
86    }
87
88    #[test]
89    fn migrate_chain() {
90        let value = json!({"schema_version": 0, "mode": "flake"});
91        let result = migrate(
92            value,
93            0,
94            2,
95            &[bump_version as MigrateFn, add_field as MigrateFn],
96        )
97        .unwrap();
98        assert_eq!(result["schema_version"], 1); // bump_version sets it to 1
99        assert_eq!(result["new_field"], "added");
100        assert_eq!(result["mode"], "flake");
101    }
102
103    #[test]
104    fn migrate_downgrade_err() {
105        let value = json!({"schema_version": 2});
106        let err = migrate(value, 2, 1, &[]).unwrap_err();
107        assert!(err.to_string().contains("downgrade not supported"));
108    }
109
110    #[test]
111    fn migrate_missing_migration_err() {
112        let value = json!({"schema_version": 0});
113        // Asked to go 0 → 2 but only one migration provided
114        let err = migrate(value, 0, 2, &[bump_version as MigrateFn]).unwrap_err();
115        assert!(err.to_string().contains("no migration available"));
116    }
117
118    #[test]
119    fn schema_version_of_present() {
120        let v = json!({"schema_version": 3, "mode": "dev"});
121        assert_eq!(schema_version_of(&v), 3);
122    }
123
124    #[test]
125    fn schema_version_of_missing() {
126        let v = json!({"mode": "dev"});
127        assert_eq!(schema_version_of(&v), 0);
128    }
129
130    #[test]
131    fn migrate_run_info_from_unversioned() {
132        // A JSON blob without schema_version (old file format) should be
133        // treated as version 0. With an empty migrations list (0 → 0 is a
134        // noop), it deserialises cleanly.
135        let old_json = json!({"mode": "dev", "guest_user": "ubuntu"});
136        let from = schema_version_of(&old_json); // 0
137        let result = migrate(old_json.clone(), from, 0, &[]).unwrap();
138        // No migrations needed — value is unchanged
139        assert_eq!(result["mode"], "dev");
140        assert_eq!(result["guest_user"], "ubuntu");
141    }
142}