mecha10-core 0.8.0

Mecha10 core runtime and foundational types
Documentation
//! Schema evolution and compatibility checking.

use super::{MessageSchema, SchemaField};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum CompatibilityMode {
    None,

    #[default]
    Backward,

    Forward,

    Full,

    Strict,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum SchemaEvolution {
    FieldAdded {
        name: String,
        field_type: String,
        required: bool,
        default_value: Option<serde_json::Value>,
    },

    FieldRemoved {
        name: String,
    },

    FieldMadeOptional {
        name: String,
    },

    FieldMadeRequired {
        name: String,
        default_value: serde_json::Value,
    },

    FieldTypeChanged {
        name: String,
        old_type: String,
        new_type: String,
    },

    FieldRenamed {
        old_name: String,
        new_name: String,
    },
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompatibilityCheck {
    pub compatible: bool,

    pub incompatibilities: Vec<String>,

    pub evolutions: Vec<SchemaEvolution>,

    pub warnings: Vec<String>,
}

impl CompatibilityCheck {
    /// Check if two schemas are compatible
    pub fn check(old: &MessageSchema, new: &MessageSchema, mode: CompatibilityMode) -> Self {
        let mut incompatibilities = Vec::new();
        let mut evolutions = Vec::new();
        let mut warnings = Vec::new();

        let old_fields: HashMap<String, &SchemaField> = old.fields.iter().map(|f| (f.name.clone(), f)).collect();
        let new_fields: HashMap<String, &SchemaField> = new.fields.iter().map(|f| (f.name.clone(), f)).collect();

        match mode {
            CompatibilityMode::None => {
                return Self {
                    compatible: true,
                    incompatibilities,
                    evolutions,
                    warnings,
                };
            }
            CompatibilityMode::Strict => {
                if old.fields.len() != new.fields.len() {
                    incompatibilities.push("Field count changed".to_string());
                }
                for (name, old_field) in &old_fields {
                    if let Some(new_field) = new_fields.get(name) {
                        if old_field.field_type != new_field.field_type {
                            incompatibilities.push(format!("Field '{}' type changed", name));
                        }
                        if old_field.required != new_field.required {
                            incompatibilities.push(format!("Field '{}' required status changed", name));
                        }
                    } else {
                        incompatibilities.push(format!("Field '{}' removed", name));
                    }
                }
                for name in new_fields.keys() {
                    if !old_fields.contains_key(name) {
                        incompatibilities.push(format!("Field '{}' added", name));
                    }
                }
            }
            CompatibilityMode::Backward => {
                for (name, old_field) in &old_fields {
                    if let Some(new_field) = new_fields.get(name) {
                        if old_field.field_type != new_field.field_type {
                            incompatibilities.push(format!(
                                "Field '{}' type changed from {} to {}",
                                name, old_field.field_type, new_field.field_type
                            ));
                            evolutions.push(SchemaEvolution::FieldTypeChanged {
                                name: name.clone(),
                                old_type: old_field.field_type.clone(),
                                new_type: new_field.field_type.clone(),
                            });
                        }

                        if old_field.required && !new_field.required {
                            evolutions.push(SchemaEvolution::FieldMadeOptional { name: name.clone() });
                        } else if !old_field.required && new_field.required {
                            incompatibilities.push(format!("Field '{}' made required (was optional)", name));
                            evolutions.push(SchemaEvolution::FieldMadeRequired {
                                name: name.clone(),
                                default_value: serde_json::Value::Null,
                            });
                        }
                    } else {
                        if old_field.required {
                            incompatibilities.push(format!("Required field '{}' removed", name));
                        } else {
                            warnings.push(format!("Optional field '{}' removed", name));
                        }
                        evolutions.push(SchemaEvolution::FieldRemoved { name: name.clone() });
                    }
                }

                for (name, new_field) in &new_fields {
                    if !old_fields.contains_key(name) {
                        if new_field.required {
                            incompatibilities.push(format!("Required field '{}' added", name));
                        } else {
                            warnings.push(format!("Optional field '{}' added", name));
                        }
                        evolutions.push(SchemaEvolution::FieldAdded {
                            name: name.clone(),
                            field_type: new_field.field_type.clone(),
                            required: new_field.required,
                            default_value: None,
                        });
                    }
                }
            }
            CompatibilityMode::Forward => {
                for (name, old_field) in &old_fields {
                    if let Some(new_field) = new_fields.get(name) {
                        if old_field.field_type != new_field.field_type {
                            incompatibilities.push(format!("Field '{}' type changed", name));
                            evolutions.push(SchemaEvolution::FieldTypeChanged {
                                name: name.clone(),
                                old_type: old_field.field_type.clone(),
                                new_type: new_field.field_type.clone(),
                            });
                        }
                    } else if old_field.required {
                        incompatibilities.push(format!("Required field '{}' removed", name));
                        evolutions.push(SchemaEvolution::FieldRemoved { name: name.clone() });
                    }
                }

                for (name, new_field) in &new_fields {
                    if !old_fields.contains_key(name) && new_field.required {
                        incompatibilities.push(format!("Required field '{}' added", name));
                        evolutions.push(SchemaEvolution::FieldAdded {
                            name: name.clone(),
                            field_type: new_field.field_type.clone(),
                            required: new_field.required,
                            default_value: None,
                        });
                    }
                }
            }
            CompatibilityMode::Full => {
                for (name, old_field) in &old_fields {
                    if let Some(new_field) = new_fields.get(name) {
                        if old_field.field_type != new_field.field_type {
                            incompatibilities.push(format!("Field '{}' type changed", name));
                        }
                        if old_field.required != new_field.required {
                            incompatibilities.push(format!("Field '{}' required status changed", name));
                        }
                    } else if old_field.required {
                        incompatibilities.push(format!("Required field '{}' removed", name));
                    }
                }

                for (name, new_field) in &new_fields {
                    if !old_fields.contains_key(name) && new_field.required {
                        incompatibilities.push(format!("Required field '{}' added", name));
                    }
                }
            }
        }

        Self {
            compatible: incompatibilities.is_empty(),
            incompatibilities,
            evolutions,
            warnings,
        }
    }
}