1use std::sync::OnceLock;
31
32use crate::diagnostics::{Diagnostic, OpLocation};
33use rustc_hash::FxHashMap;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
43pub struct Semver {
44 pub major: u32,
46 pub minor: u32,
48 pub patch: u32,
50}
51
52impl Semver {
53 #[must_use]
55 pub const fn new(major: u32, minor: u32, patch: u32) -> Self {
56 Self {
57 major,
58 minor,
59 patch,
60 }
61 }
62}
63
64impl std::fmt::Display for Semver {
65 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
67 }
68}
69
70#[derive(Debug, Clone, PartialEq)]
76#[non_exhaustive]
77pub enum AttrValue {
78 U32(u32),
80 I32(i32),
82 F32(f32),
84 Bool(bool),
86 Bytes(Vec<u8>),
88 String(String),
90}
91
92#[derive(Debug, Default, Clone)]
100pub struct AttrMap {
101 attrs: FxHashMap<String, AttrValue>,
102}
103
104impl AttrMap {
105 #[must_use]
107 pub fn new() -> Self {
108 Self::default()
109 }
110
111 pub fn insert(&mut self, key: impl Into<String>, value: AttrValue) -> Option<AttrValue> {
114 self.attrs.insert(key.into(), value)
115 }
116
117 pub fn remove(&mut self, key: &str) -> Option<AttrValue> {
119 self.attrs.remove(key)
120 }
121
122 #[must_use]
124 pub fn get(&self, key: &str) -> Option<&AttrValue> {
125 self.attrs.get(key)
126 }
127
128 pub fn rename(&mut self, from: &str, to: impl Into<String>) -> bool {
131 match self.attrs.remove(from) {
132 Some(v) => {
133 self.attrs.insert(to.into(), v);
134 true
135 }
136 None => false,
137 }
138 }
139
140 #[must_use]
142 pub fn len(&self) -> usize {
143 self.attrs.len()
144 }
145
146 #[must_use]
148 pub fn is_empty(&self) -> bool {
149 self.attrs.is_empty()
150 }
151
152 pub fn iter(&self) -> impl Iterator<Item = (&str, &AttrValue)> {
154 self.attrs.iter().map(|(k, v)| (k.as_str(), v))
155 }
156}
157
158#[derive(Debug, Clone, PartialEq, Eq)]
165#[non_exhaustive]
166pub enum MigrationError {
167 MissingAttribute {
169 name: String,
171 },
172 WrongType {
174 name: String,
176 expected: &'static str,
178 },
179 OutOfRange {
181 name: String,
183 },
184 Custom {
186 reason: String,
188 },
189}
190
191impl std::fmt::Display for MigrationError {
192 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
193 match self {
194 MigrationError::MissingAttribute { name } => {
195 write!(f, "migration needs attribute `{name}` which is missing")
196 }
197 MigrationError::WrongType { name, expected } => {
198 write!(f, "migration expected `{name}` to be {expected}")
199 }
200 MigrationError::OutOfRange { name } => {
201 write!(f, "migration value for `{name}` is out of range")
202 }
203 MigrationError::Custom { reason } => f.write_str(reason),
204 }
205 }
206}
207
208impl std::error::Error for MigrationError {}
209
210pub struct Migration {
234 pub from: (&'static str, Semver),
236 pub to: (&'static str, Semver),
238 pub rewrite: fn(&mut AttrMap) -> Result<(), MigrationError>,
240}
241
242impl Migration {
243 #[must_use]
245 pub const fn new(
246 from: (&'static str, Semver),
247 to: (&'static str, Semver),
248 rewrite: fn(&mut AttrMap) -> Result<(), MigrationError>,
249 ) -> Self {
250 Self { from, to, rewrite }
251 }
252}
253
254inventory::collect!(Migration);
255
256pub struct Deprecation {
263 pub op_id: &'static str,
265 pub deprecated_since: Semver,
267 pub note: &'static str,
269}
270
271impl Deprecation {
272 #[must_use]
274 pub const fn new(op_id: &'static str, deprecated_since: Semver, note: &'static str) -> Self {
275 Self {
276 op_id,
277 deprecated_since,
278 note,
279 }
280 }
281}
282
283inventory::collect!(Deprecation);
284
285pub struct MigrationRegistry {
291 forward: FxHashMap<(&'static str, Semver), &'static Migration>,
295 deprecations: FxHashMap<&'static str, &'static Deprecation>,
296}
297
298impl MigrationRegistry {
299 #[must_use]
301 pub fn global() -> &'static MigrationRegistry {
302 static REGISTRY: OnceLock<MigrationRegistry> = OnceLock::new();
303 REGISTRY.get_or_init(|| {
304 let migration_count = inventory::iter::<Migration>().count();
305 let mut forward = FxHashMap::default();
306 let _ = vyre_foundation::allocation::try_reserve_hash_map_to_capacity(
307 &mut forward,
308 migration_count,
309 );
310 let migrations = inventory::iter::<Migration>();
311 for m in migrations {
312 forward.insert((m.from.0, m.from.1), m);
313 }
314 let deprecation_count = inventory::iter::<Deprecation>().count();
315 let mut deprecations = FxHashMap::default();
316 vyre_foundation::allocation::try_reserve_hash_map_to_capacity(
317 &mut deprecations,
318 deprecation_count,
319 )
320 .ok();
321 let deprecation_defs = inventory::iter::<Deprecation>();
322 for d in deprecation_defs {
323 deprecations.insert(d.op_id, d);
324 }
325 MigrationRegistry {
326 forward,
327 deprecations,
328 }
329 })
330 }
331
332 #[must_use]
334 pub fn lookup(&self, op_id: &str, from: Semver) -> Option<&'static Migration> {
335 self.forward.get(&(op_id, from)).copied()
336 }
337
338 pub fn apply_chain(
351 &self,
352 op_id: &'static str,
353 from: Semver,
354 attrs: &mut AttrMap,
355 ) -> Result<(&'static str, Semver), MigrationError> {
356 let mut current_op = op_id;
357 let mut current_ver = from;
358 loop {
361 let Some(m) = self.lookup(current_op, current_ver) else {
362 return Ok((current_op, current_ver));
363 };
364 (m.rewrite)(attrs)?;
365 current_op = m.to.0;
366 current_ver = m.to.1;
367 }
368 }
369
370 #[must_use]
372 pub fn deprecation(&self, op_id: &str) -> Option<&'static Deprecation> {
373 self.deprecations.get(op_id).copied()
374 }
375}
376
377#[must_use]
384pub fn deprecation_diagnostic(dep: &Deprecation) -> Diagnostic {
385 Diagnostic::warning(
386 "W-OP-DEPRECATED",
387 format!(
388 "op `{}` is deprecated since version {}",
389 dep.op_id, dep.deprecated_since
390 ),
391 )
392 .with_location(OpLocation::op(dep.op_id.to_owned()))
393 .with_fix(dep.note)
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399
400 fn rename_mode_to_overflow(attrs: &mut AttrMap) -> Result<(), MigrationError> {
401 if !attrs.rename("mode", "overflow_behavior") {
402 return Err(MigrationError::MissingAttribute {
403 name: "mode".into(),
404 });
405 }
406 Ok(())
407 }
408
409 inventory::submit! {
413 Migration::new(
414 ("test.op_rename", Semver::new(1, 0, 0)),
415 ("test.op_rename", Semver::new(2, 0, 0)),
416 rename_mode_to_overflow,
417 )
418 }
419
420 inventory::submit! {
421 Migration::new(
422 ("test.op_chain", Semver::new(1, 0, 0)),
423 ("test.op_chain", Semver::new(2, 0, 0)),
424 |attrs| { attrs.rename("a", "b"); Ok(()) },
425 )
426 }
427
428 inventory::submit! {
429 Migration::new(
430 ("test.op_chain", Semver::new(2, 0, 0)),
431 ("test.op_chain", Semver::new(3, 0, 0)),
432 |attrs| { attrs.rename("b", "c"); Ok(()) },
433 )
434 }
435
436 inventory::submit! {
437 Deprecation::new(
438 "test.op_dep",
439 Semver::new(1, 1, 0),
440 "migrate to test.op_dep2",
441 )
442
443 }
444
445 #[test]
446 fn registry_finds_registered_migration() {
447 let reg = MigrationRegistry::global();
448 let m = reg.lookup("test.op_rename", Semver::new(1, 0, 0));
449 assert!(m.is_some(), "registered migration must be reachable");
450 let m = m.unwrap();
451 assert_eq!(m.to.1, Semver::new(2, 0, 0));
452 }
453
454 #[test]
455 fn apply_chain_rewrites_attributes() {
456 let reg = MigrationRegistry::global();
457 let mut attrs = AttrMap::new();
458 attrs.insert("mode", AttrValue::String("wrap".into()));
459 let (op, ver) = reg
460 .apply_chain("test.op_rename", Semver::new(1, 0, 0), &mut attrs)
461 .expect("Fix: migration registry missing the expected test op; ensure the #[test] fixture's inventory::submit! block is linked in this binary.");
462 assert_eq!(op, "test.op_rename");
463 assert_eq!(ver, Semver::new(2, 0, 0));
464 assert!(attrs.get("mode").is_none());
465 assert_eq!(
466 attrs.get("overflow_behavior"),
467 Some(&AttrValue::String("wrap".into()))
468 );
469 }
470
471 #[test]
472 fn apply_chain_follows_multiple_steps() {
473 let reg = MigrationRegistry::global();
474 let mut attrs = AttrMap::new();
475 attrs.insert("a", AttrValue::U32(1));
476 let (_, ver) = reg
477 .apply_chain("test.op_chain", Semver::new(1, 0, 0), &mut attrs)
478 .expect("Fix: migration registry missing the expected test op; ensure the #[test] fixture's inventory::submit! block is linked in this binary.");
479 assert_eq!(ver, Semver::new(3, 0, 0));
480 assert!(attrs.get("a").is_none());
481 assert!(attrs.get("b").is_none());
482 assert_eq!(attrs.get("c"), Some(&AttrValue::U32(1)));
483 }
484
485 #[test]
486 fn missing_source_attribute_surfaces_error() {
487 let reg = MigrationRegistry::global();
488 let mut attrs = AttrMap::new();
489 let err = reg
490 .apply_chain("test.op_rename", Semver::new(1, 0, 0), &mut attrs)
491 .expect_err("missing input must error");
492 assert!(matches!(err, MigrationError::MissingAttribute { .. }));
493 }
494
495 #[test]
496 fn no_migration_returns_input_unchanged() {
497 let reg = MigrationRegistry::global();
498 let mut attrs = AttrMap::new();
499 let (op, ver) = reg
500 .apply_chain("test.unregistered", Semver::new(1, 0, 0), &mut attrs)
501 .expect("Fix: apply_chain on an unregistered op must return Ok(input); if this errors, the no-migration terminal-state contract has regressed.");
502 assert_eq!(op, "test.unregistered");
503 assert_eq!(ver, Semver::new(1, 0, 0));
504 }
505
506 #[test]
507 fn deprecation_lookup_returns_marker() {
508 let reg = MigrationRegistry::global();
509 let dep = reg
510 .deprecation("test.op_dep")
511 .expect("Fix: test.op_dep deprecation registration missing; verify the fixture's inventory::submit! block is linked.");
512 assert_eq!(dep.deprecated_since, Semver::new(1, 1, 0));
513 assert_eq!(dep.note, "migrate to test.op_dep2");
514 }
515
516 #[test]
517 fn deprecation_diagnostic_has_warning_severity() {
518 let reg = MigrationRegistry::global();
519 let dep = reg.deprecation("test.op_dep").unwrap();
520 let diag = deprecation_diagnostic(dep);
521 assert_eq!(diag.severity, crate::diagnostics::Severity::Warning);
522 assert_eq!(diag.code.as_str(), "W-OP-DEPRECATED");
523 assert!(diag.message.contains("test.op_dep"));
524 assert!(diag
525 .suggested_fix
526 .as_ref()
527 .map(|s| s.contains("test.op_dep2"))
528 .unwrap_or(false));
529 }
530
531 #[test]
532 fn attr_map_basic_operations() {
533 let mut attrs = AttrMap::new();
534 assert!(attrs.is_empty());
535 attrs.insert("x", AttrValue::Bool(true));
536 assert_eq!(attrs.len(), 1);
537 assert_eq!(attrs.get("x"), Some(&AttrValue::Bool(true)));
538 let prev = attrs.insert("x", AttrValue::Bool(false));
539 assert_eq!(prev, Some(AttrValue::Bool(true)));
540 let removed = attrs.remove("x");
541 assert_eq!(removed, Some(AttrValue::Bool(false)));
542 assert!(attrs.is_empty());
543 }
544
545 #[test]
546 fn semver_ordering_is_lexicographic() {
547 assert!(Semver::new(1, 0, 0) < Semver::new(1, 0, 1));
548 assert!(Semver::new(1, 0, 5) < Semver::new(1, 1, 0));
549 assert!(Semver::new(1, 5, 5) < Semver::new(2, 0, 0));
550 assert_eq!(Semver::new(1, 2, 3).to_string(), "1.2.3");
551 }
552}