use panproto_core::check::classify::{
self, BreakingChange as PpBreaking, CompatReport as PpCompatReport,
NonBreakingChange as PpNonBreaking,
};
use panproto_core::check::diff;
use panproto_core::check::report::report_json;
use panproto_core::protocols::web_document::atproto;
use serde::{Deserialize, Serialize};
use crate::errors::CompatibilityError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompatibilityReport {
pub is_breaking: bool,
pub breaking_changes: Vec<Change>,
pub non_breaking_changes: Vec<Change>,
pub summary: String,
pub detail_json: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Change {
pub kind: String,
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MigrationGuidance {
pub removed_vertices: Vec<String>,
pub added_vertices: Vec<String>,
pub constraint_changes: Vec<ConstraintChange>,
pub notes: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConstraintChange {
pub kind: String,
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CrossCompatibilityImpact {
pub nsid: String,
pub is_affected: bool,
pub affected_changes: Vec<Change>,
}
pub fn check_compatibility(
from_schema: &serde_json::Value,
to_schema: &serde_json::Value,
) -> Result<CompatibilityReport, CompatibilityError> {
let protocol = atproto::protocol();
let old = atproto::parse_lexicon(from_schema)
.map_err(|e| CompatibilityError::ParseFrom(e.to_string()))?;
let new = atproto::parse_lexicon(to_schema)
.map_err(|e| CompatibilityError::ParseTo(e.to_string()))?;
let schema_diff = diff::diff(&old, &new);
let compat: PpCompatReport = classify::classify(&schema_diff, &protocol);
let detail_json = report_json(&compat);
let breaking_changes: Vec<Change> = compat
.breaking
.iter()
.map(|bc| Change {
kind: breaking_kind_name(bc),
description: format!("{bc:?}"),
})
.collect();
let non_breaking_changes: Vec<Change> = compat
.non_breaking
.iter()
.map(|nbc| Change {
kind: non_breaking_kind_name(nbc),
description: format!("{nbc:?}"),
})
.collect();
let summary = build_summary(&breaking_changes, &non_breaking_changes, compat.compatible);
Ok(CompatibilityReport {
is_breaking: !compat.compatible,
breaking_changes,
non_breaking_changes,
summary,
detail_json,
})
}
pub fn generate_migration_guidance(
from_schema: &serde_json::Value,
to_schema: &serde_json::Value,
) -> Result<MigrationGuidance, CompatibilityError> {
let protocol = atproto::protocol();
let old = atproto::parse_lexicon(from_schema)
.map_err(|e| CompatibilityError::ParseFrom(e.to_string()))?;
let new = atproto::parse_lexicon(to_schema)
.map_err(|e| CompatibilityError::ParseTo(e.to_string()))?;
let schema_diff = diff::diff(&old, &new);
let compat = classify::classify(&schema_diff, &protocol);
let mut removed_vertices = Vec::new();
let mut added_vertices = Vec::new();
let mut constraint_changes = Vec::new();
let mut notes = Vec::new();
for bc in &compat.breaking {
match bc {
PpBreaking::RemovedVertex { .. } => {
removed_vertices.push(format!("{bc:?}"));
}
PpBreaking::ConstraintTightened { .. } | PpBreaking::ConstraintAdded { .. } => {
constraint_changes.push(ConstraintChange {
kind: breaking_kind_name(bc),
description: format!("{bc:?}"),
});
}
_ => {
notes.push(format!("Breaking: {bc:?}"));
}
}
}
for nbc in &compat.non_breaking {
match nbc {
PpNonBreaking::AddedVertex { .. } => {
added_vertices.push(format!("{nbc:?}"));
}
PpNonBreaking::ConstraintRelaxed { .. } | PpNonBreaking::ConstraintRemoved { .. } => {
constraint_changes.push(ConstraintChange {
kind: non_breaking_kind_name(nbc),
description: format!("{nbc:?}"),
});
}
_ => {
notes.push(format!("Non-breaking: {nbc:?}"));
}
}
}
Ok(MigrationGuidance {
removed_vertices,
added_vertices,
constraint_changes,
notes,
})
}
pub fn check_cross_compatibility(
from_schema: &serde_json::Value,
to_schema: &serde_json::Value,
changed_nsid: &str,
dependent_schemas: &[(String, serde_json::Value)],
) -> Result<Vec<CrossCompatibilityImpact>, CompatibilityError> {
let report = check_compatibility(from_schema, to_schema)?;
if !report.is_breaking {
return Ok(dependent_schemas
.iter()
.map(|(nsid, _)| CrossCompatibilityImpact {
nsid: nsid.clone(),
is_affected: false,
affected_changes: Vec::new(),
})
.collect());
}
let mut impacts = Vec::new();
for (dep_nsid, dep_schema) in dependent_schemas {
let dep_json = serde_json::to_string(dep_schema).unwrap_or_default();
let references_changed = dep_json.contains(changed_nsid);
if references_changed {
impacts.push(CrossCompatibilityImpact {
nsid: dep_nsid.clone(),
is_affected: true,
affected_changes: report.breaking_changes.clone(),
});
} else {
impacts.push(CrossCompatibilityImpact {
nsid: dep_nsid.clone(),
is_affected: false,
affected_changes: Vec::new(),
});
}
}
Ok(impacts)
}
fn breaking_kind_name(bc: &PpBreaking) -> String {
match bc {
PpBreaking::RemovedVertex { .. } => "RemovedVertex".to_string(),
PpBreaking::RemovedEdge { .. } => "RemovedEdge".to_string(),
PpBreaking::RemovedVariant { .. } => "RemovedVariant".to_string(),
PpBreaking::KindChanged { .. } => "KindChanged".to_string(),
PpBreaking::ConstraintTightened { .. } => "ConstraintTightened".to_string(),
PpBreaking::ConstraintAdded { .. } => "ConstraintAdded".to_string(),
PpBreaking::OrderToUnordered { .. } => "OrderToUnordered".to_string(),
PpBreaking::RecursionBroken { .. } => "RecursionBroken".to_string(),
PpBreaking::LinearityTightened { .. } => "LinearityTightened".to_string(),
_ => "Unknown".to_string(),
}
}
fn non_breaking_kind_name(nbc: &PpNonBreaking) -> String {
match nbc {
PpNonBreaking::AddedVertex { .. } => "AddedVertex".to_string(),
PpNonBreaking::AddedEdge { .. } => "AddedEdge".to_string(),
PpNonBreaking::ConstraintRelaxed { .. } => "ConstraintRelaxed".to_string(),
PpNonBreaking::ConstraintRemoved { .. } => "ConstraintRemoved".to_string(),
PpNonBreaking::RemovedEdge { .. } => "RemovedEdge".to_string(),
_ => "Unknown".to_string(),
}
}
fn build_summary(breaking: &[Change], non_breaking: &[Change], compatible: bool) -> String {
if compatible {
if non_breaking.is_empty() {
"No changes detected.".to_string()
} else {
format!(
"Backward compatible. {} non-breaking change{}.",
non_breaking.len(),
if non_breaking.len() == 1 { "" } else { "s" }
)
}
} else {
format!(
"{} breaking change{}, {} non-breaking change{}.",
breaking.len(),
if breaking.len() == 1 { "" } else { "s" },
non_breaking.len(),
if non_breaking.len() == 1 { "" } else { "s" }
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_record_schema() -> serde_json::Value {
serde_json::json!({
"lexicon": 1,
"id": "com.example.test",
"defs": {
"main": {
"type": "record",
"key": "tid",
"record": {
"type": "object",
"required": ["text", "createdAt"],
"properties": {
"text": { "type": "string", "maxLength": 300 },
"createdAt": { "type": "string", "format": "datetime" }
}
}
}
}
})
}
fn sample_record_schema_added_field() -> serde_json::Value {
serde_json::json!({
"lexicon": 1,
"id": "com.example.test",
"defs": {
"main": {
"type": "record",
"key": "tid",
"record": {
"type": "object",
"required": ["text", "createdAt"],
"properties": {
"text": { "type": "string", "maxLength": 300 },
"createdAt": { "type": "string", "format": "datetime" },
"lang": { "type": "string", "format": "language" }
}
}
}
}
})
}
fn sample_record_schema_removed_field() -> serde_json::Value {
serde_json::json!({
"lexicon": 1,
"id": "com.example.test",
"defs": {
"main": {
"type": "record",
"key": "tid",
"record": {
"type": "object",
"required": ["text"],
"properties": {
"text": { "type": "string", "maxLength": 300 }
}
}
}
}
})
}
#[test]
fn test_identical_schemas_are_compatible() {
let schema = sample_record_schema();
let report = check_compatibility(&schema, &schema).unwrap();
assert!(!report.is_breaking);
assert!(report.breaking_changes.is_empty());
}
#[test]
fn test_added_optional_field_is_non_breaking() {
let from = sample_record_schema();
let to = sample_record_schema_added_field();
let report = check_compatibility(&from, &to).unwrap();
assert!(!report.is_breaking);
assert!(!report.non_breaking_changes.is_empty());
}
#[test]
fn test_removed_required_field_is_breaking() {
let from = sample_record_schema();
let to = sample_record_schema_removed_field();
let report = check_compatibility(&from, &to).unwrap();
assert!(report.is_breaking);
assert!(!report.breaking_changes.is_empty());
}
#[test]
fn test_migration_guidance_for_removed_field() {
let from = sample_record_schema();
let to = sample_record_schema_removed_field();
let guidance = generate_migration_guidance(&from, &to).unwrap();
assert!(
!guidance.removed_vertices.is_empty() || !guidance.notes.is_empty(),
"migration guidance should note removed elements"
);
}
}