ipfrs_tensorlogic/rule_migrator.rs
1//! Rule version migration for TensorLogic rule sets.
2//!
3//! Provides the infrastructure for migrating rule set schemas between declared
4//! versions (`V1`, `V2`, `V3`), applying ordered transformation steps, and
5//! validating compatibility in both upgrade and downgrade directions.
6//!
7//! # Overview
8//!
9//! ```
10//! use ipfrs_tensorlogic::rule_migrator::{
11//! MigrationStep, MigrationTransform, RuleSchemaVersion, RuleVersionMigrator,
12//! };
13//!
14//! let mut migrator = RuleVersionMigrator::new();
15//!
16//! migrator.register_step(MigrationStep {
17//! from_version: RuleSchemaVersion::V1,
18//! to_version: RuleSchemaVersion::V2,
19//! transforms: vec![
20//! MigrationTransform::RenameField {
21//! from: "head".to_string(),
22//! to: "conclusion".to_string(),
23//! },
24//! ],
25//! description: "Rename head → conclusion".to_string(),
26//! });
27//!
28//! let result = migrator.migrate(RuleSchemaVersion::V1, RuleSchemaVersion::V2);
29//! assert!(result.is_success());
30//! ```
31
32// ---------------------------------------------------------------------------
33// RuleSchemaVersion
34// ---------------------------------------------------------------------------
35
36/// Declared schema versions for TensorLogic rule sets.
37#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
38pub enum RuleSchemaVersion {
39 /// Initial rule-set schema.
40 V1 = 1,
41 /// Second-generation schema (renamed fields, added metadata).
42 V2 = 2,
43 /// Third-generation schema (type system overhaul).
44 V3 = 3,
45}
46
47// ---------------------------------------------------------------------------
48// MigrationTransform
49// ---------------------------------------------------------------------------
50
51/// An atomic transformation applied to a rule set during migration.
52#[derive(Clone, Debug, PartialEq)]
53pub enum MigrationTransform {
54 /// Rename a field across all rules in the set.
55 RenameField {
56 /// Original field name.
57 from: String,
58 /// Target field name.
59 to: String,
60 },
61 /// Insert a new field with a default value into all rules.
62 AddField {
63 /// Name of the new field.
64 name: String,
65 /// Serialised default value for the field.
66 default_value: String,
67 },
68 /// Remove a field from all rules (data may be lost — lossy).
69 RemoveField {
70 /// Name of the field to remove.
71 name: String,
72 },
73 /// Convert the type of an existing field (may be lossy).
74 ConvertType {
75 /// Field whose type is changed.
76 field: String,
77 /// Source type descriptor (informational).
78 from_type: String,
79 /// Destination type descriptor (informational).
80 to_type: String,
81 },
82}
83
84// ---------------------------------------------------------------------------
85// MigrationStep
86// ---------------------------------------------------------------------------
87
88/// A single, directed migration step between two adjacent (or distant) schema
89/// versions, carrying the ordered list of transformations to apply.
90#[derive(Clone, Debug, PartialEq)]
91pub struct MigrationStep {
92 /// The version the rule set must currently be at.
93 pub from_version: RuleSchemaVersion,
94 /// The version the rule set will be at after this step completes.
95 pub to_version: RuleSchemaVersion,
96 /// Ordered list of transformations applied during this step.
97 pub transforms: Vec<MigrationTransform>,
98 /// Human-readable description of the migration step.
99 pub description: String,
100}
101
102// ---------------------------------------------------------------------------
103// MigrationPlan
104// ---------------------------------------------------------------------------
105
106/// An ordered sequence of migration steps that connects a source version to a
107/// target version.
108#[derive(Clone, Debug, PartialEq)]
109pub struct MigrationPlan {
110 /// Ordered path from source to target version.
111 pub steps: Vec<MigrationStep>,
112}
113
114impl MigrationPlan {
115 /// Total number of transforms across all steps in this plan.
116 pub fn total_transforms(&self) -> usize {
117 self.steps.iter().map(|s| s.transforms.len()).sum()
118 }
119
120 /// Returns `true` when the plan has no steps (source == target).
121 pub fn is_empty(&self) -> bool {
122 self.steps.is_empty()
123 }
124}
125
126// ---------------------------------------------------------------------------
127// MigrationResult
128// ---------------------------------------------------------------------------
129
130/// The outcome of executing a [`MigrationPlan`].
131#[derive(Clone, Debug, PartialEq)]
132pub struct MigrationResult {
133 /// Number of migration steps that were applied.
134 pub applied_steps: usize,
135 /// Total number of transforms executed across all applied steps.
136 pub transforms_applied: usize,
137 /// Diagnostic warnings produced during migration (e.g. lossy type
138 /// conversions, downgrade data loss notices).
139 pub warnings: Vec<String>,
140}
141
142impl MigrationResult {
143 /// Returns `true` when the migration is considered successful.
144 ///
145 /// Success means either:
146 /// - at least one step was applied, or
147 /// - the source was already at the target version (zero steps needed).
148 pub fn is_success(&self) -> bool {
149 self.applied_steps > 0 || self.warnings.iter().all(|w| !w.starts_with("FATAL"))
150 }
151}
152
153// ---------------------------------------------------------------------------
154// RuleVersionMigrator
155// ---------------------------------------------------------------------------
156
157/// Registry and executor for rule-set schema migrations.
158///
159/// Steps are registered individually and queried on demand to build a
160/// [`MigrationPlan`]. Migration is simulated (no actual rule-set bytes are
161/// transformed; the struct tracks counts and emits warnings for lossy
162/// operations).
163pub struct RuleVersionMigrator {
164 /// All registered migration steps.
165 steps: Vec<MigrationStep>,
166}
167
168impl RuleVersionMigrator {
169 /// Create an empty migrator with no registered steps.
170 pub fn new() -> Self {
171 Self { steps: Vec::new() }
172 }
173
174 /// Register a migration step.
175 ///
176 /// Steps may be registered in any order; `plan` will sort and chain them
177 /// as required.
178 pub fn register_step(&mut self, step: MigrationStep) {
179 self.steps.push(step);
180 }
181
182 /// Build an ordered migration plan from `from` to `to`.
183 ///
184 /// * If `from == to`: returns `Some(MigrationPlan { steps: vec![] })`.
185 /// * If `from < to` (upgrade): attempts to find a sequential chain of
186 /// registered steps that covers every version hop between `from` and
187 /// `to`.
188 /// * If `from > to` (downgrade): attempts the same but in reverse; a
189 /// downgrade warning is embedded in the first step's description (the
190 /// warning is surfaced as a `MigrationResult::warnings` entry when
191 /// `migrate` is called).
192 /// * Returns `None` when no complete path exists.
193 pub fn plan(&self, from: RuleSchemaVersion, to: RuleSchemaVersion) -> Option<MigrationPlan> {
194 if from == to {
195 return Some(MigrationPlan { steps: vec![] });
196 }
197
198 if from < to {
199 // Upgrade path: collect steps in ascending order.
200 let path = self.find_path(from, to, false)?;
201 Some(MigrationPlan { steps: path })
202 } else {
203 // Downgrade path: collect steps in descending order.
204 let path = self.find_path(from, to, true)?;
205 Some(MigrationPlan { steps: path })
206 }
207 }
208
209 /// Find an ordered chain of steps from `from` to `to`.
210 ///
211 /// When `reverse` is `true` the search walks in the downgrade direction
212 /// (higher → lower version numbers).
213 fn find_path(
214 &self,
215 from: RuleSchemaVersion,
216 to: RuleSchemaVersion,
217 reverse: bool,
218 ) -> Option<Vec<MigrationStep>> {
219 // Enumerate every version in the ordered range between `from` and `to`.
220 // All versions in the enum, sorted ascending.
221 const ALL_VERSIONS: [RuleSchemaVersion; 3] = [
222 RuleSchemaVersion::V1,
223 RuleSchemaVersion::V2,
224 RuleSchemaVersion::V3,
225 ];
226
227 // Build the sequence of (current, next) version hops we need to cover.
228 let hops: Vec<(RuleSchemaVersion, RuleSchemaVersion)> = if !reverse {
229 // Upgrade: walk ascending from `from` to `to`.
230 ALL_VERSIONS
231 .windows(2)
232 .filter_map(|w| {
233 let (a, b) = (w[0], w[1]);
234 if a >= from && b <= to {
235 Some((a, b))
236 } else {
237 None
238 }
239 })
240 .collect()
241 } else {
242 // Downgrade: walk descending from `from` to `to`.
243 let mut desc: Vec<(RuleSchemaVersion, RuleSchemaVersion)> = ALL_VERSIONS
244 .windows(2)
245 .filter_map(|w| {
246 let (a, b) = (w[0], w[1]);
247 // In downgrade mode we need steps that go b→a (high→low).
248 // We include the pair when a >= to and b <= from.
249 if a >= to && b <= from {
250 Some((b, a)) // reversed direction
251 } else {
252 None
253 }
254 })
255 .collect();
256 desc.reverse(); // highest-to-lowest order
257 desc
258 };
259
260 if hops.is_empty() {
261 return None;
262 }
263
264 // For each hop, locate the first registered step that covers it.
265 let mut plan_steps: Vec<MigrationStep> = Vec::with_capacity(hops.len());
266 for (hop_from, hop_to) in hops {
267 let found = self
268 .steps
269 .iter()
270 .find(|s| s.from_version == hop_from && s.to_version == hop_to)
271 .cloned();
272
273 match found {
274 Some(step) => plan_steps.push(step),
275 None => return None, // path is broken
276 }
277 }
278
279 Some(plan_steps)
280 }
281
282 /// Execute a migration from `from` to `to` and return the result.
283 ///
284 /// The migration is *simulated*: no actual rule-set data is mutated. The
285 /// result carries counts of applied steps and transforms, plus any
286 /// warnings generated (e.g. for `ConvertType` or downgrade operations).
287 pub fn migrate(&self, from: RuleSchemaVersion, to: RuleSchemaVersion) -> MigrationResult {
288 let plan = match self.plan(from, to) {
289 Some(p) => p,
290 None => {
291 return MigrationResult {
292 applied_steps: 0,
293 transforms_applied: 0,
294 warnings: vec![format!(
295 "No migration path found from {:?} to {:?}",
296 from, to
297 )],
298 }
299 }
300 };
301
302 // Same-version: trivially successful with no work done.
303 if plan.is_empty() {
304 return MigrationResult {
305 applied_steps: 0,
306 transforms_applied: 0,
307 warnings: vec![],
308 };
309 }
310
311 let mut warnings: Vec<String> = Vec::new();
312
313 // Emit a global downgrade notice when migrating to an older version.
314 if from > to {
315 warnings.push(format!(
316 "Downgrade from {:?} to {:?}: migration may be lossy — fields removed in newer \
317 versions cannot be recovered",
318 from, to
319 ));
320 }
321
322 let mut transforms_applied: usize = 0;
323
324 for step in &plan.steps {
325 for transform in &step.transforms {
326 match transform {
327 MigrationTransform::ConvertType {
328 field,
329 from_type,
330 to_type,
331 } => {
332 warnings.push(format!(
333 "ConvertType on field '{}': '{}' → '{}' may be lossy",
334 field, from_type, to_type
335 ));
336 }
337 MigrationTransform::RemoveField { name } => {
338 warnings.push(format!(
339 "RemoveField '{}': data will be permanently discarded",
340 name
341 ));
342 }
343 MigrationTransform::RenameField { .. }
344 | MigrationTransform::AddField { .. } => {
345 // Non-lossy transforms; no warning required.
346 }
347 }
348 transforms_applied += 1;
349 }
350 }
351
352 MigrationResult {
353 applied_steps: plan.steps.len(),
354 transforms_applied,
355 warnings,
356 }
357 }
358
359 /// Return all registered (from, to) version pairs.
360 pub fn registered_paths(&self) -> Vec<(RuleSchemaVersion, RuleSchemaVersion)> {
361 self.steps
362 .iter()
363 .map(|s| (s.from_version, s.to_version))
364 .collect()
365 }
366}
367
368impl Default for RuleVersionMigrator {
369 fn default() -> Self {
370 Self::new()
371 }
372}
373
374// ---------------------------------------------------------------------------
375// Tests
376// ---------------------------------------------------------------------------
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381
382 // -----------------------------------------------------------------------
383 // Helpers
384 // -----------------------------------------------------------------------
385
386 fn make_v1_v2_step() -> MigrationStep {
387 MigrationStep {
388 from_version: RuleSchemaVersion::V1,
389 to_version: RuleSchemaVersion::V2,
390 transforms: vec![
391 MigrationTransform::RenameField {
392 from: "head".to_string(),
393 to: "conclusion".to_string(),
394 },
395 MigrationTransform::AddField {
396 name: "metadata".to_string(),
397 default_value: "{}".to_string(),
398 },
399 ],
400 description: "V1 → V2: rename head, add metadata".to_string(),
401 }
402 }
403
404 fn make_v2_v3_step() -> MigrationStep {
405 MigrationStep {
406 from_version: RuleSchemaVersion::V2,
407 to_version: RuleSchemaVersion::V3,
408 transforms: vec![
409 MigrationTransform::ConvertType {
410 field: "priority".to_string(),
411 from_type: "i32".to_string(),
412 to_type: "f64".to_string(),
413 },
414 MigrationTransform::RemoveField {
415 name: "legacy_tag".to_string(),
416 },
417 ],
418 description: "V2 → V3: convert priority type, remove legacy_tag".to_string(),
419 }
420 }
421
422 fn make_v3_v2_step() -> MigrationStep {
423 MigrationStep {
424 from_version: RuleSchemaVersion::V3,
425 to_version: RuleSchemaVersion::V2,
426 transforms: vec![MigrationTransform::ConvertType {
427 field: "priority".to_string(),
428 from_type: "f64".to_string(),
429 to_type: "i32".to_string(),
430 }],
431 description: "V3 → V2 downgrade: convert priority back".to_string(),
432 }
433 }
434
435 fn make_v2_v1_step() -> MigrationStep {
436 MigrationStep {
437 from_version: RuleSchemaVersion::V2,
438 to_version: RuleSchemaVersion::V1,
439 transforms: vec![MigrationTransform::RenameField {
440 from: "conclusion".to_string(),
441 to: "head".to_string(),
442 }],
443 description: "V2 → V1 downgrade: rename conclusion back".to_string(),
444 }
445 }
446
447 fn migrator_with_upgrade_steps() -> RuleVersionMigrator {
448 let mut m = RuleVersionMigrator::new();
449 m.register_step(make_v1_v2_step());
450 m.register_step(make_v2_v3_step());
451 m
452 }
453
454 // -----------------------------------------------------------------------
455 // 1. plan — same version returns empty plan
456 // -----------------------------------------------------------------------
457
458 #[test]
459 fn test_plan_same_version_is_empty() {
460 let m = migrator_with_upgrade_steps();
461 let plan = m
462 .plan(RuleSchemaVersion::V1, RuleSchemaVersion::V1)
463 .expect("test: should succeed");
464 assert!(plan.is_empty());
465 assert_eq!(plan.total_transforms(), 0);
466 }
467
468 #[test]
469 fn test_plan_same_version_v2() {
470 let m = migrator_with_upgrade_steps();
471 let plan = m
472 .plan(RuleSchemaVersion::V2, RuleSchemaVersion::V2)
473 .expect("test: should succeed");
474 assert!(plan.is_empty());
475 }
476
477 #[test]
478 fn test_plan_same_version_v3() {
479 let m = migrator_with_upgrade_steps();
480 let plan = m
481 .plan(RuleSchemaVersion::V3, RuleSchemaVersion::V3)
482 .expect("test: should succeed");
483 assert!(plan.is_empty());
484 }
485
486 // -----------------------------------------------------------------------
487 // 2. plan — V1 → V2
488 // -----------------------------------------------------------------------
489
490 #[test]
491 fn test_plan_v1_to_v2() {
492 let m = migrator_with_upgrade_steps();
493 let plan = m
494 .plan(RuleSchemaVersion::V1, RuleSchemaVersion::V2)
495 .expect("test: should succeed");
496 assert_eq!(plan.steps.len(), 1);
497 assert_eq!(plan.steps[0].from_version, RuleSchemaVersion::V1);
498 assert_eq!(plan.steps[0].to_version, RuleSchemaVersion::V2);
499 assert_eq!(plan.total_transforms(), 2);
500 }
501
502 // -----------------------------------------------------------------------
503 // 3. plan — V1 → V3 via V2
504 // -----------------------------------------------------------------------
505
506 #[test]
507 fn test_plan_v1_to_v3_via_v2() {
508 let m = migrator_with_upgrade_steps();
509 let plan = m
510 .plan(RuleSchemaVersion::V1, RuleSchemaVersion::V3)
511 .expect("test: should succeed");
512 assert_eq!(plan.steps.len(), 2);
513 assert_eq!(plan.steps[0].from_version, RuleSchemaVersion::V1);
514 assert_eq!(plan.steps[0].to_version, RuleSchemaVersion::V2);
515 assert_eq!(plan.steps[1].from_version, RuleSchemaVersion::V2);
516 assert_eq!(plan.steps[1].to_version, RuleSchemaVersion::V3);
517 // 2 transforms in V1→V2, 2 transforms in V2→V3
518 assert_eq!(plan.total_transforms(), 4);
519 }
520
521 // -----------------------------------------------------------------------
522 // 4. plan — missing step returns None
523 // -----------------------------------------------------------------------
524
525 #[test]
526 fn test_plan_missing_step_returns_none() {
527 // Only V1→V2 is registered; V2→V3 is absent.
528 let mut m = RuleVersionMigrator::new();
529 m.register_step(make_v1_v2_step());
530 let plan = m.plan(RuleSchemaVersion::V1, RuleSchemaVersion::V3);
531 assert!(plan.is_none());
532 }
533
534 #[test]
535 fn test_plan_empty_migrator_returns_none_for_upgrade() {
536 let m = RuleVersionMigrator::new();
537 assert!(m
538 .plan(RuleSchemaVersion::V1, RuleSchemaVersion::V2)
539 .is_none());
540 }
541
542 // -----------------------------------------------------------------------
543 // 5. plan — downgrade V3 → V1
544 // -----------------------------------------------------------------------
545
546 #[test]
547 fn test_plan_downgrade_v3_to_v1() {
548 let mut m = RuleVersionMigrator::new();
549 m.register_step(make_v3_v2_step());
550 m.register_step(make_v2_v1_step());
551
552 let plan = m
553 .plan(RuleSchemaVersion::V3, RuleSchemaVersion::V1)
554 .expect("test: should succeed");
555 assert_eq!(plan.steps.len(), 2);
556 // First hop: V3→V2, second hop: V2→V1
557 assert_eq!(plan.steps[0].from_version, RuleSchemaVersion::V3);
558 assert_eq!(plan.steps[0].to_version, RuleSchemaVersion::V2);
559 assert_eq!(plan.steps[1].from_version, RuleSchemaVersion::V2);
560 assert_eq!(plan.steps[1].to_version, RuleSchemaVersion::V1);
561 }
562
563 #[test]
564 fn test_plan_downgrade_missing_step_returns_none() {
565 let mut m = RuleVersionMigrator::new();
566 // Only V3→V2 is registered; V2→V1 is absent.
567 m.register_step(make_v3_v2_step());
568 let plan = m.plan(RuleSchemaVersion::V3, RuleSchemaVersion::V1);
569 assert!(plan.is_none());
570 }
571
572 // -----------------------------------------------------------------------
573 // 6. register_step
574 // -----------------------------------------------------------------------
575
576 #[test]
577 fn test_register_step_increases_registered_paths() {
578 let mut m = RuleVersionMigrator::new();
579 assert!(m.registered_paths().is_empty());
580
581 m.register_step(make_v1_v2_step());
582 assert_eq!(m.registered_paths().len(), 1);
583
584 m.register_step(make_v2_v3_step());
585 assert_eq!(m.registered_paths().len(), 2);
586 }
587
588 // -----------------------------------------------------------------------
589 // 7. registered_paths
590 // -----------------------------------------------------------------------
591
592 #[test]
593 fn test_registered_paths_content() {
594 let m = migrator_with_upgrade_steps();
595 let paths = m.registered_paths();
596 assert!(paths.contains(&(RuleSchemaVersion::V1, RuleSchemaVersion::V2)));
597 assert!(paths.contains(&(RuleSchemaVersion::V2, RuleSchemaVersion::V3)));
598 }
599
600 // -----------------------------------------------------------------------
601 // 8. migrate — total_transforms reflected in result
602 // -----------------------------------------------------------------------
603
604 #[test]
605 fn test_migrate_total_transforms() {
606 let m = migrator_with_upgrade_steps();
607 let result = m.migrate(RuleSchemaVersion::V1, RuleSchemaVersion::V3);
608 // V1→V2 has 2, V2→V3 has 2 → total 4
609 assert_eq!(result.transforms_applied, 4);
610 assert_eq!(result.applied_steps, 2);
611 }
612
613 // -----------------------------------------------------------------------
614 // 9. migrate — ConvertType emits warning
615 // -----------------------------------------------------------------------
616
617 #[test]
618 fn test_migrate_convert_type_emits_warning() {
619 let m = migrator_with_upgrade_steps();
620 // V2→V3 contains a ConvertType transform.
621 let result = m.migrate(RuleSchemaVersion::V2, RuleSchemaVersion::V3);
622 let has_convert_warning = result
623 .warnings
624 .iter()
625 .any(|w| w.contains("ConvertType") && w.contains("priority"));
626 assert!(
627 has_convert_warning,
628 "Expected ConvertType warning, got: {:?}",
629 result.warnings
630 );
631 }
632
633 // -----------------------------------------------------------------------
634 // 10. migrate — RemoveField emits warning
635 // -----------------------------------------------------------------------
636
637 #[test]
638 fn test_migrate_remove_field_emits_warning() {
639 let m = migrator_with_upgrade_steps();
640 let result = m.migrate(RuleSchemaVersion::V2, RuleSchemaVersion::V3);
641 let has_remove_warning = result
642 .warnings
643 .iter()
644 .any(|w| w.contains("RemoveField") && w.contains("legacy_tag"));
645 assert!(
646 has_remove_warning,
647 "Expected RemoveField warning, got: {:?}",
648 result.warnings
649 );
650 }
651
652 // -----------------------------------------------------------------------
653 // 11. migrate — downgrade emits downgrade warning
654 // -----------------------------------------------------------------------
655
656 #[test]
657 fn test_migrate_downgrade_emits_warning() {
658 let mut m = RuleVersionMigrator::new();
659 m.register_step(make_v3_v2_step());
660 m.register_step(make_v2_v1_step());
661
662 let result = m.migrate(RuleSchemaVersion::V3, RuleSchemaVersion::V1);
663 let has_downgrade = result
664 .warnings
665 .iter()
666 .any(|w| w.to_lowercase().contains("downgrade"));
667 assert!(
668 has_downgrade,
669 "Expected downgrade warning, got: {:?}",
670 result.warnings
671 );
672 }
673
674 // -----------------------------------------------------------------------
675 // 12. MigrationPlan::total_transforms
676 // -----------------------------------------------------------------------
677
678 #[test]
679 fn test_migration_plan_total_transforms() {
680 let step1 = make_v1_v2_step(); // 2 transforms
681 let step2 = make_v2_v3_step(); // 2 transforms
682 let plan = MigrationPlan {
683 steps: vec![step1, step2],
684 };
685 assert_eq!(plan.total_transforms(), 4);
686 }
687
688 // -----------------------------------------------------------------------
689 // 13. MigrationPlan::is_empty
690 // -----------------------------------------------------------------------
691
692 #[test]
693 fn test_migration_plan_is_empty_true() {
694 let plan = MigrationPlan { steps: vec![] };
695 assert!(plan.is_empty());
696 }
697
698 #[test]
699 fn test_migration_plan_is_empty_false() {
700 let plan = MigrationPlan {
701 steps: vec![make_v1_v2_step()],
702 };
703 assert!(!plan.is_empty());
704 }
705
706 // -----------------------------------------------------------------------
707 // 14. MigrationResult::is_success — true cases
708 // -----------------------------------------------------------------------
709
710 #[test]
711 fn test_migration_result_is_success_with_steps() {
712 let result = MigrationResult {
713 applied_steps: 1,
714 transforms_applied: 2,
715 warnings: vec![],
716 };
717 assert!(result.is_success());
718 }
719
720 #[test]
721 fn test_migration_result_is_success_same_version() {
722 // Same-version: 0 steps applied, no warnings → still success.
723 let m = migrator_with_upgrade_steps();
724 let result = m.migrate(RuleSchemaVersion::V2, RuleSchemaVersion::V2);
725 assert!(result.is_success());
726 assert_eq!(result.applied_steps, 0);
727 }
728
729 // -----------------------------------------------------------------------
730 // 15. MigrationResult::is_success — false case (no plan)
731 // -----------------------------------------------------------------------
732
733 #[test]
734 fn test_migration_result_no_plan_has_zero_steps() {
735 let m = RuleVersionMigrator::new();
736 // No steps registered → plan returns None → applied_steps == 0
737 let result = m.migrate(RuleSchemaVersion::V1, RuleSchemaVersion::V3);
738 assert_eq!(result.applied_steps, 0);
739 assert!(
740 !result.warnings.is_empty(),
741 "should warn about missing path"
742 );
743 }
744
745 // -----------------------------------------------------------------------
746 // 16. migrate — step-by-step V1→V2 accuracy
747 // -----------------------------------------------------------------------
748
749 #[test]
750 fn test_migrate_v1_to_v2_single_step() {
751 let m = migrator_with_upgrade_steps();
752 let result = m.migrate(RuleSchemaVersion::V1, RuleSchemaVersion::V2);
753 assert_eq!(result.applied_steps, 1);
754 assert_eq!(result.transforms_applied, 2);
755 // RenameField + AddField are non-lossy → no warnings
756 assert!(result.warnings.is_empty());
757 }
758
759 // -----------------------------------------------------------------------
760 // 17. RuleSchemaVersion ordering
761 // -----------------------------------------------------------------------
762
763 #[test]
764 fn test_schema_version_ordering() {
765 assert!(RuleSchemaVersion::V1 < RuleSchemaVersion::V2);
766 assert!(RuleSchemaVersion::V2 < RuleSchemaVersion::V3);
767 assert!(RuleSchemaVersion::V1 < RuleSchemaVersion::V3);
768 assert_eq!(RuleSchemaVersion::V2, RuleSchemaVersion::V2);
769 }
770
771 // -----------------------------------------------------------------------
772 // 18. Duplicate registration does not break planning
773 // -----------------------------------------------------------------------
774
775 #[test]
776 fn test_duplicate_step_registration_uses_first_match() {
777 let mut m = RuleVersionMigrator::new();
778 m.register_step(make_v1_v2_step());
779 // Register a second (different) V1→V2 step — planner should use the first.
780 m.register_step(MigrationStep {
781 from_version: RuleSchemaVersion::V1,
782 to_version: RuleSchemaVersion::V2,
783 transforms: vec![],
784 description: "duplicate V1→V2".to_string(),
785 });
786 let plan = m
787 .plan(RuleSchemaVersion::V1, RuleSchemaVersion::V2)
788 .expect("test: should succeed");
789 assert_eq!(plan.steps.len(), 1);
790 // First registered step is used.
791 assert!(!plan.steps[0].description.contains("duplicate"));
792 }
793}