use crate::types::EsmFile;
use std::error::Error;
use std::fmt;
#[derive(Debug, Clone)]
pub struct MigrationError {
pub message: String,
pub from_version: String,
pub to_version: String,
}
impl fmt::Display for MigrationError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"Migration error: {} ({} → {})",
self.message, self.from_version, self.to_version
)
}
}
impl Error for MigrationError {}
type SemVer = (u32, u32, u32);
const ADDITIVE_FLOOR: SemVer = (1, 0, 0);
fn current_version() -> SemVer {
crate::diagnostic::parse_semver(crate::SCHEMA_VERSION)
.expect("SCHEMA_VERSION is a valid major.minor.patch string")
}
fn on_additive_line(version: SemVer) -> bool {
let current = current_version();
version.0 == current.0 && version >= ADDITIVE_FLOOR && version <= current
}
pub fn supported_migration_targets(from_version: &str) -> Vec<String> {
match crate::diagnostic::parse_semver(from_version) {
Some(version) if on_additive_line(version) => vec![crate::SCHEMA_VERSION.to_string()],
_ => Vec::new(),
}
}
#[deprecated(
since = "0.2.0",
note = "renamed to `supported_migration_targets` (API_SPEC.md phase-6 G-2)"
)]
pub fn get_supported_migration_targets(from_version: &str) -> Vec<String> {
supported_migration_targets(from_version)
}
pub fn can_migrate(from_version: &str, to_version: &str) -> bool {
supported_migration_targets(from_version)
.iter()
.any(|target| target == to_version)
}
pub fn migrate(file: &EsmFile, target_version: &str) -> Result<EsmFile, MigrationError> {
let source_version = &file.esm;
if source_version.is_empty() {
return Err(MigrationError {
message: "Source file has no 'esm' version field".to_string(),
from_version: String::new(),
to_version: target_version.to_string(),
});
}
if !can_migrate(source_version, target_version) {
return Err(MigrationError {
message: format!(
"Migration from {source_version} to {target_version} is not supported"
),
from_version: source_version.clone(),
to_version: target_version.to_string(),
});
}
let mut migrated = file.clone();
migrated.esm = target_version.to_string();
Ok(migrated)
}
#[cfg(test)]
mod tests {
use super::*;
const CURRENT: &str = crate::SCHEMA_VERSION;
fn file_at(version: &str) -> EsmFile {
serde_json::from_value(serde_json::json!({
"esm": version,
"metadata": {"name": "test"},
}))
.expect("minimal ESM skeleton deserializes")
}
#[test]
fn no_target_for_any_0x_source_the_clean_break_being_uncrossable() {
for source in ["0.0.1", "0.0.5", "0.1.0", "0.3.0", "0.8.0", "0.9.0"] {
assert_eq!(
supported_migration_targets(source),
Vec::<String>::new(),
"0.x source {source} must have no automated target"
);
}
}
#[test]
fn additive_line_sources_bump_to_the_current_schema() {
for source in ["1.0.0", CURRENT] {
assert_eq!(
supported_migration_targets(source),
vec![CURRENT.to_string()],
"additive-line source {source}"
);
}
}
#[test]
fn sources_off_the_additive_line_have_no_targets() {
for source in ["1.99.0", "2.0.0", "12.34.56"] {
assert_eq!(supported_migration_targets(source), Vec::<String>::new());
}
}
#[test]
fn malformed_version_strings_have_no_targets() {
for source in [
"not-a-version",
"1.0",
"",
"1.0.0-alpha.1",
"v1.0.0",
"1.0.0 ",
"1.0.0.0",
] {
assert_eq!(
supported_migration_targets(source),
Vec::<String>::new(),
"malformed source {source:?}"
);
}
}
#[test]
fn version_comparison_is_numeric_not_lexicographic() {
assert_eq!(supported_migration_targets("1.10.0"), Vec::<String>::new());
let (major, minor, patch) = current_version();
let big_patch = format!("{major}.{minor}.{}", patch + 100);
assert_eq!(
supported_migration_targets(&big_patch),
Vec::<String>::new(),
"a large patch of the current minor ({big_patch}) is past the ceiling"
);
}
#[test]
fn can_migrate_rejects_every_0x_source_whatever_the_target() {
assert!(!can_migrate("0.0.5", "0.1.0"));
assert!(!can_migrate("0.9.0", "1.0.0"));
assert!(!can_migrate("0.9.0", CURRENT));
}
#[test]
fn can_migrate_accepts_an_additive_line_source_to_current() {
assert!(can_migrate("1.0.0", CURRENT));
assert!(can_migrate(CURRENT, CURRENT));
}
#[test]
fn can_migrate_rejects_an_intermediate_non_current_target() {
for target in ["1.0.1", "1.1.0", "1.2.0", "2.0.0"] {
if target == CURRENT {
continue;
}
assert!(
!can_migrate("1.0.0", target),
"{target} is not the current schema and must not be offered"
);
}
assert!(!can_migrate("not-a-version", CURRENT));
assert!(!can_migrate("1.0.0", "not-a-version"));
}
#[test]
fn can_migrate_migrate_and_targets_agree_across_the_grid() {
let sources = [
"0.0.5", "0.1.0", "0.9.0", "1.0.0", CURRENT, "1.0.1", "1.99.0", "2.0.0", "nonsense",
];
let targets = [CURRENT, "0.1.0", "1.0.0", "1.0.1", "2.0.0", "nonsense"];
for source in sources {
let supported = supported_migration_targets(source);
for target in targets {
let listed = supported.iter().any(|t| t == target);
assert_eq!(
can_migrate(source, target),
listed,
"can_migrate({source}, {target}) disagrees with the target list"
);
assert_eq!(
migrate(&file_at(source), target).is_ok(),
listed,
"migrate({source} → {target}) disagrees with can_migrate"
);
}
}
}
#[test]
fn migrate_refuses_a_0x_source_rather_than_bumping_its_marker() {
let source = file_at("0.9.0");
let err = migrate(&source, CURRENT).expect_err("0.x must not migrate");
assert_eq!(err.from_version, "0.9.0");
assert_eq!(err.to_version, CURRENT);
let rendered = err.to_string();
assert!(rendered.contains("0.9.0"), "{rendered}");
assert!(rendered.contains(CURRENT), "{rendered}");
assert!(rendered.contains("not supported"), "{rendered}");
assert_eq!(source.esm, "0.9.0");
}
#[test]
fn migrate_bumps_an_additive_line_file_and_leaves_the_input_alone() {
let source = file_at("1.0.0");
let migrated = migrate(&source, CURRENT).expect("additive-line bump");
assert_eq!(migrated.esm, CURRENT);
assert_eq!(source.esm, "1.0.0");
}
#[test]
fn migrate_is_a_no_op_for_a_current_version_file() {
let source = file_at(CURRENT);
let migrated = migrate(&source, CURRENT).expect("identity no-op");
assert_eq!(migrated.esm, CURRENT);
}
#[test]
fn migrate_errors_for_unsupported_version_pairs() {
assert!(migrate(&file_at("1.0.0"), "2.0.0").is_err());
assert!(migrate(&file_at("1.0.0"), "1.0.1").is_err());
assert!(migrate(&file_at("0.1.0"), CURRENT).is_err());
assert!(migrate(&file_at("1.0.0"), "not-a-version").is_err());
}
#[test]
fn migrate_errors_when_the_source_declares_no_version() {
let err = migrate(&file_at(""), CURRENT).expect_err("no source version");
assert!(err.message.contains("no 'esm' version field"), "{err}");
}
}