Skip to main content

tensorlogic_adapters/
evolution.rs

1//! Schema evolution analysis and migration planning.
2//!
3//! This module provides sophisticated analysis of schema changes to detect
4//! breaking changes, suggest migrations, and ensure backward compatibility.
5//!
6//! # Overview
7//!
8//! Schema evolution is critical for production systems. This module goes
9//! beyond simple diffs to provide:
10//!
11//! - **Breaking change detection**: Identify changes that break existing code
12//! - **Semantic versioning guidance**: Suggest version bumps (major/minor/patch)
13//! - **Migration path generation**: Create executable migration plans
14//! - **Compatibility analysis**: Assess forward/backward compatibility
15//! - **Deprecation tracking**: Manage deprecated features
16//!
17//! # Architecture
18//!
19//! - **EvolutionAnalyzer**: Analyzes schema changes
20//! - **BreakingChange**: Categorizes breaking changes by severity
21//! - **MigrationPlan**: Executable migration steps
22//! - **CompatibilityReport**: Detailed compatibility analysis
23//! - **DeprecationPolicy**: Manage deprecation lifecycle
24//!
25//! # Example
26//!
27//! ```rust
28//! use tensorlogic_adapters::{SymbolTable, DomainInfo, EvolutionAnalyzer};
29//!
30//! let mut old_schema = SymbolTable::new();
31//! old_schema.add_domain(DomainInfo::new("Person", 100)).unwrap();
32//!
33//! let mut new_schema = SymbolTable::new();
34//! new_schema.add_domain(DomainInfo::new("Person", 200)).unwrap();
35//!
36//! let analyzer = EvolutionAnalyzer::new(&old_schema, &new_schema);
37//! let report = analyzer.analyze().unwrap();
38//!
39//! if report.has_breaking_changes() {
40//!     println!("Breaking changes detected!");
41//!     for change in &report.breaking_changes {
42//!         println!("  - {}", change.description);
43//!     }
44//! }
45//!
46//! println!("Suggested version: {}", report.suggested_version_bump());
47//! ```
48
49use anyhow::Result;
50use std::collections::HashSet;
51
52use crate::SymbolTable;
53
54/// Type of schema change
55#[derive(Clone, Debug, PartialEq, Eq)]
56pub enum ChangeKind {
57    /// Domain added
58    DomainAdded,
59    /// Domain removed
60    DomainRemoved,
61    /// Domain cardinality increased
62    DomainCardinalityIncreased,
63    /// Domain cardinality decreased
64    DomainCardinalityDecreased,
65    /// Predicate added
66    PredicateAdded,
67    /// Predicate removed
68    PredicateRemoved,
69    /// Predicate arity changed
70    PredicateArityChanged,
71    /// Predicate signature changed
72    PredicateSignatureChanged,
73    /// Variable added
74    VariableAdded,
75    /// Variable removed
76    VariableRemoved,
77    /// Variable type changed
78    VariableTypeChanged,
79}
80
81impl ChangeKind {
82    /// Check if this change is potentially breaking
83    pub fn is_potentially_breaking(&self) -> bool {
84        matches!(
85            self,
86            ChangeKind::DomainRemoved
87                | ChangeKind::DomainCardinalityDecreased
88                | ChangeKind::PredicateRemoved
89                | ChangeKind::PredicateArityChanged
90                | ChangeKind::PredicateSignatureChanged
91                | ChangeKind::VariableRemoved
92                | ChangeKind::VariableTypeChanged
93        )
94    }
95}
96
97/// Impact level of a change
98#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
99pub enum ChangeImpact {
100    /// No impact (internal change)
101    None,
102    /// Minor impact (new features, backward compatible)
103    Minor,
104    /// Moderate impact (deprecations, behavior changes)
105    Moderate,
106    /// Major impact (breaking changes)
107    Major,
108    /// Critical impact (data loss possible)
109    Critical,
110}
111
112impl ChangeImpact {
113    pub fn max(self, other: Self) -> Self {
114        if self > other {
115            self
116        } else {
117            other
118        }
119    }
120}
121
122/// Breaking change with detailed information
123#[derive(Clone, Debug)]
124pub struct BreakingChange {
125    pub kind: ChangeKind,
126    pub impact: ChangeImpact,
127    pub description: String,
128    pub affected_components: Vec<String>,
129    pub migration_hint: Option<String>,
130}
131
132impl BreakingChange {
133    pub fn new(kind: ChangeKind, impact: ChangeImpact, description: impl Into<String>) -> Self {
134        Self {
135            kind,
136            impact,
137            description: description.into(),
138            affected_components: Vec::new(),
139            migration_hint: None,
140        }
141    }
142
143    pub fn with_affected(mut self, components: Vec<String>) -> Self {
144        self.affected_components = components;
145        self
146    }
147
148    pub fn with_migration_hint(mut self, hint: impl Into<String>) -> Self {
149        self.migration_hint = Some(hint.into());
150        self
151    }
152}
153
154/// Semantic version bump recommendation
155#[derive(Clone, Debug, PartialEq, Eq)]
156pub enum VersionBump {
157    /// No version change needed
158    None,
159    /// Patch version bump (bug fixes, internal changes)
160    Patch,
161    /// Minor version bump (new features, backward compatible)
162    Minor,
163    /// Major version bump (breaking changes)
164    Major,
165}
166
167impl std::fmt::Display for VersionBump {
168    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169        match self {
170            VersionBump::None => write!(f, "none"),
171            VersionBump::Patch => write!(f, "patch"),
172            VersionBump::Minor => write!(f, "minor"),
173            VersionBump::Major => write!(f, "major"),
174        }
175    }
176}
177
178/// Migration step
179#[derive(Clone, Debug)]
180pub enum MigrationStep {
181    /// Add a domain
182    AddDomain(String, usize),
183    /// Remove a domain
184    RemoveDomain(String),
185    /// Resize a domain
186    ResizeDomain(String, usize),
187    /// Add a predicate
188    AddPredicate(String, Vec<String>),
189    /// Remove a predicate
190    RemovePredicate(String),
191    /// Rename a predicate
192    RenamePredicate(String, String),
193    /// Add a variable binding
194    AddVariable(String, String),
195    /// Remove a variable binding
196    RemoveVariable(String),
197    /// Update variable type
198    UpdateVariableType(String, String),
199    /// Custom migration code
200    Custom(String),
201}
202
203impl MigrationStep {
204    pub fn description(&self) -> String {
205        match self {
206            MigrationStep::AddDomain(name, size) => {
207                format!("Add domain '{}' with cardinality {}", name, size)
208            }
209            MigrationStep::RemoveDomain(name) => format!("Remove domain '{}'", name),
210            MigrationStep::ResizeDomain(name, new_size) => {
211                format!("Resize domain '{}' to cardinality {}", name, new_size)
212            }
213            MigrationStep::AddPredicate(name, domains) => {
214                format!("Add predicate '{}' with signature {:?}", name, domains)
215            }
216            MigrationStep::RemovePredicate(name) => format!("Remove predicate '{}'", name),
217            MigrationStep::RenamePredicate(old, new) => {
218                format!("Rename predicate '{}' to '{}'", old, new)
219            }
220            MigrationStep::AddVariable(name, domain) => {
221                format!("Add variable '{}' of type '{}'", name, domain)
222            }
223            MigrationStep::RemoveVariable(name) => format!("Remove variable '{}'", name),
224            MigrationStep::UpdateVariableType(name, new_type) => {
225                format!("Update variable '{}' to type '{}'", name, new_type)
226            }
227            MigrationStep::Custom(desc) => desc.clone(),
228        }
229    }
230
231    pub fn is_reversible(&self) -> bool {
232        matches!(
233            self,
234            MigrationStep::AddDomain(_, _)
235                | MigrationStep::AddPredicate(_, _)
236                | MigrationStep::AddVariable(_, _)
237                | MigrationStep::RenamePredicate(_, _)
238        )
239    }
240}
241
242/// Migration plan
243#[derive(Clone, Debug)]
244pub struct MigrationPlan {
245    pub steps: Vec<MigrationStep>,
246    pub estimated_complexity: usize,
247    pub requires_manual_intervention: bool,
248}
249
250impl MigrationPlan {
251    pub fn new() -> Self {
252        Self {
253            steps: Vec::new(),
254            estimated_complexity: 0,
255            requires_manual_intervention: false,
256        }
257    }
258
259    pub fn add_step(&mut self, step: MigrationStep) {
260        self.estimated_complexity += 1;
261        if !step.is_reversible() {
262            self.requires_manual_intervention = true;
263        }
264        self.steps.push(step);
265    }
266
267    pub fn is_empty(&self) -> bool {
268        self.steps.is_empty()
269    }
270
271    pub fn is_automatic(&self) -> bool {
272        !self.requires_manual_intervention
273    }
274}
275
276impl Default for MigrationPlan {
277    fn default() -> Self {
278        Self::new()
279    }
280}
281
282/// Compatibility analysis result
283#[derive(Clone, Debug)]
284pub struct CompatibilityReport {
285    /// Breaking changes
286    pub breaking_changes: Vec<BreakingChange>,
287    /// Backward compatible changes
288    pub backward_compatible_changes: Vec<String>,
289    /// Forward compatible changes
290    pub forward_compatible_changes: Vec<String>,
291    /// Deprecations
292    pub deprecations: Vec<String>,
293    /// Migration plan
294    pub migration_plan: MigrationPlan,
295}
296
297impl CompatibilityReport {
298    pub fn new() -> Self {
299        Self {
300            breaking_changes: Vec::new(),
301            backward_compatible_changes: Vec::new(),
302            forward_compatible_changes: Vec::new(),
303            deprecations: Vec::new(),
304            migration_plan: MigrationPlan::new(),
305        }
306    }
307
308    pub fn has_breaking_changes(&self) -> bool {
309        !self.breaking_changes.is_empty()
310    }
311
312    pub fn is_backward_compatible(&self) -> bool {
313        !self.has_breaking_changes()
314    }
315
316    pub fn suggested_version_bump(&self) -> VersionBump {
317        if self.has_breaking_changes() {
318            VersionBump::Major
319        } else if !self.backward_compatible_changes.is_empty() {
320            VersionBump::Minor
321        } else {
322            VersionBump::Patch
323        }
324    }
325
326    pub fn max_impact(&self) -> ChangeImpact {
327        self.breaking_changes
328            .iter()
329            .map(|bc| bc.impact.clone())
330            .max()
331            .unwrap_or(ChangeImpact::None)
332    }
333}
334
335impl Default for CompatibilityReport {
336    fn default() -> Self {
337        Self::new()
338    }
339}
340
341/// Schema evolution analyzer
342pub struct EvolutionAnalyzer<'a> {
343    old_schema: &'a SymbolTable,
344    new_schema: &'a SymbolTable,
345}
346
347impl<'a> EvolutionAnalyzer<'a> {
348    pub fn new(old_schema: &'a SymbolTable, new_schema: &'a SymbolTable) -> Self {
349        Self {
350            old_schema,
351            new_schema,
352        }
353    }
354
355    /// Analyze schema evolution
356    pub fn analyze(&self) -> Result<CompatibilityReport> {
357        let mut report = CompatibilityReport::new();
358
359        self.analyze_domains(&mut report)?;
360        self.analyze_predicates(&mut report)?;
361        self.analyze_variables(&mut report)?;
362        self.generate_migration_plan(&mut report)?;
363
364        Ok(report)
365    }
366
367    fn analyze_domains(&self, report: &mut CompatibilityReport) -> Result<()> {
368        let old_domains: HashSet<_> = self.old_schema.domains.keys().collect();
369        let new_domains: HashSet<_> = self.new_schema.domains.keys().collect();
370
371        // Removed domains (breaking)
372        for removed in old_domains.difference(&new_domains) {
373            let affected = self.find_predicates_using_domain(removed);
374            report.breaking_changes.push(
375                BreakingChange::new(
376                    ChangeKind::DomainRemoved,
377                    ChangeImpact::Major,
378                    format!("Domain '{}' was removed", removed),
379                )
380                .with_affected(affected)
381                .with_migration_hint(
382                    "Replace references to this domain or remove dependent predicates",
383                ),
384            );
385        }
386
387        // Added domains (backward compatible)
388        for added in new_domains.difference(&old_domains) {
389            report
390                .backward_compatible_changes
391                .push(format!("Domain '{}' was added", added));
392        }
393
394        // Modified domains
395        for domain in old_domains.intersection(&new_domains) {
396            let old_info = &self.old_schema.domains[*domain];
397            let new_info = &self.new_schema.domains[*domain];
398
399            if new_info.cardinality > old_info.cardinality {
400                report.backward_compatible_changes.push(format!(
401                    "Domain '{}' cardinality increased from {} to {}",
402                    domain, old_info.cardinality, new_info.cardinality
403                ));
404            } else if new_info.cardinality < old_info.cardinality {
405                report.breaking_changes.push(
406                    BreakingChange::new(
407                        ChangeKind::DomainCardinalityDecreased,
408                        ChangeImpact::Critical,
409                        format!(
410                            "Domain '{}' cardinality decreased from {} to {} (possible data loss)",
411                            domain, old_info.cardinality, new_info.cardinality
412                        ),
413                    )
414                    .with_migration_hint(
415                        "Ensure all existing data fits within the new cardinality",
416                    ),
417                );
418            }
419        }
420
421        Ok(())
422    }
423
424    fn analyze_predicates(&self, report: &mut CompatibilityReport) -> Result<()> {
425        let old_predicates: HashSet<_> = self.old_schema.predicates.keys().collect();
426        let new_predicates: HashSet<_> = self.new_schema.predicates.keys().collect();
427
428        // Removed predicates (breaking)
429        for removed in old_predicates.difference(&new_predicates) {
430            report.breaking_changes.push(
431                BreakingChange::new(
432                    ChangeKind::PredicateRemoved,
433                    ChangeImpact::Major,
434                    format!("Predicate '{}' was removed", removed),
435                )
436                .with_migration_hint("Remove or replace usages of this predicate"),
437            );
438        }
439
440        // Added predicates (backward compatible)
441        for added in new_predicates.difference(&old_predicates) {
442            report
443                .backward_compatible_changes
444                .push(format!("Predicate '{}' was added", added));
445        }
446
447        // Modified predicates
448        for predicate in old_predicates.intersection(&new_predicates) {
449            let old_pred = &self.old_schema.predicates[*predicate];
450            let new_pred = &self.new_schema.predicates[*predicate];
451
452            // Check arity
453            if old_pred.arg_domains.len() != new_pred.arg_domains.len() {
454                report.breaking_changes.push(
455                    BreakingChange::new(
456                        ChangeKind::PredicateArityChanged,
457                        ChangeImpact::Major,
458                        format!(
459                            "Predicate '{}' arity changed from {} to {}",
460                            predicate,
461                            old_pred.arg_domains.len(),
462                            new_pred.arg_domains.len()
463                        ),
464                    )
465                    .with_migration_hint("Update all usages to match the new arity"),
466                );
467            }
468            // Check signature
469            else if old_pred.arg_domains != new_pred.arg_domains {
470                report.breaking_changes.push(
471                    BreakingChange::new(
472                        ChangeKind::PredicateSignatureChanged,
473                        ChangeImpact::Major,
474                        format!(
475                            "Predicate '{}' signature changed from {:?} to {:?}",
476                            predicate, old_pred.arg_domains, new_pred.arg_domains
477                        ),
478                    )
479                    .with_migration_hint("Update argument types in all usages"),
480                );
481            }
482        }
483
484        Ok(())
485    }
486
487    fn analyze_variables(&self, report: &mut CompatibilityReport) -> Result<()> {
488        let old_variables: HashSet<_> = self.old_schema.variables.keys().collect();
489        let new_variables: HashSet<_> = self.new_schema.variables.keys().collect();
490
491        // Removed variables (breaking)
492        for removed in old_variables.difference(&new_variables) {
493            report.breaking_changes.push(
494                BreakingChange::new(
495                    ChangeKind::VariableRemoved,
496                    ChangeImpact::Moderate,
497                    format!("Variable '{}' was removed", removed),
498                )
499                .with_migration_hint("Remove or replace usages of this variable"),
500            );
501        }
502
503        // Added variables (backward compatible)
504        for added in new_variables.difference(&old_variables) {
505            report
506                .backward_compatible_changes
507                .push(format!("Variable '{}' was added", added));
508        }
509
510        // Modified variables
511        for variable in old_variables.intersection(&new_variables) {
512            let old_type = &self.old_schema.variables[*variable];
513            let new_type = &self.new_schema.variables[*variable];
514
515            if old_type != new_type {
516                report.breaking_changes.push(
517                    BreakingChange::new(
518                        ChangeKind::VariableTypeChanged,
519                        ChangeImpact::Major,
520                        format!(
521                            "Variable '{}' type changed from '{}' to '{}'",
522                            variable, old_type, new_type
523                        ),
524                    )
525                    .with_migration_hint("Update usages to match the new type"),
526                );
527            }
528        }
529
530        Ok(())
531    }
532
533    fn generate_migration_plan(&self, report: &mut CompatibilityReport) -> Result<()> {
534        let mut plan = MigrationPlan::new();
535
536        // Generate steps for domain changes
537        let old_domains: HashSet<_> = self.old_schema.domains.keys().cloned().collect();
538        let new_domains: HashSet<_> = self.new_schema.domains.keys().cloned().collect();
539
540        for added in new_domains.difference(&old_domains) {
541            let info = &self.new_schema.domains[added];
542            plan.add_step(MigrationStep::AddDomain(added.clone(), info.cardinality));
543        }
544
545        for removed in old_domains.difference(&new_domains) {
546            plan.add_step(MigrationStep::RemoveDomain(removed.clone()));
547        }
548
549        for domain in old_domains.intersection(&new_domains) {
550            let old_info = &self.old_schema.domains[domain];
551            let new_info = &self.new_schema.domains[domain];
552
553            if old_info.cardinality != new_info.cardinality {
554                plan.add_step(MigrationStep::ResizeDomain(
555                    domain.clone(),
556                    new_info.cardinality,
557                ));
558            }
559        }
560
561        // Generate steps for predicate changes
562        let old_predicates: HashSet<_> = self.old_schema.predicates.keys().cloned().collect();
563        let new_predicates: HashSet<_> = self.new_schema.predicates.keys().cloned().collect();
564
565        for added in new_predicates.difference(&old_predicates) {
566            let pred = &self.new_schema.predicates[added];
567            plan.add_step(MigrationStep::AddPredicate(
568                added.clone(),
569                pred.arg_domains.clone(),
570            ));
571        }
572
573        for removed in old_predicates.difference(&new_predicates) {
574            plan.add_step(MigrationStep::RemovePredicate(removed.clone()));
575        }
576
577        // Generate steps for variable changes
578        let old_variables: HashSet<_> = self.old_schema.variables.keys().cloned().collect();
579        let new_variables: HashSet<_> = self.new_schema.variables.keys().cloned().collect();
580
581        for added in new_variables.difference(&old_variables) {
582            let domain = &self.new_schema.variables[added];
583            plan.add_step(MigrationStep::AddVariable(added.clone(), domain.clone()));
584        }
585
586        for removed in old_variables.difference(&new_variables) {
587            plan.add_step(MigrationStep::RemoveVariable(removed.clone()));
588        }
589
590        for variable in old_variables.intersection(&new_variables) {
591            let old_type = &self.old_schema.variables[variable];
592            let new_type = &self.new_schema.variables[variable];
593
594            if old_type != new_type {
595                plan.add_step(MigrationStep::UpdateVariableType(
596                    variable.clone(),
597                    new_type.clone(),
598                ));
599            }
600        }
601
602        report.migration_plan = plan;
603        Ok(())
604    }
605
606    fn find_predicates_using_domain(&self, domain: &str) -> Vec<String> {
607        self.old_schema
608            .predicates
609            .iter()
610            .filter(|(_, pred)| pred.arg_domains.contains(&domain.to_string()))
611            .map(|(name, _)| name.clone())
612            .collect()
613    }
614}
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619    use crate::{DomainInfo, PredicateInfo};
620
621    #[test]
622    fn test_domain_removal_breaking() {
623        let mut old_schema = SymbolTable::new();
624        old_schema
625            .add_domain(DomainInfo::new("Person", 100))
626            .unwrap();
627
628        let new_schema = SymbolTable::new();
629
630        let analyzer = EvolutionAnalyzer::new(&old_schema, &new_schema);
631        let report = analyzer.analyze().unwrap();
632
633        assert!(report.has_breaking_changes());
634        assert_eq!(report.suggested_version_bump(), VersionBump::Major);
635    }
636
637    #[test]
638    fn test_domain_addition_compatible() {
639        let old_schema = SymbolTable::new();
640
641        let mut new_schema = SymbolTable::new();
642        new_schema
643            .add_domain(DomainInfo::new("Person", 100))
644            .unwrap();
645
646        let analyzer = EvolutionAnalyzer::new(&old_schema, &new_schema);
647        let report = analyzer.analyze().unwrap();
648
649        assert!(!report.has_breaking_changes());
650        assert_eq!(report.suggested_version_bump(), VersionBump::Minor);
651    }
652
653    #[test]
654    fn test_cardinality_increase_compatible() {
655        let mut old_schema = SymbolTable::new();
656        old_schema
657            .add_domain(DomainInfo::new("Person", 100))
658            .unwrap();
659
660        let mut new_schema = SymbolTable::new();
661        new_schema
662            .add_domain(DomainInfo::new("Person", 200))
663            .unwrap();
664
665        let analyzer = EvolutionAnalyzer::new(&old_schema, &new_schema);
666        let report = analyzer.analyze().unwrap();
667
668        assert!(!report.has_breaking_changes());
669        assert!(!report.backward_compatible_changes.is_empty());
670    }
671
672    #[test]
673    fn test_cardinality_decrease_breaking() {
674        let mut old_schema = SymbolTable::new();
675        old_schema
676            .add_domain(DomainInfo::new("Person", 200))
677            .unwrap();
678
679        let mut new_schema = SymbolTable::new();
680        new_schema
681            .add_domain(DomainInfo::new("Person", 100))
682            .unwrap();
683
684        let analyzer = EvolutionAnalyzer::new(&old_schema, &new_schema);
685        let report = analyzer.analyze().unwrap();
686
687        assert!(report.has_breaking_changes());
688        assert_eq!(report.max_impact(), ChangeImpact::Critical);
689    }
690
691    #[test]
692    fn test_predicate_removal_breaking() {
693        let mut old_schema = SymbolTable::new();
694        old_schema
695            .add_domain(DomainInfo::new("Person", 100))
696            .unwrap();
697        old_schema
698            .add_predicate(PredicateInfo::new(
699                "knows",
700                vec!["Person".to_string(), "Person".to_string()],
701            ))
702            .unwrap();
703
704        let mut new_schema = SymbolTable::new();
705        new_schema
706            .add_domain(DomainInfo::new("Person", 100))
707            .unwrap();
708
709        let analyzer = EvolutionAnalyzer::new(&old_schema, &new_schema);
710        let report = analyzer.analyze().unwrap();
711
712        assert!(report.has_breaking_changes());
713        assert_eq!(
714            report.breaking_changes[0].kind,
715            ChangeKind::PredicateRemoved
716        );
717    }
718
719    #[test]
720    fn test_predicate_signature_change_breaking() {
721        let mut old_schema = SymbolTable::new();
722        old_schema
723            .add_domain(DomainInfo::new("Person", 100))
724            .unwrap();
725        old_schema
726            .add_domain(DomainInfo::new("Location", 50))
727            .unwrap();
728        old_schema
729            .add_predicate(PredicateInfo::new(
730                "at",
731                vec!["Person".to_string(), "Location".to_string()],
732            ))
733            .unwrap();
734
735        let mut new_schema = SymbolTable::new();
736        new_schema
737            .add_domain(DomainInfo::new("Person", 100))
738            .unwrap();
739        new_schema
740            .add_domain(DomainInfo::new("Location", 50))
741            .unwrap();
742        new_schema
743            .add_predicate(PredicateInfo::new(
744                "at",
745                vec!["Person".to_string(), "Person".to_string()],
746            ))
747            .unwrap();
748
749        let analyzer = EvolutionAnalyzer::new(&old_schema, &new_schema);
750        let report = analyzer.analyze().unwrap();
751
752        assert!(report.has_breaking_changes());
753    }
754
755    #[test]
756    fn test_migration_plan_generation() {
757        let mut old_schema = SymbolTable::new();
758        old_schema
759            .add_domain(DomainInfo::new("Person", 100))
760            .unwrap();
761
762        let mut new_schema = SymbolTable::new();
763        new_schema
764            .add_domain(DomainInfo::new("Person", 100))
765            .unwrap();
766        new_schema
767            .add_domain(DomainInfo::new("Location", 50))
768            .unwrap();
769
770        let analyzer = EvolutionAnalyzer::new(&old_schema, &new_schema);
771        let report = analyzer.analyze().unwrap();
772
773        assert!(!report.migration_plan.is_empty());
774        assert_eq!(report.migration_plan.steps.len(), 1);
775    }
776
777    #[test]
778    fn test_variable_type_change_breaking() {
779        let mut old_schema = SymbolTable::new();
780        old_schema
781            .add_domain(DomainInfo::new("Person", 100))
782            .unwrap();
783        old_schema
784            .add_domain(DomainInfo::new("Location", 50))
785            .unwrap();
786        old_schema.bind_variable("x", "Person").unwrap();
787
788        let mut new_schema = SymbolTable::new();
789        new_schema
790            .add_domain(DomainInfo::new("Person", 100))
791            .unwrap();
792        new_schema
793            .add_domain(DomainInfo::new("Location", 50))
794            .unwrap();
795        new_schema.bind_variable("x", "Location").unwrap();
796
797        let analyzer = EvolutionAnalyzer::new(&old_schema, &new_schema);
798        let report = analyzer.analyze().unwrap();
799
800        assert!(report.has_breaking_changes());
801    }
802
803    #[test]
804    fn test_no_changes() {
805        let mut old_schema = SymbolTable::new();
806        old_schema
807            .add_domain(DomainInfo::new("Person", 100))
808            .unwrap();
809
810        let mut new_schema = SymbolTable::new();
811        new_schema
812            .add_domain(DomainInfo::new("Person", 100))
813            .unwrap();
814
815        let analyzer = EvolutionAnalyzer::new(&old_schema, &new_schema);
816        let report = analyzer.analyze().unwrap();
817
818        assert!(!report.has_breaking_changes());
819        assert_eq!(report.suggested_version_bump(), VersionBump::Patch);
820    }
821}