1use anyhow::Result;
50use std::collections::HashSet;
51
52use crate::SymbolTable;
53
54#[derive(Clone, Debug, PartialEq, Eq)]
56pub enum ChangeKind {
57 DomainAdded,
59 DomainRemoved,
61 DomainCardinalityIncreased,
63 DomainCardinalityDecreased,
65 PredicateAdded,
67 PredicateRemoved,
69 PredicateArityChanged,
71 PredicateSignatureChanged,
73 VariableAdded,
75 VariableRemoved,
77 VariableTypeChanged,
79}
80
81impl ChangeKind {
82 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#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
99pub enum ChangeImpact {
100 None,
102 Minor,
104 Moderate,
106 Major,
108 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#[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#[derive(Clone, Debug, PartialEq, Eq)]
156pub enum VersionBump {
157 None,
159 Patch,
161 Minor,
163 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#[derive(Clone, Debug)]
180pub enum MigrationStep {
181 AddDomain(String, usize),
183 RemoveDomain(String),
185 ResizeDomain(String, usize),
187 AddPredicate(String, Vec<String>),
189 RemovePredicate(String),
191 RenamePredicate(String, String),
193 AddVariable(String, String),
195 RemoveVariable(String),
197 UpdateVariableType(String, String),
199 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#[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#[derive(Clone, Debug)]
284pub struct CompatibilityReport {
285 pub breaking_changes: Vec<BreakingChange>,
287 pub backward_compatible_changes: Vec<String>,
289 pub forward_compatible_changes: Vec<String>,
291 pub deprecations: Vec<String>,
293 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
341pub 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 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 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 for added in new_domains.difference(&old_domains) {
389 report
390 .backward_compatible_changes
391 .push(format!("Domain '{}' was added", added));
392 }
393
394 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 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 for added in new_predicates.difference(&old_predicates) {
442 report
443 .backward_compatible_changes
444 .push(format!("Predicate '{}' was added", added));
445 }
446
447 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 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 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 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 for added in new_variables.difference(&old_variables) {
505 report
506 .backward_compatible_changes
507 .push(format!("Variable '{}' was added", added));
508 }
509
510 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 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 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 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}