use panproto_schema::Protocol;
use serde::{Deserialize, Serialize};
use crate::diff::{ConstraintChange, SchemaDiff};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CompatReport {
pub breaking: Vec<BreakingChange>,
pub non_breaking: Vec<NonBreakingChange>,
pub compatible: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum BreakingChange {
RemovedVertex {
vertex_id: String,
},
RemovedEdge {
src: String,
tgt: String,
kind: String,
name: Option<String>,
},
KindChanged {
vertex_id: String,
old_kind: String,
new_kind: String,
},
ConstraintTightened {
vertex_id: String,
sort: String,
old_value: String,
new_value: String,
},
ConstraintAdded {
vertex_id: String,
sort: String,
value: String,
},
RemovedVariant {
vertex_id: String,
variant_id: String,
},
OrderToUnordered {
edge: panproto_schema::Edge,
},
RecursionBroken {
mu_id: String,
},
LinearityTightened {
edge: panproto_schema::Edge,
old_mode: panproto_schema::UsageMode,
new_mode: panproto_schema::UsageMode,
},
CoercionClassDowngraded {
from_kind: String,
to_kind: String,
old_class: String,
new_class: String,
},
CoercionRemoved {
from_kind: String,
to_kind: String,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum NonBreakingChange {
AddedVertex {
vertex_id: String,
},
AddedEdge {
src: String,
tgt: String,
kind: String,
name: Option<String>,
},
ConstraintRelaxed {
vertex_id: String,
sort: String,
old_value: String,
new_value: String,
},
ConstraintRemoved {
vertex_id: String,
sort: String,
},
RemovedEdge {
src: String,
tgt: String,
kind: String,
name: Option<String>,
},
}
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn classify(diff: &SchemaDiff, protocol: &Protocol) -> CompatReport {
let mut breaking = Vec::new();
let mut non_breaking = Vec::new();
for v in &diff.removed_vertices {
breaking.push(BreakingChange::RemovedVertex {
vertex_id: v.clone(),
});
}
for v in &diff.added_vertices {
non_breaking.push(NonBreakingChange::AddedVertex {
vertex_id: v.clone(),
});
}
for e in &diff.removed_edges {
if protocol.find_edge_rule(&e.kind).is_some() {
breaking.push(BreakingChange::RemovedEdge {
src: e.src.to_string(),
tgt: e.tgt.to_string(),
kind: e.kind.to_string(),
name: e.name.as_ref().map(ToString::to_string),
});
} else {
non_breaking.push(NonBreakingChange::RemovedEdge {
src: e.src.to_string(),
tgt: e.tgt.to_string(),
kind: e.kind.to_string(),
name: e.name.as_ref().map(ToString::to_string),
});
}
}
for e in &diff.added_edges {
non_breaking.push(NonBreakingChange::AddedEdge {
src: e.src.to_string(),
tgt: e.tgt.to_string(),
kind: e.kind.to_string(),
name: e.name.as_ref().map(ToString::to_string),
});
}
for kc in &diff.kind_changes {
breaking.push(BreakingChange::KindChanged {
vertex_id: kc.vertex_id.clone(),
old_kind: kc.old_kind.clone(),
new_kind: kc.new_kind.clone(),
});
}
for (vid, cdiff) in &diff.modified_constraints {
for c in &cdiff.added {
if protocol
.constraint_sorts
.iter()
.any(|s| s == c.sort.as_str())
{
breaking.push(BreakingChange::ConstraintAdded {
vertex_id: vid.clone(),
sort: c.sort.to_string(),
value: c.value.clone(),
});
}
}
for c in &cdiff.removed {
if protocol
.constraint_sorts
.iter()
.any(|s| s == c.sort.as_str())
{
non_breaking.push(NonBreakingChange::ConstraintRemoved {
vertex_id: vid.clone(),
sort: c.sort.to_string(),
});
}
}
for change in &cdiff.changed {
if protocol.constraint_sorts.iter().any(|s| s == &change.sort) {
classify_constraint_change(vid, change, &mut breaking, &mut non_breaking);
}
}
}
for v in &diff.removed_variants {
breaking.push(BreakingChange::RemovedVariant {
vertex_id: v.parent_vertex.to_string(),
variant_id: v.id.to_string(),
});
}
for (edge, old_pos, new_pos) in &diff.order_changes {
if old_pos.is_some() && new_pos.is_none() {
breaking.push(BreakingChange::OrderToUnordered { edge: edge.clone() });
}
}
for rp in &diff.removed_recursion_points {
breaking.push(BreakingChange::RecursionBroken {
mu_id: rp.mu_id.to_string(),
});
}
for (edge, old_mode, new_mode) in &diff.usage_mode_changes {
let is_tightened = matches!(
(old_mode, new_mode),
(
panproto_schema::UsageMode::Structural | panproto_schema::UsageMode::Affine,
panproto_schema::UsageMode::Linear
) | (
panproto_schema::UsageMode::Structural,
panproto_schema::UsageMode::Affine
)
);
if is_tightened {
breaking.push(BreakingChange::LinearityTightened {
edge: edge.clone(),
old_mode: old_mode.clone(),
new_mode: new_mode.clone(),
});
}
}
let compatible = breaking.is_empty();
CompatReport {
breaking,
non_breaking,
compatible,
}
}
#[must_use]
pub fn classify_with_schemas(
diff: &SchemaDiff,
protocol: &Protocol,
old_schema: &panproto_schema::Schema,
new_schema: &panproto_schema::Schema,
) -> CompatReport {
let mut report = classify(diff, protocol);
for (key, new_spec) in &new_schema.coercions {
if let Some(old_spec) = old_schema.coercions.get(key) {
if new_spec.class > old_spec.class {
report
.breaking
.push(BreakingChange::CoercionClassDowngraded {
from_kind: key.0.to_string(),
to_kind: key.1.to_string(),
old_class: format!("{:?}", old_spec.class),
new_class: format!("{:?}", new_spec.class),
});
}
}
}
for key in old_schema.coercions.keys() {
if !new_schema.coercions.contains_key(key) {
report.breaking.push(BreakingChange::CoercionRemoved {
from_kind: key.0.to_string(),
to_kind: key.1.to_string(),
});
}
}
report.compatible = report.breaking.is_empty();
report
}
fn classify_constraint_change(
vertex_id: &str,
change: &ConstraintChange,
breaking: &mut Vec<BreakingChange>,
non_breaking: &mut Vec<NonBreakingChange>,
) {
let is_tightened = is_constraint_tightened(&change.sort, &change.old_value, &change.new_value);
if is_tightened {
breaking.push(BreakingChange::ConstraintTightened {
vertex_id: vertex_id.to_string(),
sort: change.sort.clone(),
old_value: change.old_value.clone(),
new_value: change.new_value.clone(),
});
} else {
non_breaking.push(NonBreakingChange::ConstraintRelaxed {
vertex_id: vertex_id.to_string(),
sort: change.sort.clone(),
old_value: change.old_value.clone(),
new_value: change.new_value.clone(),
});
}
}
fn is_constraint_tightened(sort: &str, old_val: &str, new_val: &str) -> bool {
match sort {
"maxLength" | "maxSize" | "maximum" | "maxGraphemes" => {
let old_n: Result<i64, _> = old_val.parse();
let new_n: Result<i64, _> = new_val.parse();
if let (Ok(o), Ok(n)) = (old_n, new_n) {
return n < o;
}
true
}
"minLength" | "minimum" => {
let old_n: Result<i64, _> = old_val.parse();
let new_n: Result<i64, _> = new_val.parse();
if let (Ok(o), Ok(n)) = (old_n, new_n) {
return n > o;
}
true
}
_ => {
true
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::diff::{ConstraintDiff, KindChange};
use panproto_schema::{Edge, EdgeRule};
fn test_protocol() -> Protocol {
Protocol {
name: "test".into(),
schema_theory: "ThTest".into(),
instance_theory: "ThWType".into(),
edge_rules: vec![EdgeRule {
edge_kind: "prop".into(),
src_kinds: vec!["object".into()],
tgt_kinds: vec![],
}],
obj_kinds: vec!["object".into()],
constraint_sorts: vec!["maxLength".into()],
..Protocol::default()
}
}
#[test]
fn classify_removed_required_field_as_breaking() {
let diff = SchemaDiff {
removed_vertices: vec!["body.text".into()],
removed_edges: vec![Edge {
src: "body".into(),
tgt: "body.text".into(),
kind: "prop".into(),
name: Some("text".into()),
}],
..SchemaDiff::default()
};
let report = classify(&diff, &test_protocol());
assert!(!report.compatible, "removing a vertex should be breaking");
assert_eq!(report.breaking.len(), 2); }
#[test]
fn classify_added_optional_field_as_non_breaking() {
let diff = SchemaDiff {
added_vertices: vec!["body.newField".into()],
added_edges: vec![Edge {
src: "body".into(),
tgt: "body.newField".into(),
kind: "prop".into(),
name: Some("newField".into()),
}],
..SchemaDiff::default()
};
let report = classify(&diff, &test_protocol());
assert!(report.compatible, "adding a vertex should be non-breaking");
assert_eq!(report.non_breaking.len(), 2); assert!(report.breaking.is_empty());
}
#[test]
fn classify_constraint_tightening_as_breaking() {
let diff = SchemaDiff {
modified_constraints: std::iter::once((
"body.text".into(),
ConstraintDiff {
added: vec![],
removed: vec![],
changed: vec![crate::diff::ConstraintChange {
sort: "maxLength".into(),
old_value: "3000".into(),
new_value: "300".into(),
}],
},
))
.collect(),
..SchemaDiff::default()
};
let report = classify(&diff, &test_protocol());
assert!(
!report.compatible,
"tightening maxLength should be breaking"
);
assert!(
report
.breaking
.iter()
.any(|b| matches!(b, BreakingChange::ConstraintTightened { .. }))
);
}
#[test]
fn classify_constraint_relaxing_as_non_breaking() {
let diff = SchemaDiff {
modified_constraints: std::iter::once((
"body.text".into(),
ConstraintDiff {
added: vec![],
removed: vec![],
changed: vec![crate::diff::ConstraintChange {
sort: "maxLength".into(),
old_value: "300".into(),
new_value: "3000".into(),
}],
},
))
.collect(),
..SchemaDiff::default()
};
let report = classify(&diff, &test_protocol());
assert!(
report.compatible,
"relaxing maxLength should be non-breaking"
);
assert!(
report
.non_breaking
.iter()
.any(|nb| matches!(nb, NonBreakingChange::ConstraintRelaxed { .. }))
);
}
#[test]
fn classify_kind_change_as_breaking() {
let diff = SchemaDiff {
kind_changes: vec![KindChange {
vertex_id: "x".into(),
old_kind: "string".into(),
new_kind: "integer".into(),
}],
..SchemaDiff::default()
};
let report = classify(&diff, &test_protocol());
assert!(!report.compatible, "kind change should be breaking");
}
#[test]
fn classify_removed_non_governed_edge_as_non_breaking() {
let diff = SchemaDiff {
removed_edges: vec![Edge {
src: "body".into(),
tgt: "body.note".into(),
kind: "annotation".into(), name: Some("note".into()),
}],
..SchemaDiff::default()
};
let report = classify(&diff, &test_protocol());
assert!(
report.compatible,
"removing a non-governed edge should be non-breaking"
);
assert_eq!(report.non_breaking.len(), 1);
assert!(
report
.non_breaking
.iter()
.any(|nb| matches!(nb, NonBreakingChange::RemovedEdge { kind, .. } if kind == "annotation")),
"should produce RemovedEdge, not AddedEdge"
);
}
#[test]
fn classify_removed_governed_edge_as_breaking() {
let diff = SchemaDiff {
removed_edges: vec![Edge {
src: "body".into(),
tgt: "body.text".into(),
kind: "prop".into(), name: Some("text".into()),
}],
..SchemaDiff::default()
};
let report = classify(&diff, &test_protocol());
assert!(
!report.compatible,
"removing a governed edge should be breaking"
);
assert_eq!(report.breaking.len(), 1);
assert!(
report
.breaking
.iter()
.any(|b| matches!(b, BreakingChange::RemovedEdge { kind, .. } if kind == "prop"))
);
}
}