//! Migration framework scaffold (Iteration 6, ADR-0015 §9 / //! requirement F3). //! //! The shape lands in v1 even though no migrator is //! registered: the cost is small, the wiring is non-trivial, //! and shipping the framework now lets the *first* real //! migrator (v1 → v2, when that lands) be a tightly scoped //! one-file change rather than "design migrations + write a //! migrator + integrate." //! //! Public surface: //! //! - [`MigratorRegistry`] — ordered list of `MigrateFn`s, one //! per source version. Tests inject their own registries; the //! production-default registry is empty. //! - [`migrate_to_latest`] — given a YAML body and a registry, //! detect the source version, run each migrator in //! sequence, and return the upgraded body. Writes the //! pre-migration body to `project.yaml.v.bak` inside the //! project (a recovery aid; the .gitignore template excludes //! `project.yaml.v*.bak` so backups don't leak into git). //! //! What this does NOT do: //! //! - It does not write `project.yaml` itself. The runtime is //! responsible for atomically writing the upgraded body back. //! Keeping the migration step separate from the write step //! makes the order of operations explicit at the call site //! and trivially testable: pass in a body, get back a body. //! - It does not parse anything beyond the leading `version:` //! line. Full schema parsing is `yaml::parse_schema`'s job //! and runs *after* migration so the parser only ever sees //! the latest format. use std::path::Path; use serde::Deserialize; /// A pure migrator: takes a YAML body at version `N` and /// returns the same project at version `N + 1`. /// /// Migrators must not perform I/O. The framework is /// responsible for the .bak copy and the write-back; the /// migrator's job is purely the format transformation. pub type MigrateFn = fn(&str) -> Result; /// Ordered list of migrators. `migrators[i]` runs from /// version `i + 1` to version `i + 2` (so index 0 is v1→v2, /// index 1 is v2→v3, etc.). /// /// `latest_version()` is `1 + migrators.len()`. In v1 the /// list is empty and `latest_version()` is `1`. #[derive(Debug, Clone)] pub struct MigratorRegistry { pub migrators: Vec, } impl MigratorRegistry { /// Production-default registry: empty. As new versions /// land, register the migrators here in source-version /// order. #[must_use] pub const fn production() -> Self { Self { migrators: Vec::new(), } } /// The newest schema version this build understands. #[must_use] pub const fn latest_version(&self) -> u32 { 1u32.saturating_add(self.migrators.len() as u32) } } impl Default for MigratorRegistry { fn default() -> Self { Self::production() } } #[derive(Debug)] pub enum MigrateError { VersionParse(String), NewerThanSupported { file: u32, latest: u32 }, NoMigratorForVersion(u32), StepFailed { from: u32, to: u32, source: Box, }, BadOutput(String), Io { path: std::path::PathBuf, source: std::io::Error, }, } impl std::fmt::Display for MigrateError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::VersionParse(detail) => f.write_str(&crate::t!( "persistence.migrate.version_parse", detail = detail, )), Self::NewerThanSupported { file, latest } => f.write_str(&crate::t!( "persistence.migrate.newer_than_supported", file = file, latest = latest, )), Self::NoMigratorForVersion(v) => f.write_str(&crate::t!( "persistence.migrate.no_migrator", version = v, )), Self::StepFailed { from, to, source } => f.write_str(&crate::t!( "persistence.migrate.step_failed", from = from, to = to, source = source, )), Self::BadOutput(detail) => f.write_str(&crate::t!( "persistence.migrate.bad_output", detail = detail, )), Self::Io { path, source } => f.write_str(&crate::t!( "persistence.migrate.io", path = path.display(), source = source, )), } } } impl std::error::Error for MigrateError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::StepFailed { source, .. } => Some(source.as_ref()), Self::Io { source, .. } => Some(source), _ => None, } } } /// Result of running [`migrate_to_latest`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct MigrationOutcome { /// The upgraded body. When no migration was needed this /// is identical to the input body. pub body: String, /// Source version found in the input. `None` if the /// input parsed but its version equals `latest_version` /// (no migration ran); `Some(N)` if a migration ran from /// `N` to `latest_version`. pub migrated_from: Option, } /// Detect the version of `body` and migrate it to the /// registry's `latest_version()`. /// /// If the body is already at the latest version, returns the /// body unchanged with `migrated_from = None`. Otherwise: /// /// 1. Writes `/project.yaml.v.bak` with the /// original body (so the recovery aid is in place before /// we start mutating). /// 2. Runs each registered migrator in sequence from /// `file_version` to `latest_version`. /// 3. Returns the upgraded body for the caller to write back. /// /// A future-version body (file_version > latest_version) /// errors out — older builds shouldn't try to interpret /// newer formats they don't understand. pub fn migrate_to_latest( body: &str, registry: &MigratorRegistry, project_path: &Path, ) -> Result { let file_version = read_version(body)?; let latest = registry.latest_version(); if file_version == latest { return Ok(MigrationOutcome { body: body.to_string(), migrated_from: None, }); } if file_version > latest { return Err(MigrateError::NewerThanSupported { file: file_version, latest, }); } // Write the .bak before any transformation runs so a // mid-migration crash leaves the original recoverable. let bak_path = project_path.join(format!("{}.v{}.bak", crate::project::PROJECT_YAML, file_version)); std::fs::write(&bak_path, body).map_err(|source| MigrateError::Io { path: bak_path.clone(), source, })?; let mut current_body = body.to_string(); for v in file_version..latest { let idx = (v - 1) as usize; let migrator = registry .migrators .get(idx) .ok_or(MigrateError::NoMigratorForVersion(v))?; let next_body = migrator(¤t_body).map_err(|e| MigrateError::StepFailed { from: v, to: v + 1, source: Box::new(e), })?; // Sanity: the new body must declare the next version. // If a migrator forgets to bump, we'd loop endlessly // through the chain — catch it here. let advertised = read_version(&next_body) .map_err(|e| MigrateError::BadOutput(e.to_string()))?; if advertised != v + 1 { return Err(MigrateError::BadOutput(format!( "v{v}→v{} migrator left version field at {advertised}", v + 1, ))); } current_body = next_body; } Ok(MigrationOutcome { body: current_body, migrated_from: Some(file_version), }) } /// Ensure the `project.yaml` at `project_path` is migrated /// to the registry's latest version, writing the upgraded /// body back to disk if a migration ran. /// /// Convenience wrapper that pairs [`migrate_to_latest`] with /// the read/write IO. Used by the runtime on every project /// open (before the rebuild path or DB-existence check /// touches anything). /// /// A missing `project.yaml` is `Ok(MigrationOutcome { body: /// "", migrated_from: None })` — a brand-new project that /// the skeleton hasn't filled in yet falls into this branch /// and is left alone. pub fn ensure_project_yaml_migrated( project_path: &Path, registry: &MigratorRegistry, ) -> Result { let yaml_path = project_path.join(crate::project::PROJECT_YAML); let body = match std::fs::read_to_string(&yaml_path) { Ok(b) => b, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { return Ok(MigrationOutcome { body: String::new(), migrated_from: None, }); } Err(source) => { return Err(MigrateError::Io { path: yaml_path, source, }); } }; let outcome = migrate_to_latest(&body, registry, project_path)?; if outcome.migrated_from.is_some() { std::fs::write(&yaml_path, &outcome.body).map_err(|source| MigrateError::Io { path: yaml_path, source, })?; } Ok(outcome) } /// Extract just the `version:` field from a YAML body, /// without parsing the rest of the document. fn read_version(body: &str) -> Result { #[derive(Deserialize)] struct VersionOnly { version: u32, } let v: VersionOnly = serde_norway::from_str(body).map_err(|e| { MigrateError::VersionParse(e.to_string()) })?; Ok(v.version) } #[cfg(test)] mod tests { use super::*; fn tempdir() -> tempfile::TempDir { tempfile::tempdir().expect("create tempdir") } fn v1_body() -> String { "version: 1\nproject:\n created_at: 2026-01-01T00:00:00Z\ntables: []\nrelationships: []\n" .to_string() } #[test] fn production_registry_latest_version_is_1() { let r = MigratorRegistry::production(); assert_eq!(r.latest_version(), 1); } #[test] fn no_migration_runs_when_body_already_latest() { let tmp = tempdir(); let outcome = migrate_to_latest( &v1_body(), &MigratorRegistry::production(), tmp.path(), ) .unwrap(); assert_eq!(outcome.body, v1_body()); assert_eq!(outcome.migrated_from, None); // No .bak written when nothing migrated. let bak = tmp.path().join("project.yaml.v1.bak"); assert!(!bak.exists(), "no .bak when no migration"); } #[test] fn newer_than_supported_errors() { let body = "version: 99\nproject:\n created_at: x\n"; let err = migrate_to_latest(body, &MigratorRegistry::production(), Path::new("/tmp")) .expect_err("must reject"); assert!( matches!(err, MigrateError::NewerThanSupported { file: 99, latest: 1 }), "got: {err:?}", ); } #[test] fn malformed_version_errors() { let body = "tables: []\n"; let err = migrate_to_latest(body, &MigratorRegistry::production(), Path::new("/tmp")) .expect_err("must reject"); assert!(matches!(err, MigrateError::VersionParse(_)), "got: {err:?}"); } // --- Exercise the framework with a fake v1→v2 migrator // so we know the chain runs even without a real one. --- fn fake_v1_to_v2(body: &str) -> Result { // Trivial transformation: bump the version number in // place. Exercises the registry plumbing without // committing to any real schema change. Ok(body.replace("version: 1", "version: 2")) } fn registry_with_v1_to_v2() -> MigratorRegistry { MigratorRegistry { migrators: vec![fake_v1_to_v2 as MigrateFn], } } #[test] fn registry_with_one_migrator_advertises_latest_version_2() { let r = registry_with_v1_to_v2(); assert_eq!(r.latest_version(), 2); } #[test] fn migrate_runs_chain_and_writes_bak() { let tmp = tempdir(); let outcome = migrate_to_latest( &v1_body(), ®istry_with_v1_to_v2(), tmp.path(), ) .unwrap(); assert_eq!(outcome.migrated_from, Some(1)); assert!(outcome.body.contains("version: 2")); let bak = tmp.path().join("project.yaml.v1.bak"); assert!(bak.exists(), "expected .v1.bak to be written"); let bak_body = std::fs::read_to_string(&bak).unwrap(); assert!(bak_body.contains("version: 1")); } #[test] fn migrator_that_forgets_to_bump_version_is_caught() { let bad: MigrateFn = |body| Ok(body.to_string()); // no change let registry = MigratorRegistry { migrators: vec![bad], }; let tmp = tempdir(); let err = migrate_to_latest(&v1_body(), ®istry, tmp.path()).expect_err("must fail"); assert!(matches!(err, MigrateError::BadOutput(_)), "got: {err:?}"); } #[test] fn ensure_yaml_migrated_no_op_on_v1_with_empty_registry() { let tmp = tempdir(); let yaml_path = tmp.path().join("project.yaml"); std::fs::write(&yaml_path, v1_body()).unwrap(); let outcome = ensure_project_yaml_migrated( tmp.path(), &MigratorRegistry::production(), ) .unwrap(); assert_eq!(outcome.migrated_from, None); // File unchanged. let on_disk = std::fs::read_to_string(&yaml_path).unwrap(); assert_eq!(on_disk, v1_body()); assert!(!tmp.path().join("project.yaml.v1.bak").exists()); } #[test] fn ensure_yaml_migrated_writes_upgraded_body_and_bak() { let tmp = tempdir(); let yaml_path = tmp.path().join("project.yaml"); std::fs::write(&yaml_path, v1_body()).unwrap(); let outcome = ensure_project_yaml_migrated( tmp.path(), ®istry_with_v1_to_v2(), ) .unwrap(); assert_eq!(outcome.migrated_from, Some(1)); let on_disk = std::fs::read_to_string(&yaml_path).unwrap(); assert!(on_disk.contains("version: 2"), "got: {on_disk}"); let bak = tmp.path().join("project.yaml.v1.bak"); assert!(bak.exists()); assert!(std::fs::read_to_string(&bak).unwrap().contains("version: 1")); } #[test] fn ensure_yaml_migrated_handles_missing_yaml() { let tmp = tempdir(); // No project.yaml exists. let outcome = ensure_project_yaml_migrated( tmp.path(), &MigratorRegistry::production(), ) .unwrap(); assert_eq!(outcome.migrated_from, None); assert!(outcome.body.is_empty()); } #[test] fn migrator_that_returns_internal_error_propagates() { let bad: MigrateFn = |_| Err(MigrateError::VersionParse("simulated".to_string())); let registry = MigratorRegistry { migrators: vec![bad], }; let tmp = tempdir(); let err = migrate_to_latest(&v1_body(), ®istry, tmp.path()).expect_err("must fail"); match err { MigrateError::StepFailed { from, to, .. } => { assert_eq!(from, 1); assert_eq!(to, 2); } other => panic!("expected StepFailed, got {other:?}"), } } }