Skip to main content

tensorlogic_adapters/
schema_migration.rs

1//! Schema migration detection and planning for SymbolTable evolution.
2//!
3//! This module compares two [`SymbolTable`] versions, detects structural changes,
4//! and produces a structured [`SchemaMigrationPlan`] with actionable steps.
5//!
6//! # Overview
7//!
8//! The migration engine:
9//! 1. Snapshots both old and new schemas via [`SchemaSnapshot`]
10//! 2. Diffs predicates, domains, and variable bindings
11//! 3. Optionally detects renames using Dice-bigram similarity
12//! 4. Classifies each [`SchemaChange`] by [`ChangeSeverity`]
13//! 5. Generates ordered [`SchemaMigrationStep`]s
14//!
15//! # Example
16//!
17//! ```rust
18//! use tensorlogic_adapters::{SymbolTable, DomainInfo, PredicateInfo};
19//! use tensorlogic_adapters::schema_migration::{
20//!     compute_migration, MigrationConfig, SchemaChange,
21//! };
22//!
23//! let mut old = SymbolTable::new();
24//! old.add_domain(DomainInfo::new("Person", 100)).unwrap();
25//! let mut new_schema = SymbolTable::new();
26//! new_schema.add_domain(DomainInfo::new("Person", 100)).unwrap();
27//! new_schema.add_domain(DomainInfo::new("Animal", 50)).unwrap();
28//!
29//! let config = MigrationConfig::default();
30//! let plan = compute_migration(&old, &new_schema, &config).unwrap();
31//! assert!(!plan.is_empty());
32//! ```
33
34use std::collections::{HashMap, HashSet};
35
36use crate::SymbolTable;
37
38// ─────────────────────────────────────────────────────────────────────────────
39// SchemaChange
40// ─────────────────────────────────────────────────────────────────────────────
41
42/// A single structural change detected between two schema versions.
43#[derive(Debug, Clone, PartialEq)]
44pub enum SchemaChange {
45    /// A predicate was added in the new schema.
46    PredicateAdded { name: String, arity: usize },
47    /// A predicate was removed from the old schema.
48    PredicateRemoved { name: String, arity: usize },
49    /// A predicate exists in both versions but with a different arity.
50    PredicateArityChanged {
51        name: String,
52        old_arity: usize,
53        new_arity: usize,
54    },
55    /// A domain was added in the new schema.
56    DomainAdded { name: String },
57    /// A domain was removed from the old schema.
58    DomainRemoved { name: String },
59    /// A variable binding (rule entry) was added in the new schema.
60    RuleAdded { name: String },
61    /// A variable binding (rule entry) was removed from the old schema.
62    RuleRemoved { name: String },
63    /// A predicate was renamed (same arity, high name similarity).
64    PredicateRenamed { old_name: String, new_name: String },
65}
66
67impl SchemaChange {
68    /// Returns `true` when this change is considered breaking:
69    /// removals and arity changes break existing consumers.
70    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    /// Human-readable description of the change.
81    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// ─────────────────────────────────────────────────────────────────────────────
111// ChangeSeverity
112// ─────────────────────────────────────────────────────────────────────────────
113
114/// Severity classification for a schema change.
115#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
116pub enum ChangeSeverity {
117    /// Additive change — new predicate, domain, or rule.
118    Info,
119    /// Potentially risky change — rename or structural shift.
120    Warning,
121    /// Breaking change — removal or arity change.
122    Breaking,
123}
124
125impl ChangeSeverity {
126    /// Derive the severity from a [`SchemaChange`].
127    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// ─────────────────────────────────────────────────────────────────────────────
140// SchemaMigrationStep
141// ─────────────────────────────────────────────────────────────────────────────
142
143/// A concrete migration step to transform the old schema into the new schema.
144#[derive(Debug, Clone)]
145pub enum SchemaMigrationStep {
146    /// Create a new predicate slot.
147    AddPredicate { name: String, arity: usize },
148    /// Drop an existing predicate slot.
149    RemovePredicate { name: String },
150    /// Rename a predicate (non-destructive when reverse is possible).
151    RenamePredicate { old_name: String, new_name: String },
152    /// Extend a predicate by inserting a new positional argument.
153    AddArityColumn {
154        predicate: String,
155        position: usize,
156        default_value: String,
157    },
158    /// Shrink a predicate by removing a positional argument.
159    RemoveArityColumn { predicate: String, position: usize },
160    /// Register a new domain.
161    AddDomain { name: String },
162    /// Unregister an existing domain.
163    RemoveDomain { name: String },
164    /// Bind a new variable/rule entry.
165    AddRule { name: String },
166    /// Remove an existing variable/rule entry.
167    RemoveRule { name: String },
168}
169
170impl SchemaMigrationStep {
171    /// Human-readable description of the step.
172    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    /// Returns `true` when this step is destructive (data may be lost).
218    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// ─────────────────────────────────────────────────────────────────────────────
230// SchemaMigrationPlan
231// ─────────────────────────────────────────────────────────────────────────────
232
233/// The full migration plan produced by [`compute_migration`].
234#[derive(Debug, Clone)]
235pub struct SchemaMigrationPlan {
236    /// All detected changes between old and new schema.
237    pub changes: Vec<SchemaChange>,
238    /// Ordered list of steps to apply to reach the new schema.
239    pub steps: Vec<SchemaMigrationStep>,
240    /// `true` when at least one change is breaking.
241    pub has_breaking_changes: bool,
242    /// Count of breaking changes.
243    pub breaking_count: usize,
244    /// Count of warning-level changes.
245    pub warning_count: usize,
246    /// Count of info-level changes.
247    pub info_count: usize,
248}
249
250impl SchemaMigrationPlan {
251    /// Returns `true` when no changes were detected.
252    pub fn is_empty(&self) -> bool {
253        self.changes.is_empty()
254    }
255
256    /// Total number of detected changes.
257    pub fn num_changes(&self) -> usize {
258        self.changes.len()
259    }
260
261    /// Returns references to all breaking changes.
262    pub fn breaking_changes(&self) -> Vec<&SchemaChange> {
263        self.changes.iter().filter(|c| c.is_breaking()).collect()
264    }
265
266    /// Produces a multi-line human-readable summary report.
267    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    /// Produces a multi-line list of migration steps.
290    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// ─────────────────────────────────────────────────────────────────────────────
315// MigrationError
316// ─────────────────────────────────────────────────────────────────────────────
317
318/// Error type for migration computation and validation.
319#[derive(Debug, Clone)]
320pub enum MigrationError {
321    /// Two or more changes conflict with each other.
322    ConflictingChanges(String),
323    /// Multiple rename candidates found for a single removed predicate.
324    AmbiguousRename { candidates: Vec<String> },
325    /// The schema itself is malformed.
326    InvalidSchema(String),
327    /// Breaking changes were detected but `allow_breaking_changes` is `false`.
328    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// ─────────────────────────────────────────────────────────────────────────────
357// MigrationConfig
358// ─────────────────────────────────────────────────────────────────────────────
359
360/// Configuration for the migration engine.
361#[derive(Debug, Clone)]
362pub struct MigrationConfig {
363    /// Attempt to detect predicate renames (same arity, similar name).
364    pub detect_renames: bool,
365    /// Minimum Dice-bigram similarity score `[0.0, 1.0]` to consider a rename.
366    pub rename_similarity_threshold: f64,
367    /// When `false`, [`compute_migration`] returns an error if breaking changes exist.
368    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// ─────────────────────────────────────────────────────────────────────────────
382// SchemaSnapshot
383// ─────────────────────────────────────────────────────────────────────────────
384
385/// A lightweight snapshot of a [`SymbolTable`] for comparison purposes.
386#[derive(Debug, Clone)]
387pub struct SchemaSnapshot {
388    /// Ordered list of predicate names.
389    pub predicate_names: Vec<String>,
390    /// Ordered list of domain names.
391    pub domain_names: Vec<String>,
392    /// Ordered list of variable/rule names.
393    pub rule_names: Vec<String>,
394    /// Predicate name → arity mapping.
395    pub predicate_arities: HashMap<String, usize>,
396}
397
398impl SchemaSnapshot {
399    /// Build a snapshot from a live [`SymbolTable`].
400    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    /// Number of predicates in the snapshot.
419    pub fn predicate_count(&self) -> usize {
420        self.predicate_names.len()
421    }
422
423    /// Number of domains in the snapshot.
424    pub fn domain_count(&self) -> usize {
425        self.domain_names.len()
426    }
427}
428
429// ─────────────────────────────────────────────────────────────────────────────
430// string_similarity
431// ─────────────────────────────────────────────────────────────────────────────
432
433/// Compute the Dice coefficient on character bigrams of two strings.
434///
435/// Returns a value in `[0.0, 1.0]`, where `1.0` means identical strings.
436/// Empty strings return `0.0`.
437pub 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        // Both have fewer than 2 chars; fall back to char equality
446        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
458/// Collect all character bigrams from a string as `(char, char)` pairs.
459fn 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
467/// Count how many bigrams from `a` appear in `b`, respecting multiplicity.
468fn count_common_bigrams(a: &[(char, char)], b: &[(char, char)]) -> usize {
469    // Build a frequency map for b
470    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
488// ─────────────────────────────────────────────────────────────────────────────
489// compute_migration
490// ─────────────────────────────────────────────────────────────────────────────
491
492/// Compute the full migration plan needed to go from `old_schema` to `new_schema`.
493///
494/// Steps:
495/// 1. Build snapshots of both schemas.
496/// 2. Detect predicate additions, removals, and arity changes.
497/// 3. Detect domain additions and removals.
498/// 4. Detect variable/rule additions and removals.
499/// 5. Optionally resolve renames by similarity.
500/// 6. Synthesise `SchemaMigrationStep`s.
501/// 7. Enforce `allow_breaking_changes` policy.
502pub 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    // ── Predicates ──────────────────────────────────────────────────────────
513
514    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    // Arity changes: present in both but arity differs
518    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    // Candidates for removal and addition
531    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    // Rename detection
543    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    // Remaining removals and additions
555    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    // ── Domains ─────────────────────────────────────────────────────────────
571
572    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    // ── Variables / Rules ────────────────────────────────────────────────────
594
595    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    // ── Severity counts ──────────────────────────────────────────────────────
617
618    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    // ── Build steps ──────────────────────────────────────────────────────────
637
638    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
656/// Detect renames among removed/added predicates by Dice-bigram similarity.
657/// Matched pairs are emitted as [`SchemaChange::PredicateRenamed`] and removed
658/// from the `removed` / `added` vectors.
659fn 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    // Keep track of which names have been consumed
668    let mut consumed_removed: HashSet<String> = HashSet::new();
669    let mut consumed_added: HashSet<String> = HashSet::new();
670
671    // For each removed predicate, find all added candidates with the same arity
672    // and similarity >= threshold.
673    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        // Sort descending by similarity for deterministic selection
695        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        // Check for ambiguous renames (multiple candidates with equal top score)
702        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    // Remove consumed entries
725    removed.retain(|n| !consumed_removed.contains(n));
726    added.retain(|n| !consumed_added.contains(n));
727
728    Ok(())
729}
730
731/// Translate detected [`SchemaChange`]s into ordered [`SchemaMigrationStep`]s.
732fn 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                    // Columns were added at the end
759                    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                    // Columns were removed from the end
768                    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
800// ─────────────────────────────────────────────────────────────────────────────
801// validate_plan
802// ─────────────────────────────────────────────────────────────────────────────
803
804/// Validate that a migration plan is internally self-consistent.
805///
806/// Checks for:
807/// - Duplicate `AddPredicate` steps for the same name
808/// - Duplicate `RemovePredicate` steps for the same name
809/// - A predicate both added and removed in the same plan
810pub 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
840/// Returns an error when `name` is already present in `seen`.
841fn 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
856/// Returns an error when `name` already exists in the opposing set (added vs removed).
857fn 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// ─────────────────────────────────────────────────────────────────────────────
872// Tests
873// ─────────────────────────────────────────────────────────────────────────────
874
875#[cfg(test)]
876mod tests {
877    use super::*;
878    use crate::{DomainInfo, PredicateInfo, SymbolTable};
879
880    // ── Helper ───────────────────────────────────────────────────────────────
881
882    /// Build a minimal SymbolTable with one domain "D" and the given predicates.
883    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    // ── string_similarity ────────────────────────────────────────────────────
895
896    #[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    // ── SchemaSnapshot ───────────────────────────────────────────────────────
927
928    #[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    // ── SchemaChange::is_breaking ────────────────────────────────────────────
944
945    #[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    // ── SchemaMigrationStep ──────────────────────────────────────────────────
974
975    #[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    // ── compute_migration ────────────────────────────────────────────────────
995
996    #[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        // Disable rename detection to guarantee we see a removal
1028        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        // Rebuild new table manually with changed arity
1044        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    // ── SchemaMigrationPlan ──────────────────────────────────────────────────
1077
1078    #[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    // ── validate_plan ────────────────────────────────────────────────────────
1120
1121    #[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    // ── MigrationConfig ──────────────────────────────────────────────────────
1135
1136    #[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    // ── MigrationError ───────────────────────────────────────────────────────
1146
1147    #[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}