Skip to main content

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}