1use std::collections::{HashMap, HashSet};
35
36use crate::SymbolTable;
37
38#[derive(Debug, Clone, PartialEq)]
44pub enum SchemaChange {
45 PredicateAdded { name: String, arity: usize },
47 PredicateRemoved { name: String, arity: usize },
49 PredicateArityChanged {
51 name: String,
52 old_arity: usize,
53 new_arity: usize,
54 },
55 DomainAdded { name: String },
57 DomainRemoved { name: String },
59 RuleAdded { name: String },
61 RuleRemoved { name: String },
63 PredicateRenamed { old_name: String, new_name: String },
65}
66
67impl SchemaChange {
68 pub fn is_breaking(&self) -> bool {
71 matches!(
72 self,
73 SchemaChange::PredicateRemoved { .. }
74 | SchemaChange::PredicateArityChanged { .. }
75 | SchemaChange::DomainRemoved { .. }
76 | SchemaChange::RuleRemoved { .. }
77 )
78 }
79
80 pub fn description(&self) -> String {
82 match self {
83 SchemaChange::PredicateAdded { name, arity } => {
84 format!("Predicate '{}' added (arity {})", name, arity)
85 }
86 SchemaChange::PredicateRemoved { name, arity } => {
87 format!("Predicate '{}' removed (arity {})", name, arity)
88 }
89 SchemaChange::PredicateArityChanged {
90 name,
91 old_arity,
92 new_arity,
93 } => {
94 format!(
95 "Predicate '{}' arity changed from {} to {}",
96 name, old_arity, new_arity
97 )
98 }
99 SchemaChange::DomainAdded { name } => format!("Domain '{}' added", name),
100 SchemaChange::DomainRemoved { name } => format!("Domain '{}' removed", name),
101 SchemaChange::RuleAdded { name } => format!("Rule/variable '{}' added", name),
102 SchemaChange::RuleRemoved { name } => format!("Rule/variable '{}' removed", name),
103 SchemaChange::PredicateRenamed { old_name, new_name } => {
104 format!("Predicate '{}' renamed to '{}'", old_name, new_name)
105 }
106 }
107 }
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
116pub enum ChangeSeverity {
117 Info,
119 Warning,
121 Breaking,
123}
124
125impl ChangeSeverity {
126 pub fn from_change(change: &SchemaChange) -> Self {
128 if change.is_breaking() {
129 ChangeSeverity::Breaking
130 } else {
131 match change {
132 SchemaChange::PredicateRenamed { .. } => ChangeSeverity::Warning,
133 _ => ChangeSeverity::Info,
134 }
135 }
136 }
137}
138
139#[derive(Debug, Clone)]
145pub enum SchemaMigrationStep {
146 AddPredicate { name: String, arity: usize },
148 RemovePredicate { name: String },
150 RenamePredicate { old_name: String, new_name: String },
152 AddArityColumn {
154 predicate: String,
155 position: usize,
156 default_value: String,
157 },
158 RemoveArityColumn { predicate: String, position: usize },
160 AddDomain { name: String },
162 RemoveDomain { name: String },
164 AddRule { name: String },
166 RemoveRule { name: String },
168}
169
170impl SchemaMigrationStep {
171 pub fn description(&self) -> String {
173 match self {
174 SchemaMigrationStep::AddPredicate { name, arity } => {
175 format!("Add predicate '{}' with arity {}", name, arity)
176 }
177 SchemaMigrationStep::RemovePredicate { name } => {
178 format!("Remove predicate '{}'", name)
179 }
180 SchemaMigrationStep::RenamePredicate { old_name, new_name } => {
181 format!("Rename predicate '{}' → '{}'", old_name, new_name)
182 }
183 SchemaMigrationStep::AddArityColumn {
184 predicate,
185 position,
186 default_value,
187 } => {
188 format!(
189 "Add column at position {} to predicate '{}' (default: '{}')",
190 position, predicate, default_value
191 )
192 }
193 SchemaMigrationStep::RemoveArityColumn {
194 predicate,
195 position,
196 } => {
197 format!(
198 "Remove column at position {} from predicate '{}'",
199 position, predicate
200 )
201 }
202 SchemaMigrationStep::AddDomain { name } => {
203 format!("Add domain '{}'", name)
204 }
205 SchemaMigrationStep::RemoveDomain { name } => {
206 format!("Remove domain '{}'", name)
207 }
208 SchemaMigrationStep::AddRule { name } => {
209 format!("Add rule/variable '{}'", name)
210 }
211 SchemaMigrationStep::RemoveRule { name } => {
212 format!("Remove rule/variable '{}'", name)
213 }
214 }
215 }
216
217 pub fn is_destructive(&self) -> bool {
219 matches!(
220 self,
221 SchemaMigrationStep::RemovePredicate { .. }
222 | SchemaMigrationStep::RemoveArityColumn { .. }
223 | SchemaMigrationStep::RemoveDomain { .. }
224 | SchemaMigrationStep::RemoveRule { .. }
225 )
226 }
227}
228
229#[derive(Debug, Clone)]
235pub struct SchemaMigrationPlan {
236 pub changes: Vec<SchemaChange>,
238 pub steps: Vec<SchemaMigrationStep>,
240 pub has_breaking_changes: bool,
242 pub breaking_count: usize,
244 pub warning_count: usize,
246 pub info_count: usize,
248}
249
250impl SchemaMigrationPlan {
251 pub fn is_empty(&self) -> bool {
253 self.changes.is_empty()
254 }
255
256 pub fn num_changes(&self) -> usize {
258 self.changes.len()
259 }
260
261 pub fn breaking_changes(&self) -> Vec<&SchemaChange> {
263 self.changes.iter().filter(|c| c.is_breaking()).collect()
264 }
265
266 pub fn format_report(&self) -> String {
268 let mut out = String::new();
269 out.push_str("=== Schema Migration Report ===\n");
270 out.push_str(&format!("Total changes : {}\n", self.num_changes()));
271 out.push_str(&format!("Breaking : {}\n", self.breaking_count));
272 out.push_str(&format!("Warnings : {}\n", self.warning_count));
273 out.push_str(&format!("Info : {}\n", self.info_count));
274 if !self.changes.is_empty() {
275 out.push_str("\nChanges:\n");
276 for change in &self.changes {
277 let severity = ChangeSeverity::from_change(change);
278 let tag = match severity {
279 ChangeSeverity::Breaking => "[BREAKING]",
280 ChangeSeverity::Warning => "[WARNING] ",
281 ChangeSeverity::Info => "[INFO] ",
282 };
283 out.push_str(&format!(" {} {}\n", tag, change.description()));
284 }
285 }
286 out
287 }
288
289 pub fn format_steps(&self) -> String {
291 let mut out = String::new();
292 out.push_str("=== Migration Steps ===\n");
293 if self.steps.is_empty() {
294 out.push_str(" (no steps required)\n");
295 } else {
296 for (idx, step) in self.steps.iter().enumerate() {
297 let destructive = if step.is_destructive() {
298 " [DESTRUCTIVE]"
299 } else {
300 ""
301 };
302 out.push_str(&format!(
303 " {:>3}. {}{}\n",
304 idx + 1,
305 step.description(),
306 destructive
307 ));
308 }
309 }
310 out
311 }
312}
313
314#[derive(Debug, Clone)]
320pub enum MigrationError {
321 ConflictingChanges(String),
323 AmbiguousRename { candidates: Vec<String> },
325 InvalidSchema(String),
327 BreakingChangesNotAllowed { count: usize },
329}
330
331impl std::fmt::Display for MigrationError {
332 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
333 match self {
334 MigrationError::ConflictingChanges(msg) => {
335 write!(f, "Conflicting migration changes: {}", msg)
336 }
337 MigrationError::AmbiguousRename { candidates } => {
338 write!(f, "Ambiguous rename: multiple candidates {:?}", candidates)
339 }
340 MigrationError::InvalidSchema(msg) => {
341 write!(f, "Invalid schema: {}", msg)
342 }
343 MigrationError::BreakingChangesNotAllowed { count } => {
344 write!(
345 f,
346 "Migration contains {} breaking change(s) but allow_breaking_changes is false",
347 count
348 )
349 }
350 }
351 }
352}
353
354impl std::error::Error for MigrationError {}
355
356#[derive(Debug, Clone)]
362pub struct MigrationConfig {
363 pub detect_renames: bool,
365 pub rename_similarity_threshold: f64,
367 pub allow_breaking_changes: bool,
369}
370
371impl Default for MigrationConfig {
372 fn default() -> Self {
373 Self {
374 detect_renames: true,
375 rename_similarity_threshold: 0.7,
376 allow_breaking_changes: true,
377 }
378 }
379}
380
381#[derive(Debug, Clone)]
387pub struct SchemaSnapshot {
388 pub predicate_names: Vec<String>,
390 pub domain_names: Vec<String>,
392 pub rule_names: Vec<String>,
394 pub predicate_arities: HashMap<String, usize>,
396}
397
398impl SchemaSnapshot {
399 pub fn from_symbol_table(table: &SymbolTable) -> Self {
401 let predicate_names: Vec<String> = table.predicates.keys().cloned().collect();
402 let domain_names: Vec<String> = table.domains.keys().cloned().collect();
403 let rule_names: Vec<String> = table.variables.keys().cloned().collect();
404 let predicate_arities: HashMap<String, usize> = table
405 .predicates
406 .iter()
407 .map(|(name, info)| (name.clone(), info.arity))
408 .collect();
409
410 Self {
411 predicate_names,
412 domain_names,
413 rule_names,
414 predicate_arities,
415 }
416 }
417
418 pub fn predicate_count(&self) -> usize {
420 self.predicate_names.len()
421 }
422
423 pub fn domain_count(&self) -> usize {
425 self.domain_names.len()
426 }
427}
428
429pub fn string_similarity(a: &str, b: &str) -> f64 {
438 if a == b {
439 return 1.0;
440 }
441 let bigrams_a = collect_bigrams(a);
442 let bigrams_b = collect_bigrams(b);
443
444 if bigrams_a.is_empty() && bigrams_b.is_empty() {
445 return if a == b { 1.0 } else { 0.0 };
447 }
448 if bigrams_a.is_empty() || bigrams_b.is_empty() {
449 return 0.0;
450 }
451
452 let total = bigrams_a.len() + bigrams_b.len();
453 let common = count_common_bigrams(&bigrams_a, &bigrams_b);
454
455 (2 * common) as f64 / total as f64
456}
457
458fn collect_bigrams(s: &str) -> Vec<(char, char)> {
460 let chars: Vec<char> = s.chars().collect();
461 if chars.len() < 2 {
462 return Vec::new();
463 }
464 chars.windows(2).map(|w| (w[0], w[1])).collect()
465}
466
467fn count_common_bigrams(a: &[(char, char)], b: &[(char, char)]) -> usize {
469 let mut freq: HashMap<(char, char), usize> = HashMap::new();
471 for &bigram in b {
472 *freq.entry(bigram).or_insert(0) += 1;
473 }
474
475 let mut common = 0usize;
476 let mut used: HashMap<(char, char), usize> = HashMap::new();
477 for &bigram in a {
478 let available = freq.get(&bigram).copied().unwrap_or(0);
479 let already_used = used.get(&bigram).copied().unwrap_or(0);
480 if already_used < available {
481 common += 1;
482 *used.entry(bigram).or_insert(0) += 1;
483 }
484 }
485 common
486}
487
488pub fn compute_migration(
503 old_schema: &SymbolTable,
504 new_schema: &SymbolTable,
505 config: &MigrationConfig,
506) -> Result<SchemaMigrationPlan, MigrationError> {
507 let old_snap = SchemaSnapshot::from_symbol_table(old_schema);
508 let new_snap = SchemaSnapshot::from_symbol_table(new_schema);
509
510 let mut changes: Vec<SchemaChange> = Vec::new();
511
512 let old_pred_set: HashSet<&String> = old_snap.predicate_names.iter().collect();
515 let new_pred_set: HashSet<&String> = new_snap.predicate_names.iter().collect();
516
517 for name in old_pred_set.intersection(&new_pred_set) {
519 let old_arity = old_snap.predicate_arities.get(*name).copied().unwrap_or(0);
520 let new_arity = new_snap.predicate_arities.get(*name).copied().unwrap_or(0);
521 if old_arity != new_arity {
522 changes.push(SchemaChange::PredicateArityChanged {
523 name: (*name).clone(),
524 old_arity,
525 new_arity,
526 });
527 }
528 }
529
530 let mut removed_preds: Vec<String> = old_pred_set
532 .difference(&new_pred_set)
533 .map(|s| (*s).clone())
534 .collect();
535 let mut added_preds: Vec<String> = new_pred_set
536 .difference(&old_pred_set)
537 .map(|s| (*s).clone())
538 .collect();
539 removed_preds.sort();
540 added_preds.sort();
541
542 if config.detect_renames {
544 detect_predicate_renames(
545 &mut removed_preds,
546 &mut added_preds,
547 &old_snap.predicate_arities,
548 &new_snap.predicate_arities,
549 config.rename_similarity_threshold,
550 &mut changes,
551 )?;
552 }
553
554 for name in &removed_preds {
556 let arity = old_snap.predicate_arities.get(name).copied().unwrap_or(0);
557 changes.push(SchemaChange::PredicateRemoved {
558 name: name.clone(),
559 arity,
560 });
561 }
562 for name in &added_preds {
563 let arity = new_snap.predicate_arities.get(name).copied().unwrap_or(0);
564 changes.push(SchemaChange::PredicateAdded {
565 name: name.clone(),
566 arity,
567 });
568 }
569
570 let old_domain_set: HashSet<&String> = old_snap.domain_names.iter().collect();
573 let new_domain_set: HashSet<&String> = new_snap.domain_names.iter().collect();
574
575 let mut removed_domains: Vec<String> = old_domain_set
576 .difference(&new_domain_set)
577 .map(|s| (*s).clone())
578 .collect();
579 let mut added_domains: Vec<String> = new_domain_set
580 .difference(&old_domain_set)
581 .map(|s| (*s).clone())
582 .collect();
583 removed_domains.sort();
584 added_domains.sort();
585
586 for name in &removed_domains {
587 changes.push(SchemaChange::DomainRemoved { name: name.clone() });
588 }
589 for name in &added_domains {
590 changes.push(SchemaChange::DomainAdded { name: name.clone() });
591 }
592
593 let old_rule_set: HashSet<&String> = old_snap.rule_names.iter().collect();
596 let new_rule_set: HashSet<&String> = new_snap.rule_names.iter().collect();
597
598 let mut removed_rules: Vec<String> = old_rule_set
599 .difference(&new_rule_set)
600 .map(|s| (*s).clone())
601 .collect();
602 let mut added_rules: Vec<String> = new_rule_set
603 .difference(&old_rule_set)
604 .map(|s| (*s).clone())
605 .collect();
606 removed_rules.sort();
607 added_rules.sort();
608
609 for name in &removed_rules {
610 changes.push(SchemaChange::RuleRemoved { name: name.clone() });
611 }
612 for name in &added_rules {
613 changes.push(SchemaChange::RuleAdded { name: name.clone() });
614 }
615
616 let mut breaking_count = 0usize;
619 let mut warning_count = 0usize;
620 let mut info_count = 0usize;
621 for change in &changes {
622 match ChangeSeverity::from_change(change) {
623 ChangeSeverity::Breaking => breaking_count += 1,
624 ChangeSeverity::Warning => warning_count += 1,
625 ChangeSeverity::Info => info_count += 1,
626 }
627 }
628 let has_breaking_changes = breaking_count > 0;
629
630 if !config.allow_breaking_changes && has_breaking_changes {
631 return Err(MigrationError::BreakingChangesNotAllowed {
632 count: breaking_count,
633 });
634 }
635
636 let steps = build_migration_steps(
639 &changes,
640 &old_snap.predicate_arities,
641 &new_snap.predicate_arities,
642 );
643
644 let plan = SchemaMigrationPlan {
645 changes,
646 steps,
647 has_breaking_changes,
648 breaking_count,
649 warning_count,
650 info_count,
651 };
652
653 Ok(plan)
654}
655
656fn detect_predicate_renames(
660 removed: &mut Vec<String>,
661 added: &mut Vec<String>,
662 old_arities: &HashMap<String, usize>,
663 new_arities: &HashMap<String, usize>,
664 threshold: f64,
665 changes: &mut Vec<SchemaChange>,
666) -> Result<(), MigrationError> {
667 let mut consumed_removed: HashSet<String> = HashSet::new();
669 let mut consumed_added: HashSet<String> = HashSet::new();
670
671 for old_name in removed.iter() {
674 let old_arity = old_arities.get(old_name).copied().unwrap_or(0);
675
676 let mut candidates: Vec<(String, f64)> = added
677 .iter()
678 .filter(|new_name| !consumed_added.contains(*new_name))
679 .filter(|new_name| new_arities.get(*new_name).copied().unwrap_or(0) == old_arity)
680 .filter_map(|new_name| {
681 let sim = string_similarity(old_name, new_name);
682 if sim >= threshold {
683 Some((new_name.clone(), sim))
684 } else {
685 None
686 }
687 })
688 .collect();
689
690 if candidates.is_empty() {
691 continue;
692 }
693
694 candidates.sort_by(|a, b| {
696 b.1.partial_cmp(&a.1)
697 .unwrap_or(std::cmp::Ordering::Equal)
698 .then_with(|| a.0.cmp(&b.0))
699 });
700
701 let top_score = candidates[0].1;
703 let top_candidates: Vec<String> = candidates
704 .iter()
705 .filter(|(_, s)| (s - top_score).abs() < f64::EPSILON)
706 .map(|(n, _)| n.clone())
707 .collect();
708
709 if top_candidates.len() > 1 {
710 return Err(MigrationError::AmbiguousRename {
711 candidates: top_candidates,
712 });
713 }
714
715 let new_name = candidates[0].0.clone();
716 changes.push(SchemaChange::PredicateRenamed {
717 old_name: old_name.clone(),
718 new_name: new_name.clone(),
719 });
720 consumed_removed.insert(old_name.clone());
721 consumed_added.insert(new_name);
722 }
723
724 removed.retain(|n| !consumed_removed.contains(n));
726 added.retain(|n| !consumed_added.contains(n));
727
728 Ok(())
729}
730
731fn build_migration_steps(
733 changes: &[SchemaChange],
734 old_arities: &HashMap<String, usize>,
735 new_arities: &HashMap<String, usize>,
736) -> Vec<SchemaMigrationStep> {
737 let mut steps: Vec<SchemaMigrationStep> = Vec::new();
738
739 for change in changes {
740 match change {
741 SchemaChange::PredicateAdded { name, arity } => {
742 steps.push(SchemaMigrationStep::AddPredicate {
743 name: name.clone(),
744 arity: *arity,
745 });
746 }
747 SchemaChange::PredicateRemoved { name, .. } => {
748 steps.push(SchemaMigrationStep::RemovePredicate { name: name.clone() });
749 }
750 SchemaChange::PredicateArityChanged {
751 name,
752 old_arity,
753 new_arity,
754 } => {
755 let old_a = old_arities.get(name).copied().unwrap_or(*old_arity);
756 let new_a = new_arities.get(name).copied().unwrap_or(*new_arity);
757 if new_a > old_a {
758 for pos in old_a..new_a {
760 steps.push(SchemaMigrationStep::AddArityColumn {
761 predicate: name.clone(),
762 position: pos,
763 default_value: "NULL".to_string(),
764 });
765 }
766 } else {
767 for pos in (new_a..old_a).rev() {
769 steps.push(SchemaMigrationStep::RemoveArityColumn {
770 predicate: name.clone(),
771 position: pos,
772 });
773 }
774 }
775 }
776 SchemaChange::DomainAdded { name } => {
777 steps.push(SchemaMigrationStep::AddDomain { name: name.clone() });
778 }
779 SchemaChange::DomainRemoved { name } => {
780 steps.push(SchemaMigrationStep::RemoveDomain { name: name.clone() });
781 }
782 SchemaChange::RuleAdded { name } => {
783 steps.push(SchemaMigrationStep::AddRule { name: name.clone() });
784 }
785 SchemaChange::RuleRemoved { name } => {
786 steps.push(SchemaMigrationStep::RemoveRule { name: name.clone() });
787 }
788 SchemaChange::PredicateRenamed { old_name, new_name } => {
789 steps.push(SchemaMigrationStep::RenamePredicate {
790 old_name: old_name.clone(),
791 new_name: new_name.clone(),
792 });
793 }
794 }
795 }
796
797 steps
798}
799
800pub fn validate_plan(plan: &SchemaMigrationPlan) -> Result<(), MigrationError> {
811 let mut added_predicates: HashSet<String> = HashSet::new();
812 let mut removed_predicates: HashSet<String> = HashSet::new();
813 let mut added_domains: HashSet<String> = HashSet::new();
814 let mut removed_domains: HashSet<String> = HashSet::new();
815
816 for step in &plan.steps {
817 match step {
818 SchemaMigrationStep::AddPredicate { name, .. } => {
819 check_not_duplicate(&mut added_predicates, name, "Predicate", "added")?;
820 check_not_conflict(&removed_predicates, name, "Predicate")?;
821 }
822 SchemaMigrationStep::RemovePredicate { name } => {
823 check_not_duplicate(&mut removed_predicates, name, "Predicate", "removed")?;
824 check_not_conflict(&added_predicates, name, "Predicate")?;
825 }
826 SchemaMigrationStep::AddDomain { name } => {
827 check_not_duplicate(&mut added_domains, name, "Domain", "added")?;
828 }
829 SchemaMigrationStep::RemoveDomain { name } => {
830 check_not_duplicate(&mut removed_domains, name, "Domain", "removed")?;
831 check_not_conflict(&added_domains, name, "Domain")?;
832 }
833 _ => {}
834 }
835 }
836
837 Ok(())
838}
839
840fn check_not_duplicate(
842 seen: &mut HashSet<String>,
843 name: &str,
844 kind: &str,
845 action: &str,
846) -> Result<(), MigrationError> {
847 if !seen.insert(name.to_string()) {
848 return Err(MigrationError::ConflictingChanges(format!(
849 "{} '{}' is {} more than once",
850 kind, name, action
851 )));
852 }
853 Ok(())
854}
855
856fn check_not_conflict(
858 opposing: &HashSet<String>,
859 name: &str,
860 kind: &str,
861) -> Result<(), MigrationError> {
862 if opposing.contains(name) {
863 return Err(MigrationError::ConflictingChanges(format!(
864 "{} '{}' is both added and removed",
865 kind, name
866 )));
867 }
868 Ok(())
869}
870
871#[cfg(test)]
876mod tests {
877 use super::*;
878 use crate::{DomainInfo, PredicateInfo, SymbolTable};
879
880 fn table_with_predicates(preds: &[(&str, usize)]) -> SymbolTable {
884 let mut t = SymbolTable::new();
885 t.add_domain(DomainInfo::new("D", 1)).expect("add domain D");
886 for &(name, arity) in preds {
887 let domains: Vec<String> = (0..arity).map(|_| "D".to_string()).collect();
888 t.add_predicate(PredicateInfo::new(name, domains))
889 .expect("add predicate");
890 }
891 t
892 }
893
894 #[test]
897 fn test_string_similarity_identical() {
898 let sim = string_similarity("foo", "foo");
899 assert!(
900 (sim - 1.0).abs() < f64::EPSILON,
901 "identical strings must have similarity 1.0, got {}",
902 sim
903 );
904 }
905
906 #[test]
907 fn test_string_similarity_different() {
908 let sim = string_similarity("abc", "xyz");
909 assert!(
910 sim < 0.5,
911 "completely different strings should have similarity < 0.5, got {}",
912 sim
913 );
914 }
915
916 #[test]
917 fn test_string_similarity_partial() {
918 let sim = string_similarity("predicate", "predicat");
919 assert!(
920 sim > 0.7,
921 "highly similar strings should exceed 0.7 similarity, got {}",
922 sim
923 );
924 }
925
926 #[test]
929 fn test_schema_snapshot_from_table() {
930 let mut t = SymbolTable::new();
931 t.add_domain(DomainInfo::new("Person", 100))
932 .expect("domain");
933 t.add_domain(DomainInfo::new("Animal", 50)).expect("domain");
934 let pred = PredicateInfo::new("knows", vec!["Person".to_string(), "Person".to_string()]);
935 t.add_predicate(pred).expect("predicate");
936
937 let snap = SchemaSnapshot::from_symbol_table(&t);
938 assert_eq!(snap.domain_count(), 2);
939 assert_eq!(snap.predicate_count(), 1);
940 assert_eq!(snap.predicate_arities["knows"], 2);
941 }
942
943 #[test]
946 fn test_schema_change_is_breaking_removal() {
947 let change = SchemaChange::PredicateRemoved {
948 name: "foo".to_string(),
949 arity: 1,
950 };
951 assert!(change.is_breaking());
952 }
953
954 #[test]
955 fn test_schema_change_is_breaking_arity() {
956 let change = SchemaChange::PredicateArityChanged {
957 name: "foo".to_string(),
958 old_arity: 1,
959 new_arity: 2,
960 };
961 assert!(change.is_breaking());
962 }
963
964 #[test]
965 fn test_schema_change_not_breaking_added() {
966 let change = SchemaChange::PredicateAdded {
967 name: "bar".to_string(),
968 arity: 2,
969 };
970 assert!(!change.is_breaking());
971 }
972
973 #[test]
976 fn test_migration_step_is_destructive() {
977 let step = SchemaMigrationStep::RemovePredicate {
978 name: "old_pred".to_string(),
979 };
980 assert!(step.is_destructive());
981 }
982
983 #[test]
984 fn test_migration_step_description_nonempty() {
985 let step = SchemaMigrationStep::AddPredicate {
986 name: "new_pred".to_string(),
987 arity: 3,
988 };
989 let desc = step.description();
990 assert!(!desc.is_empty(), "description must not be empty");
991 assert!(desc.contains("new_pred"));
992 }
993
994 #[test]
997 fn test_compute_migration_empty_schemas() {
998 let old = SymbolTable::new();
999 let new = SymbolTable::new();
1000 let config = MigrationConfig::default();
1001 let plan = compute_migration(&old, &new, &config).expect("migration");
1002 assert!(
1003 plan.is_empty(),
1004 "both empty schemas should yield empty plan"
1005 );
1006 }
1007
1008 #[test]
1009 fn test_compute_migration_add_predicate() {
1010 let old = table_with_predicates(&[]);
1011 let new = table_with_predicates(&[("likes", 2)]);
1012 let config = MigrationConfig::default();
1013 let plan = compute_migration(&old, &new, &config).expect("migration");
1014
1015 assert!(!plan.is_empty());
1016 let added = plan
1017 .changes
1018 .iter()
1019 .any(|c| matches!(c, SchemaChange::PredicateAdded { name, .. } if name == "likes"));
1020 assert!(added, "expected PredicateAdded for 'likes'");
1021 }
1022
1023 #[test]
1024 fn test_compute_migration_remove_predicate() {
1025 let old = table_with_predicates(&[("old_pred", 1)]);
1026 let new = table_with_predicates(&[]);
1027 let config = MigrationConfig {
1029 detect_renames: false,
1030 ..Default::default()
1031 };
1032 let plan = compute_migration(&old, &new, &config).expect("migration");
1033
1034 let removed = plan.changes.iter().any(
1035 |c| matches!(c, SchemaChange::PredicateRemoved { name, .. } if name == "old_pred"),
1036 );
1037 assert!(removed, "expected PredicateRemoved for 'old_pred'");
1038 }
1039
1040 #[test]
1041 fn test_compute_migration_arity_change() {
1042 let old = table_with_predicates(&[("pred_a", 1)]);
1043 let mut new = SymbolTable::new();
1045 new.add_domain(DomainInfo::new("D", 1)).expect("domain");
1046 new.add_predicate(PredicateInfo::new(
1047 "pred_a",
1048 vec!["D".to_string(), "D".to_string()],
1049 ))
1050 .expect("predicate");
1051
1052 let config = MigrationConfig::default();
1053 let plan = compute_migration(&old, &new, &config).expect("migration");
1054
1055 let arity_changed = plan.changes.iter().any(|c| {
1056 matches!(
1057 c,
1058 SchemaChange::PredicateArityChanged { name, old_arity: 1, new_arity: 2 }
1059 if name == "pred_a"
1060 )
1061 });
1062 assert!(arity_changed, "expected PredicateArityChanged for 'pred_a'");
1063 }
1064
1065 #[test]
1066 fn test_compute_migration_no_change() {
1067 let schema = table_with_predicates(&[("pred_x", 2)]);
1068 let config = MigrationConfig::default();
1069 let plan = compute_migration(&schema, &schema, &config).expect("migration");
1070 assert!(
1071 plan.is_empty(),
1072 "identical schemas must produce an empty plan"
1073 );
1074 }
1075
1076 #[test]
1079 fn test_migration_plan_has_breaking() {
1080 let old = table_with_predicates(&[("to_remove", 1)]);
1081 let new = table_with_predicates(&[]);
1082 let config = MigrationConfig {
1083 detect_renames: false,
1084 ..Default::default()
1085 };
1086 let plan = compute_migration(&old, &new, &config).expect("migration");
1087 assert!(plan.has_breaking_changes);
1088 assert!(plan.breaking_count > 0);
1089 }
1090
1091 #[test]
1092 fn test_migration_plan_format_report_nonempty() {
1093 let old = table_with_predicates(&[("p", 1)]);
1094 let new = table_with_predicates(&[("p", 2)]);
1095 let config = MigrationConfig::default();
1096 let plan = compute_migration(&old, &new, &config).expect("migration");
1097 let report = plan.format_report();
1098 assert!(
1099 !report.is_empty(),
1100 "format_report should return non-empty string"
1101 );
1102 assert!(report.contains("Migration Report"));
1103 }
1104
1105 #[test]
1106 fn test_migration_plan_format_steps_nonempty() {
1107 let old = table_with_predicates(&[("p", 1)]);
1108 let new = table_with_predicates(&[("q", 1)]);
1109 let config = MigrationConfig {
1110 detect_renames: false,
1111 ..Default::default()
1112 };
1113 let plan = compute_migration(&old, &new, &config).expect("migration");
1114 let steps_str = plan.format_steps();
1115 assert!(!steps_str.is_empty());
1116 assert!(steps_str.contains("Migration Steps"));
1117 }
1118
1119 #[test]
1122 fn test_validate_plan_empty_ok() {
1123 let plan = SchemaMigrationPlan {
1124 changes: Vec::new(),
1125 steps: Vec::new(),
1126 has_breaking_changes: false,
1127 breaking_count: 0,
1128 warning_count: 0,
1129 info_count: 0,
1130 };
1131 assert!(validate_plan(&plan).is_ok());
1132 }
1133
1134 #[test]
1137 fn test_migration_config_default() {
1138 let config = MigrationConfig::default();
1139 assert!(config.detect_renames);
1140 assert!(config.allow_breaking_changes);
1141 assert!(config.rename_similarity_threshold > 0.0);
1142 assert!(config.rename_similarity_threshold <= 1.0);
1143 }
1144
1145 #[test]
1148 fn test_migration_error_display() {
1149 let err = MigrationError::ConflictingChanges("test conflict".to_string());
1150 let msg = format!("{}", err);
1151 assert!(
1152 !msg.is_empty(),
1153 "error Display must produce non-empty string"
1154 );
1155 assert!(msg.contains("test conflict"));
1156
1157 let err2 = MigrationError::AmbiguousRename {
1158 candidates: vec!["a".to_string(), "b".to_string()],
1159 };
1160 let msg2 = format!("{}", err2);
1161 assert!(msg2.contains("Ambiguous"));
1162
1163 let err3 = MigrationError::InvalidSchema("bad schema".to_string());
1164 let msg3 = format!("{}", err3);
1165 assert!(msg3.contains("bad schema"));
1166 }
1167}