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