use std::borrow::Cow;
use std::sync::OnceLock;
use crate::diagnostics::{Diagnostic, OpLocation, Severity};
use rustc_hash::FxHashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Semver {
pub major: u32,
pub minor: u32,
pub patch: u32,
}
impl Semver {
#[must_use]
pub const fn new(major: u32, minor: u32, patch: u32) -> Self {
Self {
major,
minor,
patch,
}
}
}
impl std::fmt::Display for Semver {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum AttrValue {
U32(u32),
I32(i32),
F32(f32),
Bool(bool),
Bytes(Vec<u8>),
String(String),
}
#[derive(Debug, Default, Clone)]
pub struct AttrMap {
attrs: FxHashMap<String, AttrValue>,
}
impl AttrMap {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, key: impl Into<String>, value: AttrValue) -> Option<AttrValue> {
self.attrs.insert(key.into(), value)
}
pub fn remove(&mut self, key: &str) -> Option<AttrValue> {
self.attrs.remove(key)
}
#[must_use]
pub fn get(&self, key: &str) -> Option<&AttrValue> {
self.attrs.get(key)
}
pub fn rename(&mut self, from: &str, to: impl Into<String>) -> bool {
match self.attrs.remove(from) {
Some(v) => {
self.attrs.insert(to.into(), v);
true
}
None => false,
}
}
#[must_use]
pub fn len(&self) -> usize {
self.attrs.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.attrs.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &AttrValue)> {
self.attrs.iter().map(|(k, v)| (k.as_str(), v))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum MigrationError {
MissingAttribute {
name: String,
},
WrongType {
name: String,
expected: &'static str,
},
OutOfRange {
name: String,
},
Custom {
reason: String,
},
}
impl std::fmt::Display for MigrationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MigrationError::MissingAttribute { name } => {
write!(f, "migration needs attribute `{name}` which is missing")
}
MigrationError::WrongType { name, expected } => {
write!(f, "migration expected `{name}` to be {expected}")
}
MigrationError::OutOfRange { name } => {
write!(f, "migration value for `{name}` is out of range")
}
MigrationError::Custom { reason } => f.write_str(reason),
}
}
}
impl std::error::Error for MigrationError {}
pub struct Migration {
pub from: (&'static str, Semver),
pub to: (&'static str, Semver),
pub rewrite: fn(&mut AttrMap) -> Result<(), MigrationError>,
}
impl Migration {
#[must_use]
pub const fn new(
from: (&'static str, Semver),
to: (&'static str, Semver),
rewrite: fn(&mut AttrMap) -> Result<(), MigrationError>,
) -> Self {
Self { from, to, rewrite }
}
}
inventory::collect!(Migration);
pub struct Deprecation {
pub op_id: &'static str,
pub deprecated_since: Semver,
pub note: &'static str,
}
impl Deprecation {
#[must_use]
pub const fn new(op_id: &'static str, deprecated_since: Semver, note: &'static str) -> Self {
Self {
op_id,
deprecated_since,
note,
}
}
}
inventory::collect!(Deprecation);
pub struct MigrationRegistry {
forward: FxHashMap<(&'static str, Semver), &'static Migration>,
deprecations: FxHashMap<&'static str, &'static Deprecation>,
}
impl MigrationRegistry {
#[must_use]
pub fn global() -> &'static MigrationRegistry {
static REGISTRY: OnceLock<MigrationRegistry> = OnceLock::new();
REGISTRY.get_or_init(|| {
let migrations: Vec<&'static Migration> = inventory::iter::<Migration>().collect();
let mut forward =
FxHashMap::with_capacity_and_hasher(migrations.len(), Default::default());
for m in migrations {
forward.insert((m.from.0, m.from.1), m);
}
let deprecation_defs: Vec<&'static Deprecation> =
inventory::iter::<Deprecation>().collect();
let mut deprecations =
FxHashMap::with_capacity_and_hasher(deprecation_defs.len(), Default::default());
for d in deprecation_defs {
deprecations.insert(d.op_id, d);
}
MigrationRegistry {
forward,
deprecations,
}
})
}
#[must_use]
pub fn lookup(&self, op_id: &str, from: Semver) -> Option<&'static Migration> {
self.forward.get(&(op_id, from)).copied()
}
pub fn apply_chain(
&self,
op_id: &'static str,
from: Semver,
attrs: &mut AttrMap,
) -> Result<(&'static str, Semver), MigrationError> {
let mut current_op = op_id;
let mut current_ver = from;
loop {
let Some(m) = self.lookup(current_op, current_ver) else {
return Ok((current_op, current_ver));
};
(m.rewrite)(attrs)?;
current_op = m.to.0;
current_ver = m.to.1;
}
}
#[must_use]
pub fn deprecation(&self, op_id: &str) -> Option<&'static Deprecation> {
self.deprecations.get(op_id).copied()
}
}
#[must_use]
pub fn deprecation_diagnostic(dep: &Deprecation) -> Diagnostic {
let message = format!(
"op `{}` is deprecated since version {}",
dep.op_id, dep.deprecated_since
);
Diagnostic {
severity: Severity::Warning,
code: crate::diagnostics::DiagnosticCode::new("W-OP-DEPRECATED"),
message: Cow::Owned(message),
location: Some(OpLocation::op(dep.op_id.to_owned())),
suggested_fix: Some(Cow::Borrowed(dep.note)),
doc_url: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rename_mode_to_overflow(attrs: &mut AttrMap) -> Result<(), MigrationError> {
if !attrs.rename("mode", "overflow_behavior") {
return Err(MigrationError::MissingAttribute {
name: "mode".into(),
});
}
Ok(())
}
inventory::submit! {
Migration::new(
("test.op_rename", Semver::new(1, 0, 0)),
("test.op_rename", Semver::new(2, 0, 0)),
rename_mode_to_overflow,
)
}
inventory::submit! {
Migration::new(
("test.op_chain", Semver::new(1, 0, 0)),
("test.op_chain", Semver::new(2, 0, 0)),
|attrs| { attrs.rename("a", "b"); Ok(()) },
)
}
inventory::submit! {
Migration::new(
("test.op_chain", Semver::new(2, 0, 0)),
("test.op_chain", Semver::new(3, 0, 0)),
|attrs| { attrs.rename("b", "c"); Ok(()) },
)
}
inventory::submit! {
Deprecation::new(
"test.op_dep",
Semver::new(1, 1, 0),
"migrate to test.op_dep2",
)
}
#[test]
fn registry_finds_registered_migration() {
let reg = MigrationRegistry::global();
let m = reg.lookup("test.op_rename", Semver::new(1, 0, 0));
assert!(m.is_some(), "registered migration must be reachable");
let m = m.unwrap();
assert_eq!(m.to.1, Semver::new(2, 0, 0));
}
#[test]
fn apply_chain_rewrites_attributes() {
let reg = MigrationRegistry::global();
let mut attrs = AttrMap::new();
attrs.insert("mode", AttrValue::String("wrap".into()));
let (op, ver) = reg
.apply_chain("test.op_rename", Semver::new(1, 0, 0), &mut attrs)
.expect("Fix: migration registry missing the expected test op; ensure the #[test] fixture's inventory::submit! block is linked in this binary.");
assert_eq!(op, "test.op_rename");
assert_eq!(ver, Semver::new(2, 0, 0));
assert!(attrs.get("mode").is_none());
assert_eq!(
attrs.get("overflow_behavior"),
Some(&AttrValue::String("wrap".into()))
);
}
#[test]
fn apply_chain_follows_multiple_steps() {
let reg = MigrationRegistry::global();
let mut attrs = AttrMap::new();
attrs.insert("a", AttrValue::U32(1));
let (_, ver) = reg
.apply_chain("test.op_chain", Semver::new(1, 0, 0), &mut attrs)
.expect("Fix: migration registry missing the expected test op; ensure the #[test] fixture's inventory::submit! block is linked in this binary.");
assert_eq!(ver, Semver::new(3, 0, 0));
assert!(attrs.get("a").is_none());
assert!(attrs.get("b").is_none());
assert_eq!(attrs.get("c"), Some(&AttrValue::U32(1)));
}
#[test]
fn missing_source_attribute_surfaces_error() {
let reg = MigrationRegistry::global();
let mut attrs = AttrMap::new();
let err = reg
.apply_chain("test.op_rename", Semver::new(1, 0, 0), &mut attrs)
.expect_err("missing input must error");
assert!(matches!(err, MigrationError::MissingAttribute { .. }));
}
#[test]
fn no_migration_returns_input_unchanged() {
let reg = MigrationRegistry::global();
let mut attrs = AttrMap::new();
let (op, ver) = reg
.apply_chain("test.unregistered", Semver::new(1, 0, 0), &mut attrs)
.expect("Fix: apply_chain on an unregistered op must return Ok(input); if this errors, the no-migration terminal-state contract has regressed.");
assert_eq!(op, "test.unregistered");
assert_eq!(ver, Semver::new(1, 0, 0));
}
#[test]
fn deprecation_lookup_returns_marker() {
let reg = MigrationRegistry::global();
let dep = reg
.deprecation("test.op_dep")
.expect("Fix: test.op_dep deprecation registration missing; verify the fixture's inventory::submit! block is linked.");
assert_eq!(dep.deprecated_since, Semver::new(1, 1, 0));
assert_eq!(dep.note, "migrate to test.op_dep2");
}
#[test]
fn deprecation_diagnostic_has_warning_severity() {
let reg = MigrationRegistry::global();
let dep = reg.deprecation("test.op_dep").unwrap();
let diag = deprecation_diagnostic(dep);
assert_eq!(diag.severity, Severity::Warning);
assert_eq!(diag.code.as_str(), "W-OP-DEPRECATED");
assert!(diag.message.contains("test.op_dep"));
assert!(diag
.suggested_fix
.as_ref()
.map(|s| s.contains("test.op_dep2"))
.unwrap_or(false));
}
#[test]
fn attr_map_basic_operations() {
let mut attrs = AttrMap::new();
assert!(attrs.is_empty());
attrs.insert("x", AttrValue::Bool(true));
assert_eq!(attrs.len(), 1);
assert_eq!(attrs.get("x"), Some(&AttrValue::Bool(true)));
let prev = attrs.insert("x", AttrValue::Bool(false));
assert_eq!(prev, Some(AttrValue::Bool(true)));
let removed = attrs.remove("x");
assert_eq!(removed, Some(AttrValue::Bool(false)));
assert!(attrs.is_empty());
}
#[test]
fn semver_ordering_is_lexicographic() {
assert!(Semver::new(1, 0, 0) < Semver::new(1, 0, 1));
assert!(Semver::new(1, 0, 5) < Semver::new(1, 1, 0));
assert!(Semver::new(1, 5, 5) < Semver::new(2, 0, 0));
assert_eq!(Semver::new(1, 2, 3).to_string(), "1.2.3");
}
}